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/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..97eac02b
--- /dev/null
+++ b/docs/qa/2026-10-07-backlog/issue-389.md
@@ -0,0 +1,33 @@
+# 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.
+
+## 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/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/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/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())
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-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/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,
};
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({