Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,10 @@ jobs:
# Do not contend for the host package manager during a repository job.
run: npx playwright install chromium

# A plain static server rather than the dev toolchain, so the audit tests
# exactly the bytes that will be deployed and does not depend on a dev
# server starting inside a fixed window.
- name: Serve the built site
run: npm run serve:dist &

# One process owns a loopback ephemeral-port preview and its audit child.
# Readiness checks the per-run identity and exact build, with bounded waits.
- name: Accessibility and horizontal overflow, every page, both themes
run: npx wait-on http://localhost:4328/docs-wallet/ && npm run check:a11y
run: npm run check:a11y

- name: Secret scan (full history)
uses: trufflesecurity/trufflehog@v3.90.5
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
- [What this release authorizes](https://bitcoinuniverseio.github.io/docs-wallet/assets/support-state): The generated answer to which protocol operations Universe Wallet 1.7.5.8 authorizes, and why the number is what it is.
- [What a digital artifact is](https://bitcoinuniverseio.github.io/docs-wallet/assets/what-a-digital-artifact-is): How an inscription, a token balance, and a rare satoshi actually exist on Bitcoin, and why that makes them fragile in an ordinary wallet.
- [Why an action is unavailable](https://bitcoinuniverseio.github.io/docs-wallet/assets/why-unavailable): What the unavailable screen means, what it is not, and the list of things that will not change it.
- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): What a Zerdinal listing signature commits you to, why the approval can be blocked outright, and why cancelling a listing is only advisory.
- [Zcash market listings](https://bitcoinuniverseio.github.io/docs-wallet/assets/zcash-market-listings): Review Zerdinal listings and full token lots with exact total prices, clear quantities, and a separate purchase approval.

## Safety

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
"check:manifest": "node scripts/check-manifest.mjs",
"check:diagrams": "node scripts/check-diagrams.mjs",
"check:markdown": "markdownlint-cli2 \"src/content/**/*.{md,mdx}\" \"*.md\"",
"check:a11y": "node scripts/check-accessibility.mjs",
"check:a11y": "node scripts/run-accessibility.mjs",
"check:preview": "node --test scripts/preview-lifecycle.test.mjs",
"capability:pull": "node scripts/sync-capability.mjs --pull",
"capability:write": "node scripts/sync-capability.mjs",
"test": "npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown",
"test": "npm run check:preview && npm run check:copy && npm run check:public-safety && npm run check:capability && npm run check:manifest && npm run check:schema && npm run check:diagrams && npm run check:llms && npm run check:data && npm run check:markdown",
"check:llms": "node scripts/generate-llms.mjs --check",
"check:schema": "node scripts/validate-manifest-schema.mjs",
"llms": "node scripts/generate-llms.mjs",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-accessibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// the source intends. Fails on any axe violation at serious or critical impact,
// and on any page that scrolls horizontally at 320px.
//
// Run: npx astro preview --port 4323 & node scripts/check-accessibility.mjs
// Run: npm run check:a11y (owns and verifies its built preview).

import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
Expand Down
119 changes: 119 additions & 0 deletions scripts/preview-lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { BASE, buildDigest, IDENTITY_PATH, startDistServer } from './serve-dist.mjs';
import { runAudit, verifyPreview, withBuiltPreview } from './run-accessibility.mjs';

async function fixture(t, text = 'built docs') {
const directory = await mkdtemp(join(tmpdir(), 'docs-wallet-preview-'));
const root = join(directory, 'dist');
await mkdir(root);
await writeFile(join(root, 'index.html'), text);
await writeFile(join(root, 'sitemap-0.xml'), '<urlset></urlset>');
t.after(() => rm(directory, { recursive: true, force: true }));
return { directory, root };
}

test('simultaneous previews own separate loopback ports and exact built identities', async (t) => {
const a = await fixture(t, 'build A');
const b = await fixture(t, 'build B');
const [first, second] = await Promise.all([
startDistServer({ root: a.root }), startDistServer({ root: b.root }),
]);
t.after(() => Promise.all([first.close(), second.close()]));
assert.notEqual(first.origin, second.origin);
assert.match(first.origin, /^http:\/\/127\.0\.0\.1:/);
assert.notEqual(first.identity.owner, second.identity.owner);
assert.notEqual(first.identity.buildDigest, second.identity.buildDigest);
assert.equal(first.identity.buildDigest, await buildDigest(a.root));
await Promise.all([verifyPreview(first.origin, first.identity), verifyPreview(second.origin, second.identity)]);
assert.equal(await (await fetch(first.origin + BASE + '/')).text(), 'build A');
const response = await fetch(second.origin + BASE + '/');
assert.equal(response.headers.get('access-control-allow-origin'), '*');
assert.equal(await response.text(), 'build B');
await assert.rejects(verifyPreview(first.origin, { ...first.identity, owner: second.identity.owner }), /identity mismatch/);
await assert.rejects(verifyPreview(first.origin, { ...first.identity, buildDigest: second.identity.buildDigest }), /identity mismatch/);
await first.close();
await assert.rejects(fetch(first.origin + BASE + '/'));
assert.equal(await (await fetch(second.origin + BASE + '/')).text(), 'build B');
});

test('occupied listeners are never borrowed or killed, and readiness is bounded', async (t) => {
const { root } = await fixture(t);
const existing = await startDistServer({ root });
t.after(() => existing.close());
await assert.rejects(startDistServer({ root, port: Number(new URL(existing.origin).port) }), { code: 'EADDRINUSE' });
await verifyPreview(existing.origin, existing.identity);
const stalled = createServer(() => {});
await new Promise((ready) => stalled.listen(0, '127.0.0.1', ready));
t.after(async () => {
stalled.closeAllConnections();
await new Promise((closed) => stalled.close(closed));
});
await assert.rejects(
verifyPreview('http://127.0.0.1:' + stalled.address().port, existing.identity, 25),
{ name: 'TimeoutError' },
);
});

test('failed callbacks and changed build bytes close only the owned preview', async (t) => {
const { root } = await fixture(t);
const survivor = await startDistServer({ root });
t.after(() => survivor.close());
let failedOrigin;
await assert.rejects(withBuiltPreview(async ({ origin }) => {
failedOrigin = origin;
throw new Error('gate failed');
}, { root }), /gate failed/);
await assert.rejects(fetch(failedOrigin + IDENTITY_PATH));
await verifyPreview(survivor.origin, survivor.identity);
await assert.rejects(withBuiltPreview(async () => {
await writeFile(join(root, 'index.html'), 'different build');
}, { root }), /changed during the audit/);
let ran = false;
await assert.rejects(withBuiltPreview(() => { ran = true; }, { root: join(root, 'missing') }));
assert.equal(ran, false);
});

test('audit children receive their owned origin and nonzero gates propagate', async (t) => {
const { root, directory } = await fixture(t, 'expected build');
const script = join(directory, 'failing-audit.mjs');
const observed = join(directory, 'observed.txt');
await writeFile(script,
"import { writeFile } from 'node:fs/promises';\n" +
"const response = await fetch(process.env.PREVIEW_ORIGIN + '/docs-wallet/');\n" +
"await writeFile(" + JSON.stringify(observed) + ", await response.text());\n" +
"process.exitCode = 42;\n");
let origin;
await assert.rejects(withBuiltPreview(async (preview) => {
origin = preview.origin;
await runAudit({
origin, script, env: { ...process.env, PREVIEW_ORIGIN: 'http://127.0.0.1:1' },
stdio: 'ignore',
});
}, { root }), /exited with code 42/);
assert.equal(await readFile(observed, 'utf8'), 'expected build');
await assert.rejects(fetch(origin + IDENTITY_PATH));
});

test('timed out and cancelled children terminate without closing other previews', async (t) => {
const { root, directory } = await fixture(t);
const script = join(directory, 'stalled-audit.mjs');
await writeFile(script, "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\n");
const survivor = await startDistServer({ root });
t.after(() => survivor.close());
await assert.rejects(withBuiltPreview(({ origin }) =>
runAudit({ origin, script, timeoutMs: 250, stdio: 'ignore' }), { root }),
/audit timed out/);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 250);
try {
await assert.rejects(withBuiltPreview(({ origin }) =>
runAudit({ origin, script, signal: controller.signal, stdio: 'ignore' }), { root }),
/audit cancelled/);
} finally { clearTimeout(timer); }
await verifyPreview(survivor.origin, survivor.identity);
});
84 changes: 84 additions & 0 deletions scripts/run-accessibility.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Run the unchanged browser gates against one owned, identified static build.
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDigest, DIST_ROOT, IDENTITY_PATH, startDistServer } from './serve-dist.mjs';

const AUDIT_SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), 'check-accessibility.mjs');

