fix(cv): preserve rewrite language and qualify responsive document controls

This commit is contained in:
cesnimda
2026-10-25 22:55:22 +01:00
parent 2bfaf6c14b
commit 8948c988e2
12 changed files with 822 additions and 5 deletions
+4
View File
@@ -24,6 +24,10 @@ The source prerequisite now refuses letter inference before model invocation or
## Evidence to date (intermediate, not final acceptance)
Prompt review found and removed competing selected-language instructions for improve/grammar/shorten. Those actions now request source-language preservation exclusively; content generation retains the selected output language. Eighteen EN/Bokmål action/language cases cover this split. Tests were run before the fix: 12 failed and 53 passed, demonstrating the conflicting rewrite instructions. This is prompt-contract evidence, not actual-language model qualification.
The successor focused backend run passed65/65 (`prompt-language-green.trx`). Actual local sidecar8001 and Ollama11434 read-only health/inventory probes were refused; Docker had no running services at inspection. No model/service was started or downloaded. Actual inference remains BLOCKED by the absent approved local runtime, rather than being inferred from controlled responses. This does not block independent editor/persistence qualification.
`contextual-ai.trx`: 83 backend CV AI tests passed, including four action review/history/canonical-preservation cases. `ai-ui.json`: 61 combined AI/letter UI tests passed. New direct-action and empty-source tests were observed failing before implementation. The new cover-letter concurrent-edit regression also failed before response invalidation was added. These controlled responses do not demonstrate actual model quality.
| Action | Implementation | Frontend | Backend | Actual model / production |
@@ -31,3 +31,12 @@ Artifacts reside under `.artifacts/cv-ux-01/`.
Focused results overlap full suites and are never added to their totals. The first final browser attempt is incomplete/superseded and retained. Final regression must bind source/runtime hashes after the review correction.
Remaining: fresh ordinary/contract/RD21 browsers, responsive/localisation automation, provider regression, original Windows/Linux CV oracles and Linux letter fidelity. Actual self-hosted model semantics and human accessibility/device/language/print remain unqualified. Hosted isolation/MariaDB restrictions and production/Phase10 NO-GO remain. CV-IMPORT-01 is not started.
## Reviewed successor checkpoint
- Prompt language conflict corrected;65/65 focused backend cases pass after observed12-failure RED run.18 action/language cases added.
- Current frontend full suite972/972 across107 suites; all241 current frontend source/test files match the recorded pre-run hashes (`frontend-source-identity.json`). No focused totals are added.
- Fresh actual MariaDB provider gate56/56, source before/after equal and generated service cleanup confirmed (`provider-final-01`). Earlier SDK-selection/build-lock attempts are retained and superseded.
- Letter responsive browser case passes seven widths320–1440, EN/Bokmål, light/dark, keyboard pane/focus/draft preservation, root/main overflow and primary control hit testing (`letter-responsive-03`). The two predecessors expose fixture errors, not product passes: iframe init storage reset and wrong assumed900px breakpoint. The tested application theme breakpoint is1024px.
- Offline Linux adapter guard/lifecycle checks10/10. Current application publish is overlaid in a new app directory on the exact verified cached native runtime. The first build's unnecessary audit override was removed; its image is explicitly superseded. No advisories are suppressed in the successor helper. Native OS rebuilding and actual Linux rendering are distinct gates.
- Handoff requirements prepared in CV_IMPORT_HANDOFF.md; import implementation remains unstarted.
+13
View File
@@ -0,0 +1,13 @@
# CV-IMPORT-01 handoff — implementation not started
This document supplies compatibility requirements from CV-UX-01. It does not authorise or start Docling, model qualification, legacy-parser replacement or Phase10 cleanup.
Career Profile remains the canonical career-data owner. An eventual import must preview and explicitly reconcile facts through the established profile service; it must preserve CV-specific overrides, hidden/order choices, null-as-inherit design settings and existing revisions. CV settings history resolves the current Career Profile; it is not a historical full-profile snapshot.
Cover letters use the parent JobApplication.Version for optimistic concurrency. Every intended writer stages current text, normalized document settings and history through ApplicationAssetsService before the parent transaction commits. Preserve generic null-clear versus draft null-unchanged semantics, no-op acknowledgements, recovery of previously uncovered legacy text, restore-as-new and owner filtering. Do not introduce an independent revision counter or write CoverLetterText directly from import code.
Contact/design copies are explicit reviewed snapshots. Imported facts or later CV edits must not silently rewrite saved letters. Historical text-only letters and submitted packages remain readable. New submitted packages freeze letter HTML/settings and CV output; future source changes must not rewrite their bytes. Account/backup portability includes letter design/history.
CV AI proposals are bound to owned source/revision capabilities and reviewed acceptance. Letter AI requires an owned linked CV with actual visible career evidence. Advert requirements, identity/header text and a previous letter do not establish experience. Import text is untrusted data, never model instructions. Reuse the existing approved self-hosted infrastructure; do not infer semantic, GPU or production readiness from CPU compatibility or mocked UI tests.
Before CV-IMPORT entry: read CV-UX final acceptance and its open gates, verify the accepted branch/commit and current architecture, re-establish supported-provider migration/rollback evidence and preserve frozen fixtures. Keep the legacy parser until the separately authorised replacement is qualified. Production, hosted isolation and security/recovery decisions remain separate.
+21
View File
@@ -0,0 +1,21 @@
# CV-UX-01 checkpoint ledger
- [x] Verify accepted baseline `28f83df`, clean checkout and current architecture.
- [x] Create requested feature branch and formal scope/entry decision.
- [x] CV-UX-00: finish research traceability/import and validate portable evidence.
- [ ] CV-UX-01: workspace/context/history improvements with preserved save owners.
- [ ] CV-UX-02: section interaction/focus/accessibility improvements.
- [ ] CV-UX-03: actual candidate preview and explicit reversible application.
- [ ] CV-UX-04: qualify all-writer letter dependency and approved document integration.
- [ ] CV-UX-05: persistence/recovery/concurrency preservation.
- [ ] CV-UX-06: responsive/accessibility/localisation qualification.
- [ ] CV-UX-AI: four contextual actions, source-grounded review, stale-response protection, EN/Bokmål and separate actual-model qualification; see AI.md.
- [ ] CV-UX-07: full regression/PDF/acceptance and CV-IMPORT handoff.
Entry ruling: user explicitly authorises autonomous implementation; no additional design-approval round is required. Reuse the clean requested branch in place, retaining installed build/test dependencies. No worktree/setup dependency installation is necessary.
Implementation/focused checks are complete for CV-UX-01–05 and CV-UX-AI; their acceptance boxes remain open pending final integration reconciliation. See CHECKPOINT_IMPLEMENTATION.md for intermediate evidence, including superseded attempts. Mocked AI results do not establish model quality.
Reviewed successor:65/65 prompt contract tests,972/972 full frontend,56/56 fresh actual MariaDB and one seven-scenario responsive letter browser case passed. Linux lifecycle self-checks10/10. Full ordinary discovery increases from362 historical to364 (one physical letter producer and one responsive case). Final independent contract/PDF/browser lanes remain open. Actual local inference is blocked by absent approved local services; human/hosted/production gates remain open.
Historical evidence: Phase 9 conditional acceptance is inherited, not fresh CV-UX test execution. Hosted isolation/production/manual/provider/AI semantic limitations remain open. Research checksum verification: 83/83; screenshot pairs: 37/37; contact sheets inspected locally. Raw third-party imagery stays outside Git/product assets.
+377
View File
@@ -0,0 +1,377 @@
"""Offline current-source image preparation and disposable Linux PDF qualification.
Build and run are explicit, separate commands. No dependency download or production service.
"""
import argparse
import datetime
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[2]
BASE = 'sha256:0d1cb44ba8f71d54de77459915a7052b894191cfeb75aa32f0c1e7ffdc21fda6'
PIN = 'mariadb:12.2.2@sha256:42a27baaba3e8f3f0159d16381d5a7156661aa91dcc65f663cabb6a6f9503c00'
APP = '/app/cv-ux-current'
CONFIG = '../docs/cv-redesign/linux/playwright.config.ts'
PASSWORD = 'jt-integration-only'
AUTH_PASSWORD = 'E2ePassword123!'
JWT = 'e2e-only-key-at-least-thirty-two-characters-long'
def utc():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def scrub(value):
text = str(value)
for secret in (PASSWORD, AUTH_PASSWORD, JWT):
text = text.replace(secret, '[synthetic-test-secret]')
return text
def digest(value):
return hashlib.sha256(value).hexdigest()
def identity(files):
return digest(json.dumps(files, sort_keys=True, separators=(',', ':')).encode())
def write(path, value):
path.write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8')
def fresh(path):
path = Path(path).resolve()
allowed = (ROOT / '.artifacts/cv-ux-01').resolve()
if not path.is_relative_to(allowed) or path == allowed or path.exists():
raise ValueError('Select a nonexistent child of .artifacts/cv-ux-01; previous attempts are immutable.')
path.mkdir(parents=True)
return path
class Commands:
def __init__(self, out):
self.out = out
self.records = []
def run(self, args, *, env=None, cwd=ROOT, check=True, timeout=600):
item = {'startedUtc': utc(), 'argv': [scrub(a) for a in args], 'cwd': str(cwd)}
self.records.append(item)
try:
result = subprocess.run(['rtk', 'proxy', *map(str, args)], cwd=cwd, env=env,
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout)
item['exitCode'] = result.returncode
output = result.stdout + result.stderr
log = self.out / f'command-{len(self.records):03}.log'
log.write_text(scrub(output), encoding='utf-8')
item['log'] = str(log)
item['logSha256'] = digest(log.read_bytes())
if check and result.returncode:
raise RuntimeError(f'Command {len(self.records)} failed with exit {result.returncode}; inspect {log.name}')
return result.stdout.strip()
finally:
item['finishedUtc'] = utc()
write(self.out / 'commands.json', self.records)
def sources(commands):
paths = commands.run(['git', '-c', 'core.quotePath=false', 'ls-files', '--cached', '--others', '--exclude-standard']).splitlines()
prefixes = ('JobTrackerApi/', 'job-tracker-ui/src/', 'job-tracker-ui/e2e/')
exact = {'global.json', '.dockerignore', 'docker-compose.database.yml', 'NuGet.Config', 'nuget.config', 'job-tracker-ui/package.json', 'job-tracker-ui/package-lock.json',
'scripts/verify-cv-fidelity.py', 'docs/redesign/phase-9/p9-requal-01/pdf-proof.py'}
selected = [name for name in paths if name.startswith(prefixes) or name in exact
or name.startswith(('Directory.', 'job-tracker-ui/playwright', 'job-tracker-ui/scripts/'))
or name.startswith('docs/cv-redesign/') and name.endswith(('.py', '.ts', '.json'))]
return {name: digest((ROOT / name).read_bytes()) for name in sorted(set(selected)) if (ROOT / name).is_file()}
def tree(path):
return {file.relative_to(path).as_posix(): digest(file.read_bytes()) for file in sorted(path.rglob('*')) if file.is_file()}
def image_id(value):
if not re.fullmatch(r'sha256:[a-f0-9]{64}', value):
raise ValueError('An immutable local image ID is required.')
return value
def schema_name(value):
if not re.fullmatch(r'jt_it_[a-f0-9]{32}', value):
raise ValueError('Only a generated disposable schema is allowed.')
return value
def guard_db(data, name, project, port):
if not re.fullmatch(r'jobtracker-it-[a-f0-9]{32}', project) or name != project + '-mariadb-test-1':
raise ValueError('Unexpected generated DB owner.')
labels = data.get('Config', {}).get('Labels', {})
host = data.get('HostConfig', {})
if (data.get('Name') != '/' + name or data['Config']['Image'] != PIN
or labels.get('com.docker.compose.project') != project
or labels.get('com.docker.compose.service') != 'mariadb-test'
or not data['State']['Running'] or data['State'].get('Health', {}).get('Status') != 'healthy'
or host.get('Privileged') or host.get('NetworkMode') == 'host'
or '/var/lib/mysql' not in host.get('Tmpfs', {})
or host.get('PortBindings', {}).get('3306/tcp') != [{'HostIp': '127.0.0.1', 'HostPort': str(port)}]
or any(mount.get('Destination') == '/var/lib/mysql' and mount.get('Type') != 'tmpfs' for mount in data.get('Mounts', []))):
raise ValueError('DB must be the exact healthy pinned loopback tmpfs test service.')
def verify_build(record, expected_image, current_sources):
if (record.get('exitCode') != 0 or record.get('baseImageId') != BASE
or record.get('imageId') != image_id(expected_image)
or record.get('applicationPath') != APP
or record.get('sourceSha256') != identity(current_sources)
or record.get('sources') != current_sources):
raise ValueError('Current source/build/image identity differs; rebuild in a fresh root.')
def build(args):
out = fresh(args.output)
commands = Commands(out)
record = {'startedUtc': utc(), 'exitCode': 1, 'baseImageId': BASE, 'applicationPath': APP}
try:
record['sources'] = sources(commands)
record['sourceSha256'] = identity(record['sources'])
record['commit'] = commands.run(['git', 'rev-parse', 'HEAD'])
record['status'] = commands.run(['git', 'status', '--short'])
record['dotnetInfo'] = commands.run([args.dotnet, '--info'])
assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', BASE]) == BASE
record['baseMetadata'] = json.loads(commands.run(['docker', 'image', 'inspect', '--format', '{{json .Config.Labels}}', BASE]))
native_tag = 'jobtracker-cv-ux-native:' + uuid.uuid4().hex
commands.run(['docker', 'tag', BASE, native_tag])
assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', native_tag]) == BASE
record['baseImageTag'] = native_tag
publish = out / 'publish'
env = {**os.environ, 'DOTNET_ROOT': str(Path(args.dotnet).parent)}
record['publishEnvironment'] = {'DOTNET_ROOT': env['DOTNET_ROOT'], 'NuGetAudit': env.get('NuGetAudit')}
commands.run([args.dotnet, 'publish', 'JobTrackerApi/JobTrackerApi.csproj', '--configuration', 'Release',
'--no-restore', '--output', str(publish), '/p:UseAppHost=false', '--self-contained', 'false'], env=env)
record['publish'] = tree(publish)
record['publishSha256'] = identity(record['publish'])
if 'JobTrackerApi.dll' not in record['publish'] or 'JobTrackerApi.deps.json' not in record['publish']:
raise ValueError('Portable current API output is incomplete.')
dockerfile = out / 'Dockerfile'
dockerfile.write_text(f'FROM {native_tag}\nWORKDIR {APP}\nCOPY publish/ ./\n'
+ f'LABEL io.jobjakt.cv-ux.source-sha256="{record["sourceSha256"]}" '
+ f'io.jobjakt.cv-ux.publish-sha256="{record["publishSha256"]}"\n'
+ 'ENTRYPOINT ["dotnet","JobTrackerApi.dll"]\n', encoding='utf-8')
record['dockerfileSha256'] = digest(dockerfile.read_bytes())
tag = 'jobtracker-cv-ux-api:' + uuid.uuid4().hex
commands.run(['docker', 'build', '--network', 'none', '--pull=false', '--file', str(dockerfile), '--tag', tag, str(out)])
record['imageTag'] = tag
record['imageId'] = image_id(commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', tag]))
if record['sources'] != sources(commands) or record['publish'] != tree(publish):
raise ValueError('Source or publish bytes changed during build.')
record['exitCode'] = 0
except Exception as error:
record['error'] = scrub(error)
finally:
record['finishedUtc'] = utc()
write(out / 'build.json', record)
print(json.dumps({'exitCode': record['exitCode'], 'record': str(out / 'build.json')}))
return record['exitCode']
def free_port(port=0):
with socket.socket() as probe:
probe.bind(('127.0.0.1', port))
return probe.getsockname()[1]
def auth_identity(version, commit):
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
request = urllib.request.Request('http://localhost:5302/api/auth/login',
data=json.dumps({'email': 'e2e@example.test', 'password': AUTH_PASSWORD}).encode(),
headers={'Content-Type': 'application/json', 'Origin': 'http://localhost:3300'})
with opener.open(request, timeout=10) as response:
response.read()
with opener.open('http://localhost:5302/api/auth/me', timeout=10) as response:
me = json.load(response)
if me.get('appVersion') != version or me.get('appCommitSha') != commit:
raise ValueError('Authenticated API build metadata mismatch.')
return {'appVersion': me['appVersion'], 'appCommitSha': me['appCommitSha']}
def run(args):
out = fresh(args.output)
commands = Commands(out)
record = {'startedUtc': utc(), 'exitCode': 1, 'cleanup': {}}
suffix = uuid.uuid4().hex
project = 'jobtracker-it-' + suffix
db = project + '-mariadb-test-1'
network = 'jobtracker-cv-ux-' + suffix
api = network + '-api'
schema = schema_name('jt_it_' + uuid.uuid4().hex)
compose = ['docker', 'compose', '--project-name', project, '--project-directory', str(ROOT),
'-f', str(ROOT / 'docker-compose.database.yml'), '--profile', 'integration']
db_attempted = created = attached = network_created = api_created = False
def sql(query):
return commands.run(['docker', 'exec', '-e', 'MYSQL_PWD=' + PASSWORD, db,
'mariadb', '-uroot', '--batch', '--skip-column-names', '-e', query])
def clean(key, action):
try:
action()
record['cleanup'][key] = True
except Exception as error:
record['cleanup'][key + 'Error'] = scrub(error)
record['exitCode'] = 1
try:
build_record = json.loads(Path(args.build_record).read_text(encoding='utf-8'))
current_sources = sources(commands)
verify_build(build_record, args.image, current_sources)
publish = Path(args.build_record).resolve().parent / 'publish'
if tree(publish) != build_record['publish']:
raise ValueError('Published API output drifted after build.')
static = ROOT / 'job-tracker-ui/out'
record['frontend'] = tree(static)
if not record['frontend']:
raise ValueError('Prebuilt frontend output is required.')
record.update({'buildRecord': str(Path(args.build_record).resolve()), 'buildRecordSha256': digest(Path(args.build_record).read_bytes()),
'sourceSha256': build_record['sourceSha256'], 'imageId': args.image, 'commit': build_record['commit'],
'schema': schema, 'dbContainer': db, 'dbProject': project, 'apiContainer': api, 'network': network})
image = json.loads(commands.run(['docker', 'image', 'inspect', args.image]))[0]
if (image['Id'] != args.image or image['Config']['WorkingDir'] != APP
or image['Config'].get('Labels', {}).get('io.jobjakt.cv-ux.source-sha256') != record['sourceSha256']
or image['Config'].get('Labels', {}).get('io.jobjakt.cv-ux.publish-sha256') != build_record['publishSha256']):
raise ValueError('Image source/publish labels or current app directory mismatch.')
free_port(5302)
free_port(3300)
record['chromium'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', '/usr/bin/chromium', args.image, '--version'])
record['runtime'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'dotnet', args.image, '--info'])
record['apiDllSha256'] = commands.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'sha256sum', args.image, APP + '/JobTrackerApi.dll']).split()[0]
if record['apiDllSha256'] != build_record['publish']['JobTrackerApi.dll']:
raise ValueError('Image API DLL differs from current publish bytes.')
port = free_port()
record['dbPort'] = port
environment = {**os.environ, 'MARIADB_TEST_PORT': str(port)}
assert commands.run(['docker', 'image', 'inspect', '--format', '{{.Id}}', PIN])
db_attempted = True
commands.run(compose + ['up', '--pull', 'never', '-d', '--wait', '--wait-timeout', '120', 'mariadb-test'], env=environment)
guard_db(json.loads(commands.run(['docker', 'inspect', db]))[0], db, project, port)
record['serverVersion'] = sql('SELECT VERSION()')
if not record['serverVersion'].startswith('12.2.2-MariaDB'):
raise ValueError('Exact MariaDB 12.2.2 is required.')
assert sql(f"SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='{schema}'") == '0'
sql(f'CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci')
created = True
commands.run(['docker', 'network', 'create', '--internal', '--label', 'task=cv-ux-01', network])
network_created = True
commands.run(['docker', 'network', 'connect', '--alias', 'mariadb', network, db])
attached = True
version = 'cv-ux-linux-' + record['sourceSha256'][:12]
variables = {'ASPNETCORE_ENVIRONMENT': 'Development', 'Database__Provider': 'mariadb', 'Database__ServerVersion': '12.2.2',
'ConnectionStrings__JobTracker': f'Server=mariadb;Database={schema};User ID=root;Password={PASSWORD};SslMode=None',
'Auth__Require': 'true', 'Auth__JwtKey': JWT, 'Auth__AdminEmail': 'e2e@example.test', 'Auth__AdminPassword': AUTH_PASSWORD,
'Auth__AllowRegistration': 'false', 'RateLimits__AuthLoginPermitLimit': '1000', 'Cors__Origins__0': 'http://localhost:3300',
'Workers__AiOperationsEnabled': 'false', 'Workers__SavedJobSearchAlertsEnabled': 'false', 'HttpsRedirection__Enabled': 'false',
'App__Version': version, 'App__CommitSha': record['commit'], 'Data__Root': '/data', 'Logging__LogLevel__Default': 'Warning'}
create = ['docker', 'create', '--name', api, '--label', 'task=cv-ux-01', '--network', network,
'-p', '127.0.0.1:5302:8080', '--tmpfs', '/data:rw,nosuid,size=512m']
for key, value in variables.items():
create += ['-e', key + '=' + value]
api_created = True
commands.run(create + [args.image])
assert commands.run(['docker', 'inspect', '--format', '{{.Image}}', api]) == args.image
commands.run(['docker', 'start', api])
deadline = time.monotonic() + 120
while True:
try:
with urllib.request.urlopen('http://localhost:5302/health', timeout=3) as response:
health = json.load(response)
if health.get('version') != version:
raise ValueError('Linux API health version mismatch.')
with urllib.request.urlopen('http://localhost:5302/ready', timeout=3) as response:
assert json.load(response)['status'] == 'ready'
record['health'] = health
record['apiBuildMetadata'] = auth_identity(version, record['commit'])
break
except Exception:
if time.monotonic() >= deadline:
raise
time.sleep(1)
migration_sql = f'SELECT MigrationId FROM `{schema}`.`__EFMigrationsHistory` ORDER BY MigrationId'
record['migrations'] = sql(migration_sql).splitlines()
if '20261025090000_AddCoverLetterDocumentDesign' not in record['migrations']:
raise ValueError('Current letter design migration was not applied.')
env = {**os.environ, 'CV_UX_LINUX_READY': version, 'CV_UX_LINUX_SOURCE': record['sourceSha256'],
'PLAYWRIGHT_STATIC_EXPORT': 'true', 'PLAYWRIGHT_PREBUILT_EXPORT': 'true', 'PLAYWRIGHT_REUSE_SERVERS': 'false',
'NEXT_PUBLIC_API_BASE_URL': 'http://localhost:5302/api', 'CV_FIDELITY_OUTPUT': str(out / 'fidelity'),
'CV_SETTINGS_FIDELITY_OUTPUT': str(out / 'settings-fidelity'), 'LETTER_FIDELITY_OUTPUT': str(out / 'letter-fidelity'),
'PLAYWRIGHT_JSON_OUTPUT_NAME': str(out / 'linux-renderer.json')}
commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + CONFIG, '--retries=0',
'--reporter=list,json', '--output=' + str(out / 'test-results')], cwd=ROOT / 'job-tracker-ui', env=env, timeout=1800)
native = json.loads((out / 'linux-renderer.json').read_text(encoding='utf-8'))
stats = native.get('stats', {})
if stats.get('expected') != 6 or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'):
raise ValueError('Expected six passing browser cases with no skips, retries or errors.')
record['browserStats'] = stats
if sql(migration_sql).splitlines() != record['migrations'] or sources(commands) != current_sources or tree(static) != record['frontend']:
raise ValueError('Source/runtime/migration drift during rendering.')
for folder in ('fidelity', 'letter-fidelity'):
if not (out / folder / 'measurements.json').is_file():
raise ValueError('Native corpus measurements missing: ' + folder)
cv = json.loads((out / 'fidelity/measurements.json').read_text(encoding='utf-8'))
letter = json.loads((out / 'letter-fidelity/measurements.json').read_text(encoding='utf-8'))
settings_files = list((out / 'settings-fidelity').glob('*-measurements.json'))
if len(cv.get('fixtures', [])) != 16 or len(settings_files) != 4 or sum(len(json.loads(file.read_text(encoding='utf-8')).get('fixtures', [])) for file in settings_files) != 8:
raise ValueError('The original CV16 + settings8 document membership is incomplete.')
if {fixture.get('fixture') for fixture in letter.get('fixtures', [])} != {'light-en-a4', 'dark-nb-a4', 'light-nb-letter', 'dark-en-letter'}:
raise ValueError('The independent letter4 document membership is incomplete.')
record['exitCode'] = 0
except Exception as error:
record['error'] = scrub(error)
finally:
if api_created:
clean('apiLogsCaptured', lambda: commands.run(['docker', 'logs', api]))
clean('apiRemoved', lambda: commands.run(['docker', 'rm', '--force', api]))
if attached:
clean('databaseDisconnected', lambda: commands.run(['docker', 'network', 'disconnect', network, db]))
if network_created:
clean('networkRemoved', lambda: commands.run(['docker', 'network', 'rm', network]))
if created and (not api_created or record['cleanup'].get('apiRemoved')):
def remove_schema():
sql(f'DROP DATABASE `{schema_name(schema)}`')
assert sql(f"SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME='{schema}'") == '0'
clean('schemaRemoved', remove_schema)
elif created:
record['cleanup']['schemaRemovedError'] = 'API removal unconfirmed; schema retained.'
record['exitCode'] = 1
if db_attempted:
clean('dbLogsCaptured', lambda: commands.run(compose + ['logs', '--no-color', 'mariadb-test']))
clean('databaseRemoved', lambda: commands.run(compose + ['rm', '--stop', '--force', 'mariadb-test']))
record['finishedUtc'] = utc()
write(out / 'process.json', record)
print(json.dumps({'exitCode': record['exitCode'], 'record': str(out / 'process.json')}))
return record['exitCode']
def main():
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_subparsers(dest='mode', required=True)
image = modes.add_parser('build')
image.add_argument('--output', required=True)
image.add_argument('--dotnet', default='C:/Users/Connor/.dotnet/dotnet.exe')
renderer = modes.add_parser('run')
renderer.add_argument('--output', required=True)
renderer.add_argument('--build-record', required=True)
renderer.add_argument('--image', required=True)
args = parser.parse_args()
return build(args) if args.mode == 'build' else run(args)
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,17 @@
import { defineConfig } from '../../../job-tracker-ui/node_modules/@playwright/test';
import base from '../../../job-tracker-ui/playwright.config';
import path from 'node:path';
const source = process.env.CV_UX_LINUX_SOURCE;
if (!source || !/^[a-f0-9]{64}$/.test(source) || process.env.CV_UX_LINUX_READY !== `cv-ux-linux-${source.slice(0, 12)}`) {
throw new Error('Use the current-source CV-UX Linux lifecycle and its immutable image.');
}
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
export default defineConfig({ ...base,
testDir: path.resolve('e2e'),
testMatch: ['cv-readiness.spec.ts', 'cv-settings-presentation.spec.ts', 'cover-letter-document-fidelity.spec.ts'],
grep: /native preview\/PDF fidelity corpus|document controls preserve settings|cover letter native preview\/PDF fidelity/,
workers: 1, retries: 0,
webServer: [{ ...servers[1], cwd: path.resolve(__dirname, '../../../job-tracker-ui'), reuseExistingServer: false }],
metadata: { linuxApiIdentity: process.env.CV_UX_LINUX_READY, sourceSha256: source, mariaDbVersion: '12.2.2', cvDocuments: 24, letterDocuments: 4 },
});
+210
View File
@@ -0,0 +1,210 @@
"""Offline lifecycle failure checks. These tests never call Docker, .NET or a browser."""
import contextlib
import copy
import importlib.util
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('cv_ux_linux', Path(__file__).with_name('linux-run.py'))
runner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runner)
IMAGE = 'sha256:' + 'b' * 64
SOURCE = {'JobTrackerApi/Program.cs': 'a' * 64}
COMMIT = 'c' * 40
PROJECT = 'jobtracker-it-' + 'a' * 32
DB = PROJECT + '-mariadb-test-1'
def database(port=33309):
return {'Name': '/' + DB, 'Config': {'Image': runner.PIN, 'Labels': {
'com.docker.compose.project': PROJECT, 'com.docker.compose.service': 'mariadb-test'}},
'State': {'Running': True, 'Health': {'Status': 'healthy'}},
'HostConfig': {'Tmpfs': {'/var/lib/mysql': ''}, 'NetworkMode': 'bridge',
'PortBindings': {'3306/tcp': [{'HostIp': '127.0.0.1', 'HostPort': str(port)}]}},
'Mounts': [{'Destination': '/var/lib/mysql', 'Type': 'tmpfs'}]}
def build_record():
return {'exitCode': 0, 'baseImageId': runner.BASE, 'imageId': IMAGE, 'applicationPath': runner.APP,
'sources': SOURCE, 'sourceSha256': runner.identity(SOURCE), 'commit': COMMIT,
'publish': {'JobTrackerApi.dll': 'f' * 64}, 'publishSha256': 'e' * 64}
class GuardTests(unittest.TestCase):
def test_mutable_or_wrong_image_and_source_are_rejected(self):
for image in ('jobtracker:latest', 'sha256:abc', 'sha256:' + 'd' * 64):
with self.assertRaises(ValueError):
runner.verify_build(build_record(), image, SOURCE)
with self.assertRaises(ValueError):
runner.verify_build(build_record(), IMAGE, {'different-source': '0' * 64})
def test_only_generated_schema_can_be_removed(self):
self.assertEqual(runner.schema_name('jt_it_' + 'a' * 32), 'jt_it_' + 'a' * 32)
for name in ('jobtracker', 'jt_it_abc', 'jt_it_' + 'a' * 32 + ';DROP DATABASE x'):
with self.assertRaises(ValueError):
runner.schema_name(name)
def test_wrong_database_identity_or_persistent_storage_is_rejected(self):
runner.guard_db(database(), DB, PROJECT, 33309)
mutations = [('image', lambda d: d['Config'].update(Image='mariadb:latest')),
('project', lambda d: d['Config']['Labels'].update({'com.docker.compose.project': 'production'})),
('port', lambda d: d['HostConfig']['PortBindings'].update({'3306/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '33309'}]})),
('persistent', lambda d: d['Mounts'][0].update(Type='volume')),
('privileged', lambda d: d['HostConfig'].update(Privileged=True)),
('host', lambda d: d['HostConfig'].update(NetworkMode='host'))]
for label, mutate in mutations:
with self.subTest(label=label):
value = copy.deepcopy(database())
mutate(value)
with self.assertRaises(ValueError):
runner.guard_db(value, DB, PROJECT, 33309)
def test_existing_or_outside_evidence_root_is_rejected(self):
with tempfile.TemporaryDirectory() as directory, patch.object(runner, 'ROOT', Path(directory)):
root = Path(directory) / '.artifacts/cv-ux-01/attempt'
runner.fresh(root)
with self.assertRaises(ValueError):
runner.fresh(root)
with self.assertRaises(ValueError):
runner.fresh(Path(directory) / 'outside')
class LifecycleTests(unittest.TestCase):
def test_image_preparation_uses_current_portable_publish_and_offline_new_app_directory(self):
calls = []
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
out = root / '.artifacts/cv-ux-01/image'
class Commands:
def __init__(self, output):
pass
def run(self, args, **kwargs):
calls.append(args)
if 'publish' in args:
publish = Path(args[args.index('--output') + 1])
publish.mkdir()
(publish / 'JobTrackerApi.dll').write_bytes(b'current compiled API')
(publish / 'JobTrackerApi.deps.json').write_text('{}')
if args[:3] == ['docker', 'image', 'inspect']:
if '{{json .Config.Labels}}' in args:
return '{}'
return runner.BASE if args[-1] == runner.BASE or args[-1].startswith('jobtracker-cv-ux-native:') else IMAGE
return COMMIT if args == ['git', 'rev-parse', 'HEAD'] else ''
with patch.object(runner, 'ROOT', root), patch.object(runner, 'Commands', Commands), \
patch.object(runner, 'sources', return_value=SOURCE), contextlib.redirect_stdout(io.StringIO()):
code = runner.build(SimpleNamespace(output=str(out), dotnet='local-dotnet'))
self.assertEqual(code, 0)
record = json.loads((out / 'build.json').read_text())
self.assertEqual(record['imageId'], IMAGE)
self.assertEqual(record['sources'], SOURCE)
self.assertIn('WORKDIR ' + runner.APP, (out / 'Dockerfile').read_text())
self.assertNotEqual(runner.APP, '/app')
publish = next(args for args in calls if 'publish' in args)
self.assertIn('--no-restore', publish)
self.assertIn('/p:UseAppHost=false', publish)
docker = next(args for args in calls if args[:2] == ['docker', 'build'])
self.assertEqual(docker[docker.index('--network') + 1], 'none')
self.assertIn('--pull=false', docker)
def exercise(self, failure=None):
calls = []
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
out = root / '.artifacts/cv-ux-01/renderer'
saved = root / 'build.json'
saved.write_text(json.dumps(build_record()), encoding='utf-8')
class Commands:
def __init__(self, output):
self.out = output
def run(self, args, **kwargs):
calls.append(args)
if args[:3] == ['docker', 'image', 'inspect'] and '--format' not in args:
return json.dumps([{'Id': IMAGE, 'Config': {'WorkingDir': runner.APP, 'Labels': {
'io.jobjakt.cv-ux.source-sha256': runner.identity(SOURCE),
'io.jobjakt.cv-ux.publish-sha256': 'e' * 64}}}])
if args[:2] == ['docker', 'inspect'] and args[-1] == DB:
return json.dumps([database()])
if args[:2] == ['docker', 'inspect']:
return IMAGE
if '--entrypoint' in args and args[args.index('--entrypoint') + 1] == 'sha256sum':
return 'f' * 64 + ' ' + runner.APP + '/JobTrackerApi.dll'
if args[:2] == ['docker', 'exec']:
query = args[-1]
if query == 'SELECT VERSION()':
return '12.2.2-MariaDB-offline-test'
if '__EFMigrationsHistory' in query:
return '20261025090000_AddCoverLetterDocumentDesign'
return '0'
if args[:2] == ['docker', 'logs'] and failure == 'log':
raise OSError('Synthetic log failure')
if args[:3] == ['docker', 'rm', '--force'] and failure == 'cleanup':
raise OSError('Synthetic removal failure')
if args[0] == 'node':
if failure == 'browser':
raise OSError('Synthetic browser failure')
runner.write(out / 'linux-renderer.json', {'stats': {'expected': 6, 'unexpected': 0, 'flaky': 0, 'skipped': 0}, 'errors': []})
for name, fixtures in [('fidelity', [{}] * 16), ('letter-fidelity', [{'fixture': name} for name in ['light-en-a4', 'dark-nb-a4', 'light-nb-letter', 'dark-en-letter']])]:
(out / name).mkdir()
runner.write(out / name / 'measurements.json', {'fixtures': fixtures})
(out / 'settings-fidelity').mkdir()
for index in range(4):
runner.write(out / 'settings-fidelity' / f'{index}-measurements.json', {'fixtures': [{}, {}]})
return 'synthetic-runtime-identity'
def urlopen(url, **kwargs):
if failure == 'startup':
raise OSError('Synthetic startup failure')
return io.BytesIO(json.dumps({'version': 'cv-ux-linux-' + runner.identity(SOURCE)[:12]} if url.endswith('/health') else {'status': 'ready'}).encode())
options = SimpleNamespace(output=str(out), image=IMAGE, build_record=str(saved))
with patch.object(runner, 'ROOT', root), patch.object(runner, 'Commands', Commands), \
patch.object(runner, 'sources', return_value=SOURCE), \
patch.object(runner, 'tree', side_effect=lambda p: build_record()['publish'] if p.name == 'publish' else {'index.html': '1' * 64}), \
patch.object(runner, 'free_port', side_effect=lambda port=0: port or 33309), \
patch.object(runner.uuid, 'uuid4', return_value=SimpleNamespace(hex='a' * 32)), \
patch.object(runner.urllib.request, 'urlopen', urlopen), \
patch.object(runner, 'auth_identity', return_value={'appVersion': 'synthetic', 'appCommitSha': COMMIT}), \
patch.object(runner.time, 'monotonic', side_effect=[0, 121] if failure == 'startup' else lambda: 0), \
contextlib.redirect_stdout(io.StringIO()):
code = runner.run(options)
record = json.loads((out / 'process.json').read_text(encoding='utf-8'))
self.assertEqual(code, 0 if failure is None else 1)
self.assertEqual(record['exitCode'], code)
if failure == 'cleanup':
self.assertIn('apiRemovedError', record['cleanup'])
self.assertNotIn('schemaRemoved', record['cleanup'])
else:
self.assertTrue(record['cleanup']['apiRemoved'])
self.assertTrue(record['cleanup']['schemaRemoved'])
self.assertTrue(record['cleanup']['databaseRemoved'])
self.assertTrue(any(args[:3] == ['docker', 'rm', '--force'] for args in calls))
return calls
def test_success_records_positive_cleanup(self):
self.exercise()
def test_api_startup_failure_still_cleans_every_owned_resource(self):
self.exercise('startup')
def test_log_failure_cannot_suppress_removal_or_pass(self):
self.exercise('log')
def test_api_removal_failure_prevents_schema_drop_and_pass(self):
self.exercise('cleanup')
def test_browser_failure_cleans_resources_and_stays_failed(self):
self.exercise('browser')
if __name__ == '__main__':
unittest.main()