From 6e0a878dcbf84e39e4a90f1aff86879f19c0a28c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci Date: Wed, 7 Oct 2026 03:18:03 +0100 Subject: [PATCH 1/3] Keep deferred Vault completion and content identity accurate --- docs/qa/2026-10-07-backlog/issue-389.md | 23 ++++++++++ src/castle-practice.js | 11 ++++- tests/castle-practice.test.cjs | 56 ++++++++++++++++++++++++ tests/content-manifest-revision.test.cjs | 32 ++++++++++++++ tools/build.cjs | 8 +++- 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 docs/qa/2026-10-07-backlog/issue-389.md create mode 100644 tests/content-manifest-revision.test.cjs diff --git a/docs/qa/2026-10-07-backlog/issue-389.md b/docs/qa/2026-10-07-backlog/issue-389.md new file mode 100644 index 00000000..882f3a22 --- /dev/null +++ b/docs/qa/2026-10-07-backlog/issue-389.md @@ -0,0 +1,23 @@ +# Issue 389 - deferred Vault follow-ups + +2026-10-07, branch codex/backlog-389-20261007, based on f084aa6. + +## Changed + +Castle practice accepts the registered scalar listing metadata as the identity of a historical firstCompletedAt run while a Vault chunk is pending or failed. Loaded definitions still require the exact saved body; a current completion still requires the normal completion predicate. The build content revision now hashes both initial and deferred definition scripts. IDs, revisions, lazy loading and save format are unchanged. + +Item 2 already has a pending route, loading indicator and timeout in current source. The existing deferred-route and deferred-loading regressions pass; no new app.js change was required. + +## Verified + +Coordinator npm.cmd run build succeeds. Focused Node suite with --test-isolation=none --test-concurrency=1 over castle-practice, content-manifest-revision, official-deferred, deferred-route, deferred-loading and budget passes 30/30, no skips/cancellations (112755ms). Emitted identity matches both payloads. Prettier on all four changed files passes. + +Measured JavaScript gzip: 134941 bytes against the unchanged strict 134944-byte limit. Core offline bytes: 1892675. Integration has only three gzip bytes of headroom, so a combined exact-head build must re-prove the existing budget; this slice does not relax it. + +## NOT verified + +Physical Android/TalkBack and actual browser practice pending/failure rendering are unverified. No deployment, hosted CI or publication. Independent review remains pending. The first process-isolated test run used an incorrect budget filename and stale build; its emitted identity assertion failed. Rebuilding and the scoped no-isolation run above provide the final proof. The initial diagnosis of a hanging process was not sustained: the runner was consuming CPU validating the catalogue. + +## Residual risk + +Grok 4.7 high reached its 25-turn cap after the useful changes, so the coordinator owns completion. Muse lane creation was refused because current host kraspyon differs from registry owner desktop-ihkoojs; no bypass or Muse job ran. HUMAN_TODO.md remains authoritative and was not edited. Root owns central STATE updates and publication. Existing PR #572 practice interactions require root integration review. diff --git a/src/castle-practice.js b/src/castle-practice.js index 921c618c..696e491a 100644 --- a/src/castle-practice.js +++ b/src/castle-practice.js @@ -143,7 +143,16 @@ } function sameDefinition(left, right) { - return !!left && !!right && stable(left) === stable(right); + // A pending or failed Vault chunk leaves a listing: no solution, and its public fields are + // scalars. A loaded definition still has to match exactly, including its body. + return ( + !!left && + !!right && + (stable(left) === stable(right) || + (!right.solution && + right.id && + Object.keys(right).every((key) => left[key] === right[key]))) + ); } function dateMarker(value) { diff --git a/tests/castle-practice.test.cjs b/tests/castle-practice.test.cjs index 37a719ce..4afadd44 100644 --- a/tests/castle-practice.test.cjs +++ b/tests/castle-practice.test.cjs @@ -132,6 +132,62 @@ test('a malformed committed read makes the adapter unavailable and open accepts assert.equal(await adapter.open(puzzle.id, 'kitchen'), false); }); +test('a saved Vault completion counts from its listing before load, after load failure, and after the definition arrives', async () => { + const api = practiceApi(), + { listing } = require('../tools/build-official-content.cjs'), + catalogue = load(process.cwd()), + full = catalogue.puzzles.find((puzzle) => puzzle.id === 'vault-binary-01'), + entry = listing(full), + puzzles = catalogue.puzzles.map((puzzle) => (puzzle === full ? entry : puzzle)), + partial = { ...catalogue, puzzles }, + binary = puzzles.filter((puzzle) => puzzle.type === 'binary').length; + assert.equal(full.type, 'binary'); + assert.equal(entry.solution, undefined); + const room = async (runs) => + ( + await api + .create({ + catalogue: partial, + readRuns: async () => runs, + isCurrentCompletion: () => false, + }) + .snapshot() + ).rooms.observatory; + const saved = runFor(full, { firstCompletedAt: '2026-09-01T10:00:00.000Z' }); + const pending = await room([saved]); + assert.equal(pending.completed, 1, 'listing identity keeps the saved completion'); + assert.equal(pending.total, binary); + assert.equal( + ( + await room([ + runFor( + { ...full, title: 'Changed definition' }, + { firstCompletedAt: saved.firstCompletedAt }, + ), + ]) + ).completed, + 0, + ); + assert.equal( + (await room([runFor(full, { completedAt: '2026-09-02T10:00:00.000Z' })])).completed, + 0, + 'a current completion still needs the definition body', + ); + for (const key of Object.keys(entry)) delete entry[key]; + Object.assign(entry, full); + assert.equal((await room([saved])).completed, 1); + assert.equal((await room([saved])).total, binary); + assert.equal( + ( + await room([ + runFor({ ...full, solution: ['tampered'] }, { firstCompletedAt: saved.firstCompletedAt }), + ]) + ).completed, + 0, + 'once the definition is present only the exact body counts', + ); +}); + test('the Castle panel emits bound practice controls and escapes catalogue labels', async () => { const { practicePanel } = await import('../src/castle/practice.mjs'); const html = practicePanel( diff --git a/tests/content-manifest-revision.test.cjs b/tests/content-manifest-revision.test.cjs new file mode 100644 index 00000000..158ad0bb --- /dev/null +++ b/tests/content-manifest-revision.test.cjs @@ -0,0 +1,32 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { contentManifestRevision } = require('../tools/build.cjs'); +const { readIdentity, sha256 } = require('../tools/platform-identity.cjs'); + +test('content identity binds deferred definition bytes, not only the startup script', () => { + const startup = 'startup-script'; + const revision = contentManifestRevision(startup, 'definitions-a'); + assert.equal(revision, sha256(`${startup}\0definitions-a`)); + assert.notEqual(revision, sha256(startup)); + assert.notEqual(revision, contentManifestRevision(startup, 'definitions-b')); + assert.notEqual(revision, contentManifestRevision('other-startup', 'definitions-a')); +}); + +test('the emitted web identity covers the deferred definition file', () => { + const root = path.resolve(__dirname, '..'); + const names = fs.readdirSync(path.join(root, 'dist/assets')); + const read = (pattern) => { + const found = names.filter((name) => pattern.test(name)); + assert.equal(found.length, 1, String(pattern)); + return fs.readFileSync(path.join(root, 'dist/assets', found[0]), 'utf8'); + }; + const content = read(/^official-content\.[a-f0-9]{12}\.js$/); + const deferred = read(/^official-deferred\.[a-f0-9]{12}\.js$/); + const { identity } = readIdentity(path.join(root, 'dist')); + assert.equal(identity.contentManifestRevision, contentManifestRevision(content, deferred)); + assert.notEqual(identity.contentManifestRevision, sha256(content)); +}); diff --git a/tools/build.cjs b/tools/build.cjs index 7cc13ab1..84065d9e 100644 --- a/tools/build.cjs +++ b/tools/build.cjs @@ -369,7 +369,7 @@ function build() { ...source, payloadSha256: payloadDigest(DIST), appVersion: VERSION, - contentManifestRevision: sha256(contentSource), + contentManifestRevision: contentManifestRevision(contentSource, deferredSource), rulesCompatibility: {}, }; const platformIdentity = writeIdentity(DIST, platformBuild); @@ -569,6 +569,11 @@ self.addEventListener('fetch',event=>{const r=event.request,u=new URL(r.url);if( write(path.join(ROOT, 'build-info.json'), JSON.stringify(info, null, 2)); console.log(JSON.stringify(info, null, 2)); } +// Startup content names the deferred chunk, but a 12-character filename hash is not the +// definition identity. Bind both payloads so two different Vault bodies cannot share it. +function contentManifestRevision(contentSource, deferredSource) { + return sha256(`${contentSource}\0${deferredSource}`); +} if (require.main === module) build(); module.exports = { build, @@ -577,4 +582,5 @@ module.exports = { PATH_ROUTE_ALIASES, pathRouteAliasDocument, writePathRouteAliases, + contentManifestRevision, }; From d1458a09ebac97dc97cca40ad556a0d595d8794c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci Date: Wed, 7 Oct 2026 03:30:17 +0100 Subject: [PATCH 2/3] Keep Android content identity aligned with deferred Vault source --- docs/qa/2026-10-07-backlog/issue-389.md | 10 ++++++++++ tests/android-build.test.cjs | 21 +++++++++++++++++++++ tools/build-android.cjs | 2 +- tools/check-android-artifact.cjs | 21 ++++++++++++--------- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/qa/2026-10-07-backlog/issue-389.md b/docs/qa/2026-10-07-backlog/issue-389.md index 882f3a22..97eac02b 100644 --- a/docs/qa/2026-10-07-backlog/issue-389.md +++ b/docs/qa/2026-10-07-backlog/issue-389.md @@ -21,3 +21,13 @@ Physical Android/TalkBack and actual browser practice pending/failure rendering ## Residual risk Grok 4.7 high reached its 25-turn cap after the useful changes, so the coordinator owns completion. Muse lane creation was refused because current host kraspyon differs from registry owner desktop-ihkoojs; no bypass or Muse job ran. HUMAN_TODO.md remains authoritative and was not edited. Root owns central STATE updates and publication. Existing PR #572 practice interactions require root integration review. + +## Final Android identity correction + +The first independent Grok review found a HIGH producer/consumer defect: the Android runtime copied the complete web content revision, but its receipt and checker still expected only the startup script hash. A clean baseline build:android passed, while the added regression failed on receipt hash 95d1e511... versus complete revision 7a76937c... (exit 1). An earlier baseline attempt failed the clean-source precondition after a test edit; that attempt is not counted as regression proof. + +The receipt now preserves the runtime revision. The checker computes the authoritative shared helper from exactly one startup and one deferred UTF-8 asset. The new regression compares web, Android runtime and receipt revisions, and requires the artifact inspector to accept the result. Other direct assumptions were searched: standalone preview-house hashes the complete catalogue JSON and needs no change. + +Coordinator checks on the correction: npm.cmd run build:android exit 0; npm.cmd run check:android exit 0; Android-build, Android-boot-target, platform-identity, content-manifest-revision and budget suites with no isolation and concurrency 1 pass 24/24, zero skips/cancellations (49824ms). Prettier and diff check pass. Measured JavaScript gzip remains 134941 bytes; core offline bytes 1892676. No budget ceilings, save formats, IDs or database versions change. + +Second and final independent review: fresh read-only GPT-6.1 Sol medium reviewed only the three-file fix diff, found no CRITICAL/HIGH blockers or additional actionable findings. Its static check confirms unique asset selection, UTF-8 input order, receipt/runtime agreement and the regression. It ran no tests or physical-device checks. The subsequent amendment only adds this evidence; exact-head artifact regeneration/check remains the final local step. HUMAN_TODO.md remains authoritative and unchanged. diff --git a/tests/android-build.test.cjs b/tests/android-build.test.cjs index 007cca64..4747ec73 100644 --- a/tests/android-build.test.cjs +++ b/tests/android-build.test.cjs @@ -10,6 +10,7 @@ const { ANDROID_DIST, deriveAndroidPayload, sourceSha } = require('../tools/buil const { inspectAndroidArtifact } = require('../tools/check-android-artifact.cjs'); const { checkPublicPayload } = require('../tools/sync-android.cjs'); const { readIdentity, payloadDigest } = require('../tools/platform-identity.cjs'); +const { contentManifestRevision } = require('../tools/build.cjs'); const ROOT = path.resolve(__dirname, '..'); const WEB_DIST = path.join(ROOT, 'dist'); @@ -36,6 +37,26 @@ test('generated Android payload satisfies the closed artifact contract', () => { assert.ok(result.bytes > 0); }); +test('web, Android runtime and receipt share the complete content revision', () => { + const assetNames = fs.readdirSync(path.join(WEB_DIST, 'assets')); + const read = (pattern) => { + const names = assetNames.filter((name) => pattern.test(name)); + assert.equal(names.length, 1); + return fs.readFileSync(path.join(WEB_DIST, 'assets', names[0]), 'utf8'); + }; + const expected = contentManifestRevision( + read(/^official-content\.[0-9a-f]{12}\.js$/), + read(/^official-deferred\.[0-9a-f]{12}\.js$/), + ); + const web = readIdentity(WEB_DIST).identity; + const android = readIdentity(ANDROID_DIST).identity; + const receipt = readJson(path.join(ANDROID_DIST, 'android-build-identity.json')); + assert.equal(web.contentManifestRevision, expected); + assert.equal(android.contentManifestRevision, expected); + assert.equal(receipt.contentManifestRevision, expected); + assert.deepEqual(inspectAndroidArtifact().errors, []); +}); + test('Android replaces the web runtime identity with explicit browser-preview capabilities', () => { const web = readIdentity(WEB_DIST); const android = readIdentity(ANDROID_DIST); diff --git a/tools/build-android.cjs b/tools/build-android.cjs index a42ddd72..c093e10b 100644 --- a/tools/build-android.cjs +++ b/tools/build-android.cjs @@ -401,7 +401,7 @@ function deriveAndroidPayload({ sourceSha: commit, artifactSha256: treeDigest(target, new Set(['android-build-identity.json'])), webBuild: webInfo.build, - contentManifestRevision: content.sha256, + contentManifestRevision: runtimeIdentity.contentManifestRevision, rulesCompatibility: {}, rulesSourceDigest: sourceDigest(root), saveEnvelopeVersions: { diff --git a/tools/check-android-artifact.cjs b/tools/check-android-artifact.cjs index 48e5dfae..ae5cd330 100644 --- a/tools/check-android-artifact.cjs +++ b/tools/check-android-artifact.cjs @@ -5,6 +5,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { payloadDigest, readIdentity, identitySource } = require('./platform-identity.cjs'); +const { contentManifestRevision } = require('./build.cjs'); const { ANDROID_DIST, HOST_ONLY, @@ -94,16 +95,18 @@ function expectedCosts(entries) { function currentContentManifestRevision(root, errors) { const directory = path.join(root, 'dist', 'assets'); - const candidates = files(directory).filter((filename) => - /^official-content\.[0-9a-f]{12}\.js$/.test(path.basename(filename)), - ); - if (candidates.length !== 1) { - errors.push( - `Current web build needs exactly one official-content asset; found ${candidates.length}.`, - ); - return ''; + const assets = files(directory); + const sources = []; + for (const name of ['official-content', 'official-deferred']) { + const pattern = new RegExp(`^${name}\\.[0-9a-f]{12}\\.js$`); + const candidates = assets.filter((filename) => pattern.test(path.basename(filename))); + if (candidates.length !== 1) { + errors.push(`Current web build needs exactly one ${name} asset; found ${candidates.length}.`); + return ''; + } + sources.push(fs.readFileSync(candidates[0], 'utf8')); } - return sha256(fs.readFileSync(candidates[0])); + return contentManifestRevision(...sources); } function inspectAndroidArtifact({ From 36a7e9bd4282c698f035ded1ec9d72a6c9c194ca Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:52:29 -0700 Subject: [PATCH 3/3] test: bind deferred practice acceptance to the emitted app and native saves Keep all reviewed production code unchanged. Exercise pending, rejected, retried and reloaded official definitions at both viewport widths through the real app-owned adapter and native IndexedDB. Preserve the synthetic saved record at every phase. Add exact-head source archive/hash receipts and full unchanged-budget verification before native acceptance. Python compilation passes locally; browser execution remains a CI gate. --- .github/workflows/practice-deferred.yml | 69 +++++++++ tests/browser_practice_deferred.py | 177 ++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 .github/workflows/practice-deferred.yml create mode 100644 tests/browser_practice_deferred.py diff --git a/.github/workflows/practice-deferred.yml b/.github/workflows/practice-deferred.yml new file mode 100644 index 00000000..2f79783f --- /dev/null +++ b/.github/workflows/practice-deferred.yml @@ -0,0 +1,69 @@ +name: Practice deferred acceptance +on: + pull_request: + paths: + - 'src/castle-practice.js' + - 'src/app.js' + - 'tools/build*.cjs' + - 'tools/check-android-artifact.cjs' + - 'tests/*practice*.cjs' + - 'tests/browser_practice_deferred.py' + - '.github/workflows/practice-deferred.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + practice-deferred: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Capture immutable candidate inputs + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + mkdir -p test-results/practice-deferred + printf '{"sourceSha":"%s","treeSha":"%s"}\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^{tree})" > test-results/practice-deferred/source-head.json + git archive --format=tar --prefix=source/ HEAD | gzip -n > test-results/practice-deferred/source.tar.gz + sha256sum src/castle-practice.js src/app.js tools/build.cjs tools/build-android.cjs tools/check-android-artifact.cjs tests/browser_practice_deferred.py > test-results/practice-deferred/inputs.sha256 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + - run: npm ci + - name: Require full pinned verification and original byte ceilings + run: | + set -o pipefail + npm run verify 2>&1 | tee test-results/practice-deferred/verify.log + - run: python -m pip install -r requirements-dev.txt + - name: Install Chromium with Ubuntu repositories + run: | + test -s /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' 'Dir::Etc::sourcelist "/etc/apt/sources.list.d/ubuntu.sources";' 'Dir::Etc::sourceparts "-";' | sudo tee /etc/apt/apt.conf.d/99alibi-ci-sources >/dev/null + python -m playwright install --with-deps chromium + - name: Native app-owned practice counts through failure and recovery + run: | + set -o pipefail + python tests/browser_practice_deferred.py 2>&1 | tee test-results/practice-deferred/browser.log + - name: Check exact source remained unchanged + if: ${{ !cancelled() }} + run: | + sha256sum --check test-results/practice-deferred/inputs.sha256 + git diff --exit-code + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: practice-deferred-${{ github.event.pull_request.head.sha || github.sha }} + path: | + test-results/practice-deferred/ + build-info.json + retention-days: 14 + if-no-files-found: error diff --git a/tests/browser_practice_deferred.py b/tests/browser_practice_deferred.py new file mode 100644 index 00000000..646c21dd --- /dev/null +++ b/tests/browser_practice_deferred.py @@ -0,0 +1,177 @@ +"""Emitted practice adapter and native IndexedDB across deferred delivery states. + +A disposable local origin, not a physical-device or deployed-service test. +""" +import hashlib +import json +import os +import subprocess +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'test-results' / 'practice-deferred' +FIXTURE = b'''Synthetic practice seed +''' + + +class Handler(SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/__seed__': + data, mime = FIXTURE, 'text/html' + elif self.path in ('/__source__/core.js', '/__source__/engines.js'): + data = (ROOT / 'src' / self.path.rsplit('/', 1)[1]).read_bytes() + mime = 'text/javascript' + else: + return super().do_GET() + self.send_response(200) + self.send_header('Content-Type', mime) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *_): + pass + + +def snapshot(page): + value = page.evaluate('()=>AlibiDiagnostics.getPracticeSnapshot()') + assert value['available'], value + assert value['rooms']['observatory']['completed'] == 1, value['rooms']['observatory'] + assert len(value['rooms']) == 13 + return value['rooms']['observatory'] + + +def stored(page, key): + return page.evaluate('''key=>new Promise((resolve,reject)=>{ + const request=indexedDB.open('alibi-device',1); + request.onerror=()=>reject(request.error); + request.onsuccess=()=>{ + const db=request.result,tx=db.transaction('runs'),read=tx.objectStore('runs').get(key); + let result; + read.onsuccess=()=>result=read.result; + tx.oncomplete=()=>{db.close();resolve(result);}; + tx.onabort=()=>{db.close();reject(tx.error);}; + }; + })''', key) + + +def scenario(browser, base, width, puzzle, report): + context = browser.new_context(viewport={'width': width, 'height': 900}, + service_workers='block', reduced_motion='reduce') + held = [] + pattern = '**/official-deferred.*.js' + context.route(pattern, lambda route: held.append(route)) + try: + page = context.new_page() + page.set_default_timeout(15000) + page.goto(base + '/__seed__') + original = page.evaluate('''async puzzle=>{ + const value={schemaVersion:1,key:puzzle.id+'@'+puzzle.revision,rev:4,puzzle, + state:AlibiCore.registry[puzzle.type].initial(puzzle),undo:[],redo:[],moves:0, + hints:0,elapsed:0,completedAt:null,firstCompletedAt:'2026-09-01T10:00:00.000Z', + updatedAt:'2026-09-01T10:00:00.000Z',note:'synthetic historical completion'}; + await new Promise((resolve,reject)=>{ + const request=indexedDB.open('alibi-device',1); + request.onupgradeneeded=()=>{ + for(const name of ['runs','packs','meta'])request.result.createObjectStore(name,{keyPath:'key'}); + }; + request.onerror=()=>reject(request.error); + request.onsuccess=()=>{ + const db=request.result,tx=db.transaction('runs','readwrite'); + tx.objectStore('runs').put({key:value.key,value}); + tx.oncomplete=()=>{db.close();resolve();}; + tx.onabort=()=>{db.close();reject(tx.error);}; + }; + }); + return {key:value.key,value}; + }''', puzzle) + page.goto(base + '/#/home', wait_until='domcontentloaded') + page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot') + page.evaluate('''()=>{ + window.definitionResult=null; + ALIBI_DEFERRED.ensure().then(()=>window.definitionResult='loaded', + error=>window.definitionResult=error.message); + }''') + page.wait_for_function('()=>Array.from(document.scripts).some(s=>s.src.includes("official-deferred."))') + assert held, 'the real deferred script request must be pending' + assert page.evaluate('ALIBI_DEFERRED.ready') is False + pending = snapshot(page) + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'pending', 'room': pending}) + for route in held: + route.abort('failed') + held.clear() + page.wait_for_function('()=>typeof window.definitionResult==="string"') + assert page.evaluate('definitionResult') == 'Puzzle definitions did not load.' + assert page.evaluate('ALIBI_DEFERRED.ready') is False + failed = snapshot(page) + assert failed == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'failed', 'room': failed}) + context.unroute(pattern) + page.evaluate('async()=>await ALIBI_DEFERRED.ensure()') + assert page.evaluate('ALIBI_DEFERRED.ready') is True + loaded = snapshot(page) + assert loaded == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'loaded', 'room': loaded}) + # The same app-owned snapshot survives a new page after definitions load. + page.reload(wait_until='domcontentloaded') + page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot') + page.evaluate('async()=>await ALIBI_DEFERRED.ensure()') + assert snapshot(page) == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'reload', 'room': pending}) + finally: + context.close() + + +def run(): + OUT.mkdir(parents=True, exist_ok=True) + report = {'passed': False, 'scope': 'emitted app-owned adapter, native IndexedDB, real deferred request', + 'cases': [], 'sourceSha256': {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() + for p in ('src/castle-practice.js', 'src/app.js', 'tools/build.cjs', + 'tools/build-android.cjs', 'tools/check-android-artifact.cjs')}} + server = ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=str(ROOT / 'dist'))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + report['identity'] = json.loads((ROOT / 'build-info.json').read_text())['platformBuild'] + puzzle = json.loads(subprocess.check_output([ + 'node', '-e', "console.log(JSON.stringify(require('./tools/official-catalogue.cjs').load(process.cwd()).puzzles.find(p=>p.id==='vault-binary-01')))" + ], cwd=ROOT, text=True)) + assert puzzle['type'] == 'binary' and puzzle['solution'] + with sync_playwright() as pw: + options = {'headless': True, 'args': ['--no-sandbox']} + executable = os.environ.get('CHROMIUM_PATH') + if not executable and Path('/usr/bin/chromium').exists(): + executable = '/usr/bin/chromium' + if executable: + options['executable_path'] = executable + browser = pw.chromium.launch(**options) + try: + report['browserVersion'] = browser.version + for width in (390, 1280): + scenario(browser, f'http://127.0.0.1:{server.server_port}', width, puzzle, report) + finally: + browser.close() + assert len(report['cases']) == 8 + report['passed'] = True + except Exception as error: + report['failure'] = f'{type(error).__name__}: {error}' + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + (OUT / 'results.json').write_text(json.dumps(report, indent=2), encoding='utf-8') + print(json.dumps(report, indent=2), flush=True) + return 0 if report['passed'] else 1 + + +if __name__ == '__main__': + raise SystemExit(run())