export async function verifyPreview(origin, identity, timeoutMs = 10_000) {
if (!/^http:\/\/127\.0\.0\.1:[1-9][0-9]*$/.test(origin)) throw new Error('Preview must use owned loopback origin');
const response = await fetch(origin + IDENTITY_PATH, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error('Preview identity request failed: ' + response.status);
const actual = await response.json();
if (actual.owner !== identity.owner || actual.buildDigest !== identity.buildDigest) {
throw new Error('Preview ownership or built-document identity mismatch');
}
}

export async function withBuiltPreview(run, { root = DIST_ROOT, readinessTimeoutMs = 10_000 } = {}) {
const expectedDigest = await buildDigest(root);
const preview = await startDistServer({ root, port: 0, owner: randomUUID() });
try {
await verifyPreview(preview.origin, { ...preview.identity, buildDigest: expectedDigest }, readinessTimeoutMs);
process.stdout.write('Auditing built docs ' + expectedDigest + ' at ' + preview.origin + '\n');
const result = await run(preview);
if (await buildDigest(root) !== expectedDigest) throw new Error('Built documents changed during the audit');
return result;
} finally {
await preview.close();
}
}

export async function runAudit({
origin, script = AUDIT_SCRIPT, signal, timeoutMs = 20 * 60_000,
env = process.env, stdio = 'inherit',
}) {
if (signal?.aborted) throw new Error('Accessibility audit cancelled');
const child = spawn(process.execPath, [script], {
env: { ...env, PREVIEW_ORIGIN: origin },
stdio,
shell: false,
});
let failure;
let forcedStop;
const stop = (message) => {
if (failure) return;
failure = new Error(message);
child.kill('SIGTERM');
// The handle belongs to this invocation; never inspect or kill by port.
forcedStop = setTimeout(() => child.kill('SIGKILL'), 2_000);
};
const onAbort = () => stop('Accessibility audit cancelled');
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => stop('Accessibility audit timed out'), timeoutMs);
try {
const code = await new Promise((resolveExit, reject) => {
child.once('error', reject);
child.once('close', resolveExit);
});
if (failure) throw failure;
if (code !== 0) throw new Error('Accessibility audit exited with code ' + code);
} finally {
clearTimeout(timer);
clearTimeout(forcedStop);
signal?.removeEventListener('abort', onAbort);
}
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const controller = new AbortController();
const onSignal = () => controller.abort();
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);
try {
await withBuiltPreview(({ origin }) => runAudit({ origin, signal: controller.signal }));
} catch (error) {
process.stderr.write(error.message + '\n');
process.exitCode = 1;
} finally {
process.removeListener('SIGINT', onSignal);
process.removeListener('SIGTERM', onSignal);
}
}
Loading
Loading