diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml
index 3b9d1de15..cc946cec2 100644
--- a/.github/workflows/desktop-release-guard.yml
+++ b/.github/workflows/desktop-release-guard.yml
@@ -82,10 +82,36 @@ jobs:
node -e 'if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(process.argv[1])) process.exit(1)' "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
+ renderer-axe-boundary:
+ name: Prove stock Chromium existing-target axe boundary
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - name: Checkout pull-request validation source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version-file: '.nvmrc'
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install locked dependencies and real Chromium
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Run focused stock Chromium existing-target axe regressions
+ env:
+ PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY: '1'
+ run: node --test --test-name-pattern='packaged acceptance renderer variants' apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
+
package:
name: Validate unsigned ${{ matrix.platform }}-${{ matrix.arch }} package
if: github.event_name == 'pull_request'
- needs: validation-version
+ needs: [validation-version, renderer-axe-boundary]
runs-on: ${{ matrix.runner }}
continue-on-error: ${{ matrix.release_target == false }}
timeout-minutes: 60
@@ -209,7 +235,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
- sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot gnome-keyring libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip
+ sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot fonts-liberation gnome-keyring libglib2.0-bin libsecret-1-0 rpm tesseract-ocr unzip xdg-utils xvfb zip
- name: Package desktop app from clean checkout
shell: bash
@@ -294,6 +320,30 @@ jobs:
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"
+ - name: Capture packaged Linux x64 visual and accessibility acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ shell: bash
+ env:
+ PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts
+ run: |
+ keyring_root="$(mktemp -d)"
+ trap 'rm -rf -- "$keyring_root"' EXIT
+ dbus-run-session -- bash -euo pipefail -c '
+ export XDG_DATA_HOME="$1"
+ eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)"
+ xvfb-run --auto-servernum npm run desktop:acceptance
+ ' bash "$keyring_root"
+ npm run desktop:acceptance:verify
+
+ - name: Publish mandatory packaged desktop acceptance evidence
+ if: always() && matrix.platform == 'linux' && matrix.arch == 'x64'
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: propr-desktop-acceptance-linux-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: desktop-acceptance-artifacts
+ if-no-files-found: error
+ retention-days: 14
+
- name: Launch packaged Windows application and exercise MVP desktop flows
if: matrix.platform == 'win32'
shell: bash
@@ -549,7 +599,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
- sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip
+ sudo apt-get install --yes cpio dbus-x11 fakeroot fonts-liberation gnome-keyring libsecret-1-0 rpm tesseract-ocr zip
- name: Configure required macOS signing and notarization
if: matrix.platform == 'darwin'
@@ -651,6 +701,29 @@ jobs:
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"
+ - name: Capture signed packaged Linux x64 visual and accessibility acceptance evidence
+ if: matrix.platform == 'linux' && matrix.arch == 'x64'
+ shell: bash
+ env:
+ PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts
+ run: |
+ keyring_root="$(mktemp -d)"
+ trap 'rm -rf -- "$keyring_root"' EXIT
+ dbus-run-session -- bash -euo pipefail -c '
+ export XDG_DATA_HOME="$1"
+ eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)"
+ xvfb-run --auto-servernum npm run desktop:acceptance
+ ' bash "$keyring_root"
+ npm run desktop:acceptance:verify
+
+ - name: Publish mandatory signed packaged desktop acceptance evidence
+ if: always() && matrix.platform == 'linux' && matrix.arch == 'x64'
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: propr-desktop-acceptance-signed-linux-x64-${{ github.run_id }}-${{ github.run_attempt }}
+ path: desktop-acceptance-artifacts
+ if-no-files-found: error
+ retention-days: 14
- name: Inspect signed and notarized macOS application
if: matrix.platform == 'darwin'
shell: bash
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index ac5bdac0e..9e49314ff 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -13,6 +13,7 @@ npm run desktop:typecheck
npm run desktop:test
npm run desktop:package
npm run desktop:smoke # Run under xvfb-run on a headless Linux host.
+npm run desktop:acceptance # Linux x64 package; run under Xvfb in a D-Bus/keyring session.
npm run desktop:make
npm run desktop:audit
# On Linux hosts with the corresponding native packaging tools installed:
@@ -44,6 +45,30 @@ executable and fuse inspection without launching a window. Release CI launches b
inspects macOS and Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/MSI packages, and validates
configured OS signatures.
+## Packaged visual and accessibility acceptance
+
+Linux x64 is the canonical visual runtime. `desktop:acceptance` launches the real packaged executable with a fresh
+private Electron profile and drives it over Chromium's debugging protocol with Playwright. A separately authorized
+acceptance mode supplies deterministic local API, Socket.IO, browser-pairing, ProPR Connect, and local-setup fixtures
+while preserving the production main/preload/renderer boundary, renderer sandbox, context isolation, navigation
+policy, and credential service. It requires independent command-line and environment triggers, accepts only a
+packaged Linux binary, and refuses the default Electron profile.
+
+The mandatory artifact contains screenshots for first run, endpoint and Connect confirmation, pairing, local setup
+prerequisites/progress/recovery/completion, dashboard profile management, offline, revoked, and incompatible states.
+Every state is captured at standard, narrow, high-DPI, 200% zoom, and reduced-motion configurations. The manifest
+records fixed locale/time/theme inputs, dimensions, hashes, and native coverage. The 200% variant uses an
+acceptance-only, main-authorized preload bridge to Electron `webFrame` zoom and records its read-back alongside raw
+CDP/renderer viewports, independently measured geometry, DPR, and physical PNG dimensions. Accessibility evidence
+fails for any serious or critical axe finding or missing keyboard order, visible focus, dialog trap/restore,
+accessible name, or live announcement proof. Finalization rejects missing, duplicate, unexpected, incorrectly sized,
+or secret-bearing output. Sentinel coverage includes renderer DOM, process output, URLs, local/session storage,
+persisted profile/config data, screenshots and metadata, and every decompressed Playwright trace entry.
+
+The existing six-target package matrix is unchanged. Linux x64 produces the visual/accessibility runtime evidence.
+Linux arm64, macOS x64/arm64, and Windows x64/arm64 retain native package inspection and platform runtime smoke
+coverage; their acceptance classification is structural/runtime-only.
+
Darwin packaged Connect acceptance first inspects the normal unsigned package, then generates a one-run self-signed
CA:false code-signing leaf in an isolated default keychain and signs only that smoke artifact. The signature uses an
explicit certificate-bound designated requirement that is verified before the pair process and again after the
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index c00932ef4..899187fd2 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -31,6 +31,8 @@
"smoke:package": "node scripts/smoke-packaged.mjs",
"smoke:connect-package": "node scripts/smoke-packaged-connect.mjs",
"smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only",
+ "acceptance:package": "node scripts/run-packaged-acceptance.mjs",
+ "acceptance:verify": "node -e \"import('./scripts/acceptance-artifacts.mjs').then(m => m.verifyAcceptanceArtifacts(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || m.defaultAcceptanceOutputDirectory()))\"",
"premake": "npm run prepare:renderer",
"make": "electron-forge make",
"make:dmg": "node scripts/make-dmg.mjs",
@@ -52,7 +54,9 @@
"@electron/fuses": "^2.1.3",
"@types/node": "^22.10.0",
"@vitejs/plugin-react": "^4.6.0",
+ "axe-core": "4.10.3",
"electron": "^44.0.0",
+ "playwright": "^1.62.1",
"socket.io": "^4.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
diff --git a/apps/desktop/scripts/acceptance-artifacts.mjs b/apps/desktop/scripts/acceptance-artifacts.mjs
new file mode 100644
index 000000000..4cbe46cde
--- /dev/null
+++ b/apps/desktop/scripts/acceptance-artifacts.mjs
@@ -0,0 +1,680 @@
+import { createHash } from 'node:crypto';
+import { spawnSync } from 'node:child_process';
+import { lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { inflateRawSync } from 'node:zlib';
+import { PACKAGED_ACCEPTANCE_TIME } from './packaged-acceptance-clock.mjs';
+
+export const FIXED_TIME = PACKAGED_ACCEPTANCE_TIME;
+export const ACCEPTANCE_ARTIFACT_LEAF = 'desktop-acceptance-artifacts';
+export const ACCEPTANCE_PROFILE_PREFIX = 'propr-desktop-acceptance-';
+export const ACCEPTANCE_SURFACES_PREFIX = 'propr-acceptance-surfaces-';
+export const DESKTOP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+export const WORKSPACE_ROOT = resolve(DESKTOP_ROOT, '../..');
+
+export const ACCEPTANCE_JOURNEYS = Object.freeze([
+ 'first-run-chooser',
+ 'manual-endpoint-confirmation',
+ 'connect-confirmation',
+ 'remote-pairing',
+ 'local-setup-prerequisites',
+ 'local-setup-progress',
+ 'local-setup-error',
+ 'local-setup-completion',
+ 'dashboard-profile-manager',
+ 'offline',
+ 'revoked',
+ 'incompatible-instance',
+]);
+
+export const ACCEPTANCE_VARIANTS = Object.freeze({
+ standard: { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false },
+ narrow: { viewport: { width: 880, height: 620 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false },
+ 'high-dpi': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 2, zoom: 1, reducedMotion: false },
+ 'zoom-200': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 2, reducedMotion: false },
+ 'reduced-motion': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: true },
+});
+
+export const FIXED_ACCEPTANCE_ORIGINS = Object.freeze({
+ // Canonical loopback aliases keep the fixture private while exercising the
+ // renderer's real remote-pairing path instead of its localhost setup lane.
+ ready: 'http://127.0.0.2:41731',
+ revoked: 'http://127.0.0.3:41732',
+ incompatible: 'http://127.0.0.4:41733',
+ offline: 'http://127.0.0.1:9',
+});
+
+export const DETERMINISTIC_INPUTS = Object.freeze({
+ originPolicy: 'fixed-loopback-fixtures',
+ origins: Object.values(FIXED_ACCEPTANCE_ORIGINS),
+ rendererClock: FIXED_TIME,
+ timestamps: FIXED_TIME,
+ visibleData: 'fixed-acceptance-fixtures-v1',
+ locale: 'en-US',
+ timezone: 'UTC',
+ font: 'Liberation Sans',
+ colorScheme: 'light',
+ viewports: 'manifest-per-screenshot',
+ scales: 'manifest-per-screenshot',
+ zoom: 'authenticated-electron-web-frame-per-screenshot',
+ reducedMotion: 'manifest-per-screenshot',
+ animations: 'disabled',
+ repeatability: 'byte-identical-double-capture',
+});
+
+export const defaultAcceptanceOutputDirectory = () => join(WORKSPACE_ROOT, ACCEPTANCE_ARTIFACT_LEAF);
+export const screenshotName = (journey, variant) => `${journey}--${variant}.png`;
+export const expectedScreenshotNames = () => ACCEPTANCE_JOURNEYS.flatMap(journey =>
+ Object.keys(ACCEPTANCE_VARIANTS).map(variant => screenshotName(journey, variant)));
+
+const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
+const canonicalRelative = (root, path) => relative(root, path).split(sep).join('/');
+const isSha256 = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
+const isInteger = value => Number.isInteger(value) && value >= 0;
+const MAX_VIEWPORT_INSET_CSS_PIXELS = 64;
+
+const assertExactKeys = (value, expected, description) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${description} must be an object`);
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ if (actual.join('\n') !== wanted.join('\n')) throw new Error(`${description} schema changed`);
+};
+
+const assertExactJson = (actual, expected, description) => {
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${description} changed`);
+};
+
+const assertViewportMetricEvidence = (evidence, config, description) => {
+ const expectedEffective = {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ };
+ const expectedPhysical = {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ };
+ assertExactKeys(evidence.requestedViewport, ['width', 'height'], `${description} requested viewport`);
+ assertExactKeys(evidence.playwrightViewport, ['width', 'height'], `${description} Playwright viewport`);
+ assertExactKeys(evidence.rendererViewport, ['width', 'height'], `${description} renderer viewport`);
+ assertExactKeys(evidence.documentClientViewport, ['width', 'height'], `${description} document client viewport`);
+ assertExactKeys(evidence.scrollbarInsets, ['width', 'height'], `${description} scrollbar insets`);
+ assertExactKeys(evidence.visualViewportInsets, ['width', 'height'], `${description} visual viewport insets`);
+ assertExactKeys(evidence.layoutViewport, ['width', 'height'], `${description} layout viewport`);
+ assertExactKeys(evidence.cdpVisualViewport, ['width', 'height', 'scale'], `${description} CDP visual viewport`);
+ assertExactKeys(evidence.rendererVisualViewport, ['width', 'height', 'scale'], `${description} renderer visual viewport`);
+ assertExactKeys(evidence.effectiveVisibleCssSpan, ['width', 'height'], `${description} effective visible CSS span`);
+ assertExactKeys(evidence.geometryZoom, ['width', 'height'], `${description} renderer geometry zoom`);
+ assertExactKeys(evidence.physicalPngDimensions, ['width', 'height'], `${description} physical PNG dimensions`);
+ const scrollbarInsetsAreValid = ['width', 'height'].every(axis => (
+ Number.isFinite(evidence.scrollbarInsets[axis])
+ && evidence.scrollbarInsets[axis] >= 0
+ && evidence.scrollbarInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS
+ && evidence.scrollbarInsets[axis]
+ === evidence.rendererViewport[axis] - evidence.documentClientViewport[axis]
+ ));
+ const visualViewportInsetsAreValid = ['width', 'height'].every(axis => (
+ Number.isFinite(evidence.visualViewportInsets[axis])
+ && evidence.visualViewportInsets[axis] >= 0
+ && evidence.visualViewportInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS
+ && evidence.visualViewportInsets[axis]
+ === evidence.rendererViewport[axis] - evidence.rendererVisualViewport[axis]
+ ));
+ if (JSON.stringify(evidence.requestedViewport) !== JSON.stringify(config.viewport)
+ || JSON.stringify(evidence.playwrightViewport) !== JSON.stringify(config.viewport)
+ || JSON.stringify(evidence.rendererViewport) !== JSON.stringify(expectedEffective)
+ || !scrollbarInsetsAreValid
+ || !visualViewportInsetsAreValid
+ || JSON.stringify(evidence.layoutViewport) !== JSON.stringify(evidence.documentClientViewport)
+ || JSON.stringify(evidence.rendererVisualViewport) !== JSON.stringify(evidence.cdpVisualViewport)
+ || evidence.rendererVisualViewport.scale !== 1
+ || JSON.stringify(evidence.effectiveVisibleCssSpan) !== JSON.stringify(expectedEffective)
+ || evidence.geometryZoom.width !== config.zoom || evidence.geometryZoom.height !== config.zoom
+ || evidence.requestedDeviceScaleFactor !== config.deviceScaleFactor
+ || evidence.rendererDevicePixelRatio !== config.deviceScaleFactor * config.zoom
+ || evidence.requestedZoomFactor !== config.zoom || evidence.appliedZoomFactor !== config.zoom
+ || evidence.zoomResetFactor !== 1 || evidence.zoomMechanism !== 'electron-web-frame'
+ || JSON.stringify(evidence.physicalPngDimensions) !== JSON.stringify(expectedPhysical)) {
+ throw new Error(`${description} viewport metric evidence changed`);
+ }
+};
+
+export const readPngDimensions = bytes => {
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
+ throw new Error('Acceptance screenshot is not a PNG');
+ }
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
+};
+
+const walkFiles = async root => {
+ const files = [];
+ const visit = async directory => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ const stats = await lstat(path);
+ if (stats.isSymbolicLink()) throw new Error(`Acceptance artifact contains a symbolic link: ${entry.name}`);
+ if (stats.isDirectory()) await visit(path);
+ else if (stats.isFile()) files.push(path);
+ else throw new Error(`Acceptance artifact is not a regular file: ${entry.name}`);
+ }
+ };
+ await visit(root);
+ return files.sort();
+};
+
+const genericSecretPatterns = [
+ /propr_it_[A-Za-z0-9_-]{20,}/g,
+ /gh[pousr]_[A-Za-z0-9_]{20,}/g,
+ /Bearer\s+[A-Za-z0-9._~-]{16,}/gi,
+ /(?:token|secret|password|private[_-]?key)\s*[=:]\s*[^\s"']{8,}/gi,
+];
+
+// Keep generic matches inside plaintext runs so binary bytes cannot join otherwise separate metadata.
+const printableTextRuns = function* (bytes) {
+ let start = 0;
+ for (let index = 0; index < bytes.length; index += 1) {
+ const byte = bytes[index];
+ if (byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)) continue;
+ if (index > start) yield bytes.subarray(start, index).toString('ascii');
+ start = index + 1;
+ }
+ if (start < bytes.length) yield bytes.subarray(start).toString('ascii');
+};
+
+const scanBytes = (bytes, description, sentinels) => {
+ for (const sentinel of sentinels) {
+ if (sentinel && bytes.includes(Buffer.from(sentinel))) throw new Error(`Secret sentinel found in ${description}`);
+ }
+ for (const text of printableTextRuns(bytes)) {
+ for (const pattern of genericSecretPatterns) {
+ pattern.lastIndex = 0;
+ if (pattern.test(text)) throw new Error(`Secret-shaped value found in ${description}`);
+ }
+ }
+};
+
+const crc32 = bytes => {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+
+const defaultOcr = async bytes => {
+ const result = spawnSync('tesseract', ['stdin', 'stdout', '--dpi', '96', '--psm', '11'], {
+ input: bytes,
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ });
+ if (result.error?.code === 'ENOENT') throw new Error('Acceptance screenshot OCR requires the tesseract executable');
+ if (result.error || result.status !== 0) {
+ throw new Error(`Acceptance screenshot OCR failed: ${result.error?.message || result.stderr || `status ${result.status}`}`);
+ }
+ return result.stdout;
+};
+
+const renderedImageExtension = description => /\.(?:png|jpe?g|webp)$/i.test(description);
+
+export const scanRenderedScreenshot = async (bytes, description, sentinels = [], ocr = defaultOcr) => {
+ const recognized = await ocr(bytes, description);
+ if (typeof recognized !== 'string') throw new Error('Acceptance screenshot OCR returned invalid data');
+ scanBytes(Buffer.from(recognized), `${description} (rendered pixels)`, sentinels);
+};
+
+const readZipEntries = bytes => {
+ const minimumEndOffset = Math.max(0, bytes.length - 65_557);
+ let endOffset = -1;
+ for (let offset = bytes.length - 22; offset >= minimumEndOffset; offset -= 1) {
+ if (bytes.readUInt32LE(offset) === 0x06054b50) { endOffset = offset; break; }
+ }
+ if (endOffset < 0) throw new Error('Acceptance trace ZIP end record is missing');
+ const diskNumber = bytes.readUInt16LE(endOffset + 4);
+ const centralDisk = bytes.readUInt16LE(endOffset + 6);
+ const entriesOnDisk = bytes.readUInt16LE(endOffset + 8);
+ const entryCount = bytes.readUInt16LE(endOffset + 10);
+ const centralSize = bytes.readUInt32LE(endOffset + 12);
+ const centralOffset = bytes.readUInt32LE(endOffset + 16);
+ const commentLength = bytes.readUInt16LE(endOffset + 20);
+ if (diskNumber !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount || entryCount > 10_000
+ || centralOffset + centralSize > endOffset || endOffset + 22 + commentLength !== bytes.length) {
+ throw new Error('Acceptance trace ZIP directory is invalid or unsupported');
+ }
+ const entries = [];
+ let offset = centralOffset;
+ let totalUncompressed = 0;
+ for (let index = 0; index < entryCount; index += 1) {
+ if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) throw new Error('Acceptance trace ZIP entry is invalid');
+ const flags = bytes.readUInt16LE(offset + 8);
+ const method = bytes.readUInt16LE(offset + 10);
+ const checksum = bytes.readUInt32LE(offset + 16);
+ const compressedSize = bytes.readUInt32LE(offset + 20);
+ const uncompressedSize = bytes.readUInt32LE(offset + 24);
+ const filenameLength = bytes.readUInt16LE(offset + 28);
+ const extraLength = bytes.readUInt16LE(offset + 30);
+ const commentLength = bytes.readUInt16LE(offset + 32);
+ const externalAttributes = bytes.readUInt32LE(offset + 38);
+ const localOffset = bytes.readUInt32LE(offset + 42);
+ const nextOffset = offset + 46 + filenameLength + extraLength + commentLength;
+ if (nextOffset > bytes.length || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff
+ || (flags & 1) !== 0 || ![0, 8].includes(method) || uncompressedSize > 64 * 1024 * 1024) {
+ throw new Error('Acceptance trace ZIP entry uses an unsafe or unsupported encoding');
+ }
+ const name = bytes.subarray(offset + 46, offset + 46 + filenameLength).toString('utf8');
+ if (!name || name.startsWith('/') || name.split('/').includes('..') || ((externalAttributes >>> 16) & 0o170000) === 0o120000) {
+ throw new Error('Acceptance trace contains an unsafe entry');
+ }
+ if (localOffset + 30 > bytes.length || bytes.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Acceptance trace ZIP local entry is invalid');
+ const localNameLength = bytes.readUInt16LE(localOffset + 26);
+ const localExtraLength = bytes.readUInt16LE(localOffset + 28);
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
+ const localName = bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength).toString('utf8');
+ if (localName !== name || bytes.readUInt16LE(localOffset + 6) !== flags
+ || bytes.readUInt16LE(localOffset + 8) !== method || dataOffset + compressedSize > bytes.length) {
+ throw new Error('Acceptance trace ZIP entry is truncated or inconsistent');
+ }
+ const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);
+ const value = method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed, { maxOutputLength: 64 * 1024 * 1024 });
+ if (value.length !== uncompressedSize || crc32(value) !== checksum) throw new Error('Acceptance trace ZIP entry integrity failed');
+ totalUncompressed += value.length;
+ if (totalUncompressed > 256 * 1024 * 1024) throw new Error('Acceptance trace ZIP expands beyond its scan limit');
+ entries.push({ name, value });
+ offset = nextOffset;
+ }
+ if (offset !== centralOffset + centralSize) throw new Error('Acceptance trace ZIP directory size changed');
+ return entries;
+};
+
+const scanZip = async (file, sentinels, ocr) => {
+ let entries;
+ try { entries = readZipEntries(await readFile(file)); } catch (error) {
+ throw new Error(`Acceptance trace is not a readable safe ZIP: ${basename(file)}`, { cause: error });
+ }
+ for (const { name, value } of entries) {
+ scanBytes(value, `${file}:${name}`, sentinels);
+ if (renderedImageExtension(name)) await scanRenderedScreenshot(value, `${file}:${name}`, sentinels, ocr);
+ }
+};
+
+export const scanAcceptancePaths = async (paths, sentinels = [], { ocr = defaultOcr } = {}) => {
+ for (const input of paths) {
+ let stats;
+ try { stats = await lstat(input); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
+ if (stats.isSymbolicLink()) throw new Error(`Acceptance scan input is a symbolic link: ${input}`);
+ const files = stats.isDirectory() ? await walkFiles(input) : [input];
+ for (const file of files) {
+ const bytes = await readFile(file);
+ scanBytes(bytes, file, sentinels);
+ if (renderedImageExtension(file)) await scanRenderedScreenshot(bytes, file, sentinels, ocr);
+ if (file.endsWith('.zip')) await scanZip(file, sentinels, ocr);
+ }
+ }
+};
+
+const allowedCleanup = (kind, allowedWorkspaceParents) => {
+ if (kind === 'artifact') {
+ return {
+ parents: new Set((allowedWorkspaceParents ?? [WORKSPACE_ROOT, DESKTOP_ROOT, tmpdir()]).map(path => resolve(path))),
+ name: value => value === ACCEPTANCE_ARTIFACT_LEAF,
+ };
+ }
+ if (kind === 'profile') {
+ return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_PROFILE_PREFIX}[A-Za-z0-9]+$`).test(value) };
+ }
+ if (kind === 'surfaces') {
+ return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_SURFACES_PREFIX}[A-Za-z0-9]+$`).test(value) };
+ }
+ throw new Error('Unknown acceptance cleanup kind');
+};
+
+export const safeRemoveAcceptanceLeaf = async (input, { kind = 'artifact', allowedWorkspaceParents } = {}) => {
+ if (!isAbsolute(input)) throw new Error('Acceptance cleanup requires an absolute path');
+ const target = resolve(input);
+ const policy = allowedCleanup(kind, allowedWorkspaceParents);
+ const parent = dirname(target);
+ if (target === parse(target).root || !policy.name(basename(target))) {
+ throw new Error('Acceptance cleanup target is not a dedicated allowlisted leaf');
+ }
+ const canonicalAllowedParents = new Set();
+ for (const allowedParent of policy.parents) {
+ const allowedStats = await lstat(allowedParent);
+ if (allowedStats.isSymbolicLink() || !allowedStats.isDirectory()) {
+ throw new Error('Acceptance cleanup allowed parent must be an existing non-link directory');
+ }
+ canonicalAllowedParents.add(await realpath(allowedParent));
+ }
+ const parentStats = await lstat(parent);
+ if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) {
+ throw new Error('Acceptance cleanup parent must be an existing non-link directory');
+ }
+ const canonicalParent = await realpath(parent);
+ if (!canonicalAllowedParents.has(canonicalParent)) {
+ throw new Error('Acceptance cleanup target parent is not the canonical allowlisted parent');
+ }
+ let stats;
+ try { stats = await lstat(target); } catch (error) { if (error?.code === 'ENOENT') return; throw error; }
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('Acceptance cleanup target must be a non-link directory');
+ await rm(target, { recursive: true, force: false });
+};
+
+export const prepareAcceptanceArtifactDirectory = async outputDirectory => {
+ const root = resolve(outputDirectory);
+ await safeRemoveAcceptanceLeaf(root, { kind: 'artifact' });
+ await mkdir(join(root, 'screenshots'), { recursive: true, mode: 0o700 });
+ return root;
+};
+
+const expectedArtifactFiles = () => new Set([
+ ...expectedScreenshotNames().map(name => `screenshots/${name}`),
+ 'accessibility.json',
+ 'sanitized-summary.json',
+ 'sanitized-log.json',
+ 'sanitized-trace.zip',
+ 'manifest.json',
+]);
+
+const validateScreenshotEntry = (entry, expectedName) => {
+ assertExactKeys(entry, [
+ 'name', 'journey', 'variant', 'width', 'height', 'physicalPngDimensions', 'reducedMotion',
+ 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport',
+ 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport',
+ 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom',
+ 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor',
+ 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism',
+ 'locale', 'timezone', 'font', 'colorScheme', 'rendererTime', 'originPolicy', 'visibleData',
+ 'animations', 'bytes', 'sha256', 'repeatabilitySha256',
+ ], `Acceptance screenshot ${expectedName}`);
+ const [journey, variantWithExtension] = expectedName.split('--');
+ const variant = variantWithExtension.slice(0, -4);
+ const config = ACCEPTANCE_VARIANTS[variant];
+ if (entry.name !== expectedName || entry.journey !== journey || entry.variant !== variant
+ || entry.width !== config.viewport.width * config.deviceScaleFactor
+ || entry.height !== config.viewport.height * config.deviceScaleFactor
+ || entry.reducedMotion !== config.reducedMotion || entry.locale !== DETERMINISTIC_INPUTS.locale
+ || entry.timezone !== DETERMINISTIC_INPUTS.timezone || entry.font !== DETERMINISTIC_INPUTS.font
+ || entry.colorScheme !== DETERMINISTIC_INPUTS.colorScheme || entry.rendererTime !== FIXED_TIME
+ || entry.originPolicy !== DETERMINISTIC_INPUTS.originPolicy || entry.visibleData !== DETERMINISTIC_INPUTS.visibleData
+ || entry.animations !== DETERMINISTIC_INPUTS.animations || !isInteger(entry.bytes) || entry.bytes === 0
+ || !isSha256(entry.sha256) || entry.repeatabilitySha256 !== entry.sha256) {
+ throw new Error(`Acceptance screenshot metadata changed for ${expectedName}`);
+ }
+ assertViewportMetricEvidence(entry, config, `Acceptance screenshot ${expectedName}`);
+};
+
+const validateAccessibility = accessibility => {
+ assertExactKeys(accessibility, [
+ 'schemaVersion', 'generatedAt', 'serious', 'critical', 'findings', 'checks', 'keyboardOrder',
+ 'visibleFocus', 'modalFocusTrap', 'modalFocusRestore', 'accessibleNames', 'liveAnnouncements',
+ ], 'Acceptance accessibility report');
+ if (accessibility.schemaVersion !== 6 || accessibility.generatedAt !== FIXED_TIME
+ || accessibility.serious !== 0 || accessibility.critical !== 0 || accessibility.findings?.length !== 0
+ || accessibility.keyboardOrder !== true || accessibility.visibleFocus !== true
+ || accessibility.modalFocusTrap !== true || accessibility.modalFocusRestore !== true
+ || accessibility.accessibleNames !== true || accessibility.checks?.length !== expectedScreenshotNames().length) {
+ throw new Error('Acceptance accessibility thresholds were not met');
+ }
+ assertExactKeys(accessibility.liveAnnouncements, ['status', 'error'], 'Acceptance live announcements');
+ for (const [kind, evidence] of Object.entries(accessibility.liveAnnouncements)) {
+ assertExactKeys(evidence, ['journey', 'beforeHash', 'afterHash', 'mutated'], `Acceptance ${kind} live announcement`);
+ if (!ACCEPTANCE_JOURNEYS.includes(evidence.journey) || evidence.mutated !== true
+ || !isSha256(evidence.beforeHash) || !isSha256(evidence.afterHash) || evidence.beforeHash === evidence.afterHash) {
+ throw new Error(`Acceptance ${kind} live region did not mutate`);
+ }
+ }
+ accessibility.checks.forEach((check, index) => {
+ const name = expectedScreenshotNames()[index];
+ assertExactKeys(check, [
+ 'name', 'journey', 'variant', 'serious', 'critical', 'accessibleNames', 'locale', 'timezone',
+ 'fontLoaded', 'reducedMotion', 'animationsDisabled', 'rendererTime', 'physicalPngDimensions',
+ 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport',
+ 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport',
+ 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom',
+ 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor',
+ 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism',
+ ], `Acceptance accessibility check ${name}`);
+ const [journey, variantWithExtension] = name.split('--');
+ const variant = variantWithExtension.slice(0, -4);
+ const config = ACCEPTANCE_VARIANTS[variant];
+ if (check.name !== name || check.journey !== journey || check.variant !== variant
+ || check.reducedMotion !== config.reducedMotion || check.locale !== DETERMINISTIC_INPUTS.locale
+ || check.timezone !== DETERMINISTIC_INPUTS.timezone || check.rendererTime !== FIXED_TIME) {
+ throw new Error(`Acceptance accessibility metadata changed for ${name}`);
+ }
+ assertViewportMetricEvidence(check, config, `Acceptance accessibility check ${name}`);
+ if (check.serious !== 0 || check.critical !== 0 || check.accessibleNames !== true
+ || check.fontLoaded !== true || check.animationsDisabled !== true) {
+ throw new Error(`Acceptance accessibility check failed for ${name}`);
+ }
+ });
+};
+
+const validateSummary = summary => {
+ assertExactKeys(summary, ['schemaVersion', 'generatedAt', 'status', 'journeys', 'screenshots', 'boundary', 'console', 'services', 'redaction'], 'Acceptance summary');
+ if (summary.schemaVersion !== 5 || summary.generatedAt !== FIXED_TIME || summary.status !== 'passed'
+ || summary.journeys !== ACCEPTANCE_JOURNEYS.length || summary.screenshots !== expectedScreenshotNames().length
+ || summary.redaction !== 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.') {
+ throw new Error('Acceptance sanitized summary is invalid');
+ }
+ assertExactKeys(summary.boundary, ['packagedExecutable', 'rendererOrigin', 'preloadBridge', 'journeys'], 'Acceptance boundary summary');
+ if (summary.boundary.packagedExecutable !== true || summary.boundary.rendererOrigin !== 'propr-app://renderer'
+ || summary.boundary.preloadBridge !== true
+ || summary.boundary.journeys?.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance executable boundary was not observed');
+ assertExactKeys(summary.console, ['records', 'errors'], 'Acceptance console summary');
+ if (!isInteger(summary.console.records) || !isInteger(summary.console.errors) || summary.console.errors > summary.console.records) {
+ throw new Error('Acceptance console summary is invalid');
+ }
+ assertExactKeys(summary.services, ['rest', 'socketIo', 'pairing', 'connect'], 'Acceptance service summary');
+ const { rest, socketIo, pairing, connect } = summary.services;
+ assertExactKeys(rest, ['requestCount', 'authenticatedRequestCount', 'journeys', 'currentUserValidation'], 'Acceptance REST summary');
+ assertExactKeys(rest.currentUserValidation, [
+ 'correlation', 'rendererRequestIssued', 'rendererScopeGeneration', 'mainProxyObserved', 'mainActiveScopeGeneration',
+ 'activeScopeAccepted', 'mainOnlyBearerInjection', 'upstreamRequestArrived', 'responseStatus',
+ 'responseClassification', 'parsedUserSchemaAccepted', 'rendererActiveScopeAccepted',
+ 'firstFailedPredicate',
+ ], 'Acceptance current-user validation summary');
+ assertExactKeys(socketIo, ['authenticatedConnections', 'events', 'journeys', 'handshake'], 'Acceptance Socket.IO summary');
+ assertExactKeys(pairing, ['started', 'polled', 'activated', 'journeys'], 'Acceptance pairing summary');
+ assertExactKeys(connect, ['confirmedRequests', 'journeys'], 'Acceptance Connect summary');
+ assertExactKeys(socketIo.handshake, [
+ 'mainAttempts', 'fixtureAttempts', 'scopeQueryPresent', 'scopeQueryCount',
+ 'scopeEqualsActivatedBinding', 'activeBindingPresent', 'profileGenerationCurrent',
+ 'originEqualsActivatedBinding', 'path', 'transport', 'resource',
+ 'rendererBearerPresent', 'rendererCookiePresent', 'authorizationHeaderPresent',
+ 'authorizationHeaderExactlyMainInjected', 'rendererObservedApplicationEvent', 'rendererLifecycle',
+ 'rejectionCategory',
+ ], 'Acceptance Socket.IO handshake summary');
+ const handshake = socketIo.handshake;
+ assertExactKeys(handshake.rendererLifecycle, [
+ 'phase', 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled',
+ 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent',
+ 'desktopRuntime', 'connectionScope', 'socketConstructionInvocations', 'socketConstructions',
+ 'connectInvocations',
+ ], 'Acceptance renderer Socket.IO lifecycle summary');
+ const currentUserValidation = rest.currentUserValidation;
+ if (rest.requestCount <= 0 || rest.authenticatedRequestCount <= 0 || !rest.journeys.includes('dashboard-profile-manager')
+ || currentUserValidation.correlation !== 'current-scope-user-validation'
+ || currentUserValidation.rendererRequestIssued !== true
+ || !isInteger(currentUserValidation.rendererScopeGeneration) || currentUserValidation.rendererScopeGeneration <= 0
+ || currentUserValidation.mainProxyObserved !== true
+ || !isInteger(currentUserValidation.mainActiveScopeGeneration) || currentUserValidation.mainActiveScopeGeneration < 0
+ || currentUserValidation.activeScopeAccepted !== true
+ || currentUserValidation.mainOnlyBearerInjection !== true
+ || currentUserValidation.upstreamRequestArrived !== true
+ || currentUserValidation.responseStatus !== 200
+ || currentUserValidation.responseClassification !== 'success'
+ || currentUserValidation.parsedUserSchemaAccepted !== true
+ || currentUserValidation.rendererActiveScopeAccepted !== true
+ || currentUserValidation.firstFailedPredicate !== 'none'
+ || socketIo.authenticatedConnections !== 1 || socketIo.events <= 0 || !socketIo.journeys.includes('dashboard-profile-manager')
+ || handshake.mainAttempts !== 1 || handshake.fixtureAttempts !== 1
+ || handshake.scopeQueryPresent !== true || handshake.scopeQueryCount !== 1
+ || handshake.scopeEqualsActivatedBinding !== true
+ || handshake.activeBindingPresent !== true || handshake.profileGenerationCurrent !== true
+ || handshake.originEqualsActivatedBinding !== true
+ || handshake.path !== 'socket-io' || handshake.transport !== 'websocket' || handshake.resource !== 'websocket'
+ || handshake.rendererBearerPresent !== false || handshake.rendererCookiePresent !== false
+ || handshake.authorizationHeaderPresent !== true || handshake.authorizationHeaderExactlyMainInjected !== true
+ || handshake.rendererObservedApplicationEvent !== true
+ || handshake.rendererLifecycle.phase !== 'socket-connect-invoked'
+ || handshake.rendererLifecycle.profileActivationPublished !== true
+ || handshake.rendererLifecycle.socketProviderMounted !== true
+ || handshake.rendererLifecycle.providerDisabled !== false
+ || handshake.rendererLifecycle.disabledByDemoModeLoading !== false
+ || handshake.rendererLifecycle.disabledByDemoMode !== false
+ || handshake.rendererLifecycle.disabledByCurrentUserLoading !== false
+ || handshake.rendererLifecycle.disabledByCurrentUserAbsent !== false
+ || handshake.rendererLifecycle.desktopRuntime !== true
+ || handshake.rendererLifecycle.connectionScope !== 'available'
+ || handshake.rendererLifecycle.socketConstructionInvocations !== 1
+ || handshake.rendererLifecycle.socketConstructions !== 1
+ || handshake.rendererLifecycle.connectInvocations !== 1
+ || handshake.rejectionCategory !== 'none'
+ || pairing.started <= 0 || pairing.polled <= 0 || pairing.activated <= 0 || !pairing.journeys.includes('dashboard-profile-manager')
+ || connect.confirmedRequests <= 0 || connect.journeys.join('\n') !== 'connect-confirmation') {
+ throw new Error('Acceptance claimed service journey was not observed');
+ }
+};
+
+const validateSanitizedLog = log => {
+ assertExactKeys(log, ['schemaVersion', 'generatedAt', 'records'], 'Acceptance sanitized log');
+ if (log.schemaVersion !== 1 || log.generatedAt !== FIXED_TIME || !Array.isArray(log.records)) throw new Error('Acceptance sanitized log is invalid');
+ for (const record of log.records) {
+ assertExactKeys(record, ['journey', 'source', 'level', 'bytes', 'sha256'], 'Acceptance sanitized log record');
+ if (!ACCEPTANCE_JOURNEYS.includes(record.journey) || !['renderer-console', 'renderer-page-error', 'packaged-process'].includes(record.source)
+ || typeof record.level !== 'string' || !isInteger(record.bytes) || !isSha256(record.sha256)) {
+ throw new Error('Acceptance sanitized log record is invalid');
+ }
+ }
+ const processJourneys = log.records.filter(record => record.source === 'packaged-process').map(record => record.journey);
+ if (processJourneys.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance sanitized process log coverage is incomplete');
+};
+
+export const validateAcceptanceEvidence = (accessibility, manifest, summary, sanitizedLog) => {
+ assertExactKeys(manifest, [
+ 'schemaVersion', 'generatedAt', 'platform', 'arch', 'executableBoundary', 'deterministicInputs',
+ 'nativePackageCoverage', 'screenshots', 'supporting',
+ ], 'Acceptance manifest');
+ if (manifest.schemaVersion !== 6 || manifest.platform !== 'linux' || manifest.arch !== 'x64'
+ || manifest.generatedAt !== FIXED_TIME || manifest.executableBoundary !== 'packaged-electron-main-preload-renderer'
+ || manifest.screenshots?.length !== expectedScreenshotNames().length) {
+ throw new Error('Acceptance manifest is incomplete or non-deterministic');
+ }
+ assertExactJson(manifest.deterministicInputs, DETERMINISTIC_INPUTS, 'Acceptance deterministic inputs');
+ assertExactJson(manifest.nativePackageCoverage, {
+ 'linux-x64': 'visual-accessibility-runtime',
+ 'linux-arm64': 'structural-runtime-only',
+ 'darwin-x64': 'structural-runtime-only',
+ 'darwin-arm64': 'structural-runtime-only',
+ 'win32-x64': 'structural-runtime-only',
+ 'win32-arm64': 'structural-runtime-only',
+ }, 'Acceptance native package coverage');
+ manifest.screenshots.forEach((entry, index) => validateScreenshotEntry(entry, expectedScreenshotNames()[index]));
+ for (const journey of ACCEPTANCE_JOURNEYS) {
+ const entries = Object.fromEntries(manifest.screenshots
+ .filter(entry => entry.journey === journey)
+ .map(entry => [entry.variant, entry]));
+ if (entries['zoom-200']?.sha256 === entries.standard?.sha256
+ || entries['zoom-200']?.sha256 === entries['high-dpi']?.sha256) {
+ throw new Error(`Acceptance zoom-200 screenshot is not visually distinct for ${journey}`);
+ }
+ }
+ if (!Array.isArray(manifest.supporting) || manifest.supporting.length !== 4) throw new Error('Acceptance supporting manifest is incomplete');
+ const expectedSupporting = ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip'];
+ manifest.supporting.forEach((entry, index) => {
+ assertExactKeys(entry, ['name', 'bytes', 'sha256'], `Acceptance supporting artifact ${expectedSupporting[index]}`);
+ if (entry.name !== expectedSupporting[index] || !isInteger(entry.bytes) || entry.bytes === 0 || !isSha256(entry.sha256)) {
+ throw new Error('Acceptance supporting artifact metadata changed');
+ }
+ });
+ validateAccessibility(accessibility);
+ validateSummary(summary);
+ validateSanitizedLog(sanitizedLog);
+ const rendererLogs = sanitizedLog.records.filter(record => record.source !== 'packaged-process');
+ if (rendererLogs.length !== summary.console.records
+ || rendererLogs.filter(record => record.level === 'error').length !== summary.console.errors) {
+ throw new Error('Acceptance sanitized log and summary counts disagree');
+ }
+};
+
+export const verifyAcceptanceArtifacts = async (outputDirectory, { sentinels = [], ocr = defaultOcr } = {}) => {
+ const root = resolve(outputDirectory);
+ const rootStats = await lstat(root);
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error('Acceptance artifact root must be a non-link directory');
+ const files = await walkFiles(root);
+ const relativeFiles = files.map(path => canonicalRelative(root, path));
+ const found = new Set(relativeFiles);
+ if (found.size !== relativeFiles.length) throw new Error('Duplicate acceptance artifact path');
+ const expected = expectedArtifactFiles();
+ const missing = [...expected].filter(path => !found.has(path));
+ const unexpected = [...found].filter(path => !expected.has(path));
+ if (missing.length || unexpected.length) {
+ throw new Error(`Acceptance artifact set mismatch (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'})`);
+ }
+
+ const accessibility = JSON.parse(await readFile(join(root, 'accessibility.json'), 'utf8'));
+ const summary = JSON.parse(await readFile(join(root, 'sanitized-summary.json'), 'utf8'));
+ const sanitizedLog = JSON.parse(await readFile(join(root, 'sanitized-log.json'), 'utf8'));
+ const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
+ validateAcceptanceEvidence(accessibility, manifest, summary, sanitizedLog);
+
+ for (const entry of manifest.screenshots) {
+ const bytes = await readFile(join(root, 'screenshots', entry.name));
+ const dimensions = readPngDimensions(bytes);
+ if (dimensions.width !== entry.width || dimensions.height !== entry.height
+ || bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) {
+ throw new Error(`Acceptance screenshot bytes, digest, or dimensions changed for ${entry.name}`);
+ }
+ }
+ for (const entry of manifest.supporting) {
+ const bytes = await readFile(join(root, entry.name));
+ if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) throw new Error(`Acceptance supporting artifact changed for ${entry.name}`);
+ }
+ await scanAcceptancePaths([root], sentinels, { ocr });
+ return manifest;
+};
+
+export const writeAcceptanceManifest = async (outputDirectory, screenshotMetadata) => {
+ const root = resolve(outputDirectory);
+ await mkdir(root, { recursive: true });
+ const screenshotMap = new Map(screenshotMetadata.map(entry => [entry.name, entry]));
+ if (screenshotMap.size !== screenshotMetadata.length) throw new Error('Duplicate acceptance screenshot metadata');
+ const expectedNames = new Set(expectedScreenshotNames());
+ const unexpectedMetadata = [...screenshotMap.keys()].filter(name => !expectedNames.has(name));
+ if (unexpectedMetadata.length) throw new Error(`Unexpected acceptance screenshot metadata: ${unexpectedMetadata.join(', ')}`);
+ const screenshots = [];
+ for (const name of expectedScreenshotNames()) {
+ if (!screenshotMap.has(name)) throw new Error(`Acceptance screenshot metadata is missing ${name}`);
+ const path = join(root, 'screenshots', name);
+ const bytes = await readFile(path);
+ screenshots.push({ ...screenshotMap.get(name), name, bytes: bytes.length, sha256: sha256(bytes) });
+ }
+ const supporting = [];
+ for (const name of ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip']) {
+ const bytes = await readFile(join(root, name));
+ supporting.push({ name, bytes: bytes.length, sha256: sha256(bytes) });
+ }
+ const manifest = {
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ platform: 'linux',
+ arch: 'x64',
+ executableBoundary: 'packaged-electron-main-preload-renderer',
+ deterministicInputs: DETERMINISTIC_INPUTS,
+ nativePackageCoverage: {
+ 'linux-x64': 'visual-accessibility-runtime',
+ 'linux-arm64': 'structural-runtime-only',
+ 'darwin-x64': 'structural-runtime-only',
+ 'darwin-arm64': 'structural-runtime-only',
+ 'win32-x64': 'structural-runtime-only',
+ 'win32-arm64': 'structural-runtime-only',
+ },
+ screenshots,
+ supporting,
+ };
+ await writeFile(join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
+ return manifest;
+};
+
diff --git a/apps/desktop/scripts/acceptance-artifacts.test.mjs b/apps/desktop/scripts/acceptance-artifacts.test.mjs
new file mode 100644
index 000000000..4af6aa710
--- /dev/null
+++ b/apps/desktop/scripts/acceptance-artifacts.test.mjs
@@ -0,0 +1,525 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { describe, it } from 'node:test';
+import {
+ ACCEPTANCE_ARTIFACT_LEAF,
+ ACCEPTANCE_JOURNEYS,
+ ACCEPTANCE_VARIANTS,
+ DETERMINISTIC_INPUTS,
+ FIXED_TIME,
+ expectedScreenshotNames,
+ readPngDimensions,
+ safeRemoveAcceptanceLeaf,
+ scanAcceptancePaths,
+ scanRenderedScreenshot,
+ screenshotName,
+ validateAcceptanceEvidence,
+ verifyAcceptanceArtifacts,
+ writeAcceptanceManifest,
+} from './acceptance-artifacts.mjs';
+
+const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
+const crc32 = bytes => {
+ let crc = 0xffffffff;
+ for (const byte of bytes) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+const zipFixture = (name, contents) => {
+ const filename = Buffer.from(name);
+ const data = Buffer.from(contents);
+ const checksum = crc32(data);
+ const local = Buffer.alloc(30 + filename.length);
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt32LE(checksum, 14);
+ local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(filename.length, 26);
+ filename.copy(local, 30);
+ const central = Buffer.alloc(46 + filename.length);
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
+ central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24);
+ central.writeUInt16LE(filename.length, 28); filename.copy(central, 46);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
+ end.writeUInt32LE(central.length, 12); end.writeUInt32LE(local.length + data.length, 16);
+ return Buffer.concat([local, data, central, end]);
+};
+const pngHeader = (width, height) => {
+ const bytes = Buffer.alloc(24);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
+ bytes.write('IHDR', 12, 'ascii');
+ bytes.writeUInt32BE(width, 16);
+ bytes.writeUInt32BE(height, 20);
+ return bytes;
+};
+
+const viewportMetricEvidenceFor = config => {
+ const rendererViewport = {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ };
+ const scrollbarInsets = config.zoom === 2 ? { width: 8, height: 0 } : { width: 0, height: 0 };
+ const visualViewportInsets = config.zoom === 2 ? { width: 7.5, height: 0 } : { width: 0, height: 0 };
+ const documentClientViewport = {
+ width: rendererViewport.width - scrollbarInsets.width,
+ height: rendererViewport.height - scrollbarInsets.height,
+ };
+ const rendererVisualViewport = {
+ width: rendererViewport.width - visualViewportInsets.width,
+ height: rendererViewport.height - visualViewportInsets.height,
+ scale: 1,
+ };
+ return {
+ requestedViewport: { ...config.viewport },
+ playwrightViewport: { ...config.viewport },
+ rendererViewport,
+ documentClientViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ layoutViewport: { ...documentClientViewport },
+ cdpVisualViewport: { ...rendererVisualViewport },
+ rendererVisualViewport,
+ effectiveVisibleCssSpan: { ...rendererViewport },
+ geometryZoom: { width: config.zoom, height: config.zoom },
+ requestedDeviceScaleFactor: config.deviceScaleFactor,
+ rendererDevicePixelRatio: config.deviceScaleFactor * config.zoom,
+ requestedZoomFactor: config.zoom,
+ appliedZoomFactor: config.zoom,
+ zoomResetFactor: 1,
+ zoomMechanism: 'electron-web-frame',
+ physicalPngDimensions: {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ },
+ };
+};
+
+const accessibilityFor = () => ({
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ serious: 0,
+ critical: 0,
+ findings: [],
+ checks: ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => ({
+ name: screenshotName(journey, variant), journey, variant, serious: 0, critical: 0, accessibleNames: true,
+ locale: DETERMINISTIC_INPUTS.locale, timezone: DETERMINISTIC_INPUTS.timezone, fontLoaded: true,
+ reducedMotion: config.reducedMotion, animationsDisabled: true, rendererTime: FIXED_TIME,
+ ...viewportMetricEvidenceFor(config),
+ }))),
+ keyboardOrder: true,
+ visibleFocus: true,
+ modalFocusTrap: true,
+ modalFocusRestore: true,
+ accessibleNames: true,
+ liveAnnouncements: {
+ status: { journey: 'remote-pairing', beforeHash: '1'.repeat(64), afterHash: '2'.repeat(64), mutated: true },
+ error: { journey: 'offline', beforeHash: '3'.repeat(64), afterHash: '4'.repeat(64), mutated: true },
+ },
+});
+
+const summaryFor = () => ({
+ schemaVersion: 5,
+ generatedAt: FIXED_TIME,
+ status: 'passed',
+ journeys: ACCEPTANCE_JOURNEYS.length,
+ screenshots: expectedScreenshotNames().length,
+ boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS },
+ console: { records: 3, errors: 0 },
+ services: {
+ rest: {
+ requestCount: 12,
+ authenticatedRequestCount: 3,
+ journeys: ['dashboard-profile-manager'],
+ currentUserValidation: {
+ correlation: 'current-scope-user-validation',
+ rendererRequestIssued: true,
+ rendererScopeGeneration: 1,
+ mainProxyObserved: true,
+ mainActiveScopeGeneration: 0,
+ activeScopeAccepted: true,
+ mainOnlyBearerInjection: true,
+ upstreamRequestArrived: true,
+ responseStatus: 200,
+ responseClassification: 'success',
+ parsedUserSchemaAccepted: true,
+ rendererActiveScopeAccepted: true,
+ firstFailedPredicate: 'none',
+ },
+ },
+ socketIo: {
+ authenticatedConnections: 1,
+ events: 1,
+ journeys: ['dashboard-profile-manager'],
+ handshake: {
+ mainAttempts: 1,
+ fixtureAttempts: 1,
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ scopeEqualsActivatedBinding: true,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActivatedBinding: true,
+ path: 'socket-io',
+ transport: 'websocket',
+ resource: 'websocket',
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ authorizationHeaderPresent: true,
+ authorizationHeaderExactlyMainInjected: true,
+ rendererObservedApplicationEvent: true,
+ rendererLifecycle: {
+ phase: 'socket-connect-invoked',
+ profileActivationPublished: true,
+ socketProviderMounted: true,
+ providerDisabled: false,
+ disabledByDemoModeLoading: false,
+ disabledByDemoMode: false,
+ disabledByCurrentUserLoading: false,
+ disabledByCurrentUserAbsent: false,
+ desktopRuntime: true,
+ connectionScope: 'available',
+ socketConstructionInvocations: 1,
+ socketConstructions: 1,
+ connectInvocations: 1,
+ },
+ rejectionCategory: 'none',
+ },
+ },
+ pairing: { started: 1, polled: 1, activated: 1, journeys: ['dashboard-profile-manager'] },
+ connect: { confirmedRequests: 1, journeys: ['connect-confirmation'] },
+ },
+ redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.',
+});
+
+const sanitizedLogFor = () => ({
+ schemaVersion: 1,
+ generatedAt: FIXED_TIME,
+ records: [
+ ...Array.from({ length: 3 }, (_, index) => ({
+ journey: 'first-run-chooser', source: 'renderer-console', level: 'info', bytes: index, sha256: '5'.repeat(64),
+ })),
+ ...ACCEPTANCE_JOURNEYS.map(journey => ({
+ journey, source: 'packaged-process', level: 'combined', bytes: 0, sha256: '6'.repeat(64),
+ })),
+ ],
+});
+
+const createCompleteArtifactSet = async root => {
+ await mkdir(join(root, 'screenshots'), { recursive: true });
+ const metadata = [];
+ for (const journey of ACCEPTANCE_JOURNEYS) {
+ for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) {
+ const name = screenshotName(journey, variant);
+ const bytes = Buffer.concat([
+ pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor),
+ Buffer.from(`${journey}:${variant}`),
+ ]);
+ await writeFile(join(root, 'screenshots', name), bytes);
+ metadata.push({
+ name, journey, variant,
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ reducedMotion: config.reducedMotion,
+ ...viewportMetricEvidenceFor(config),
+ locale: DETERMINISTIC_INPUTS.locale,
+ timezone: DETERMINISTIC_INPUTS.timezone,
+ font: DETERMINISTIC_INPUTS.font,
+ colorScheme: DETERMINISTIC_INPUTS.colorScheme,
+ rendererTime: FIXED_TIME,
+ originPolicy: DETERMINISTIC_INPUTS.originPolicy,
+ visibleData: DETERMINISTIC_INPUTS.visibleData,
+ animations: DETERMINISTIC_INPUTS.animations,
+ repeatabilitySha256: sha256(bytes),
+ });
+ }
+ }
+ const accessibility = accessibilityFor();
+ const summary = summaryFor();
+ const sanitizedLog = sanitizedLogFor();
+ await writeFile(join(root, 'accessibility.json'), `${JSON.stringify(accessibility)}\n`);
+ await writeFile(join(root, 'sanitized-summary.json'), `${JSON.stringify(summary)}\n`);
+ await writeFile(join(root, 'sanitized-log.json'), `${JSON.stringify(sanitizedLog)}\n`);
+ await writeFile(join(root, 'sanitized-trace.zip'), zipFixture('trace-entry.txt', 'sanitized trace'));
+ const manifest = await writeAcceptanceManifest(root, metadata);
+ return { accessibility, manifest, summary, sanitizedLog };
+};
+
+describe('packaged acceptance artifact contract', () => {
+ it('has exactly 60 deterministic unique names and physical dimensions', () => {
+ const names = ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => {
+ assert.deepEqual(readPngDimensions(pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor)), {
+ width: config.viewport.width * config.deviceScaleFactor,
+ height: config.viewport.height * config.deviceScaleFactor,
+ });
+ return screenshotName(journey, variant);
+ }));
+ assert.equal(names.length, 60);
+ assert.deepEqual(names, expectedScreenshotNames());
+ assert.equal(new Set(names).size, 60);
+ assert.ok(names.every(name => /^[a-z0-9-]+--[a-z0-9-]+\.png$/.test(name)));
+ });
+
+ it('writes and verifies the complete strict 60-entry manifest and supporting set', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-full-'));
+ const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ const evidence = await createCompleteArtifactSet(root);
+ assert.equal(evidence.manifest.screenshots.length, 60);
+ assert.equal(evidence.manifest.schemaVersion, 6);
+ assert.equal(evidence.accessibility.schemaVersion, 6);
+ const zoomEvidence = evidence.manifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ assert.deepEqual(zoomEvidence.rendererViewport, { width: 640, height: 410 });
+ assert.deepEqual(zoomEvidence.documentClientViewport, { width: 632, height: 410 });
+ assert.deepEqual(zoomEvidence.layoutViewport, { width: 632, height: 410 });
+ assert.deepEqual(zoomEvidence.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(zoomEvidence.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(zoomEvidence.scrollbarInsets, { width: 8, height: 0 });
+ assert.deepEqual(zoomEvidence.visualViewportInsets, { width: 7.5, height: 0 });
+ assert.deepEqual(evidence.manifest.screenshots.map(entry => entry.name), expectedScreenshotNames());
+ assert.ok(evidence.manifest.screenshots.every(entry => entry.bytes > 24 && entry.sha256 === entry.repeatabilitySha256));
+ assert.deepEqual(evidence.manifest.supporting.map(entry => entry.name), [
+ 'accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip',
+ ]);
+ assert.doesNotThrow(() => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog));
+ const verified = await verifyAcceptanceArtifacts(root, { ocr: async () => '' });
+ assert.deepEqual(verified, JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')));
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('scans full persisted surfaces and OCR-recognized rendered pixels', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-scan-'));
+ try {
+ await mkdir(join(root, 'storage'));
+ await writeFile(join(root, 'storage', 'safe.json'), '{"status":"ready"}');
+ await scanAcceptancePaths([root], ['acceptance-exact-sentinel']);
+ await assert.rejects(
+ scanRenderedScreenshot(Buffer.from('not-used-by-mock'), 'screenshot.png', ['acceptance-exact-sentinel'], async () => 'acceptance-exact-sentinel'),
+ /rendered pixels/,
+ );
+ await writeFile(join(root, 'storage', 'unsafe.log'), 'acceptance-exact-sentinel');
+ await assert.rejects(scanAcceptancePaths([root], ['acceptance-exact-sentinel']), /Secret sentinel/);
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it('keeps generic matching within printable binary runs and exact sentinel scanning raw', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-binary-scan-'));
+ const database = join(root, 'key4.db');
+ const binaryAssignmentBridge = Buffer.concat([
+ Buffer.from('SQLite format 3\0NSS metadata password=', 'ascii'),
+ Buffer.from([0x00, 0x80, 0xff, 0x01]),
+ Buffer.from('binary-record-body', 'ascii'),
+ Buffer.from([0x00, 0x02]),
+ ]);
+ try {
+ assert.match(binaryAssignmentBridge.toString('utf8'), /password\s*[=:]\s*[^\s"']{8,}/i);
+ await writeFile(database, binaryAssignmentBridge);
+ await scanAcceptancePaths([database]);
+
+ for (const credential of [
+ `propr_it_${'a'.repeat(20)}`,
+ `ghp_${'b'.repeat(20)}`,
+ `Bearer ${'c'.repeat(16)}`,
+ 'token=printable-value',
+ 'secret: printable-value',
+ 'password = printable-value',
+ 'private-key=printable-value',
+ ]) {
+ await writeFile(database, Buffer.concat([
+ binaryAssignmentBridge,
+ Buffer.from([0x00, 0xff]),
+ Buffer.from(credential, 'ascii'),
+ Buffer.from([0x00]),
+ ]));
+ await assert.rejects(scanAcceptancePaths([database]), /Secret-shaped value/);
+ }
+
+ const sentinel = 'acceptance-exact-sentinel';
+ await writeFile(database, Buffer.concat([
+ Buffer.from([0x00, 0x80, 0xff]),
+ Buffer.from(sentinel, 'ascii'),
+ Buffer.from([0x01, 0xfe, 0x00]),
+ ]));
+ await assert.rejects(scanAcceptancePaths([database], [sentinel]), /Secret sentinel/);
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it('fails closed on missing, mutated, and unexpected artifact data', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-invalid-'));
+ const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ await mkdir(join(root, 'screenshots'), { recursive: true });
+ await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /artifact set mismatch.*missing/i);
+ const evidence = await createCompleteArtifactSet(root);
+ assert.throws(
+ () => validateAcceptanceEvidence({ ...evidence.accessibility, extra: true }, evidence.manifest, evidence.summary, evidence.sanitizedLog),
+ /schema changed/,
+ );
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, {
+ ...evidence.summary,
+ services: { ...evidence.summary.services, connect: { confirmedRequests: 0, journeys: [] } },
+ }, evidence.sanitizedLog),
+ /not observed/,
+ );
+ for (const mutateHandshake of [
+ handshake => { handshake.scopeQueryPresent = false; handshake.scopeQueryCount = 0; },
+ handshake => { handshake.scopeQueryCount = 2; },
+ handshake => { handshake.scopeEqualsActivatedBinding = false; handshake.rejectionCategory = 'stale-scope'; },
+ handshake => { handshake.rejectionCategory = 'wrong-origin'; },
+ handshake => { handshake.rendererBearerPresent = true; },
+ ]) {
+ const invalidSummary = structuredClone(evidence.summary);
+ mutateHandshake(invalidSummary.services.socketIo.handshake);
+ assert.throws(
+ () => validateAcceptanceEvidence(
+ evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog,
+ ),
+ /not observed/,
+ );
+ }
+ for (const mutateLifecycle of [
+ lifecycle => { lifecycle.profileActivationPublished = false; },
+ lifecycle => { lifecycle.socketProviderMounted = false; },
+ lifecycle => { lifecycle.providerDisabled = true; },
+ lifecycle => { lifecycle.disabledByDemoModeLoading = true; },
+ lifecycle => { lifecycle.disabledByDemoMode = true; },
+ lifecycle => { lifecycle.disabledByCurrentUserLoading = true; },
+ lifecycle => { lifecycle.disabledByCurrentUserAbsent = true; },
+ lifecycle => { lifecycle.connectionScope = 'unavailable'; },
+ lifecycle => { lifecycle.socketConstructionInvocations = 0; },
+ lifecycle => { lifecycle.socketConstructions = 0; },
+ lifecycle => { lifecycle.connectInvocations = 0; },
+ lifecycle => { lifecycle.connectInvocations = 2; },
+ ]) {
+ const invalidSummary = structuredClone(evidence.summary);
+ mutateLifecycle(invalidSummary.services.socketIo.handshake.rendererLifecycle);
+ assert.throws(
+ () => validateAcceptanceEvidence(
+ evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog,
+ ),
+ /not observed/,
+ );
+ }
+ const relabeledManifest = structuredClone(evidence.manifest);
+ const zoomEntry = relabeledManifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ zoomEntry.appliedZoomFactor = 1;
+ zoomEntry.rendererDevicePixelRatio = 1;
+ zoomEntry.geometryZoom = { width: 1, height: 1 };
+ zoomEntry.effectiveVisibleCssSpan = { width: 1280, height: 820 };
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, relabeledManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ for (const mutate of [
+ entry => { entry.layoutViewport.width += 1; },
+ entry => { entry.scrollbarInsets.width += 1; },
+ entry => { entry.visualViewportInsets.width += 1; },
+ entry => { entry.rendererVisualViewport.width += 0.5; },
+ entry => { entry.cdpVisualViewport.width += 0.5; },
+ entry => {
+ entry.documentClientViewport.width = entry.rendererViewport.width - 65;
+ entry.layoutViewport.width = entry.documentClientViewport.width;
+ entry.scrollbarInsets.width = 65;
+ },
+ entry => {
+ entry.visualViewportInsets.width = 65;
+ entry.rendererVisualViewport.width = entry.rendererViewport.width - 65;
+ entry.cdpVisualViewport.width = entry.rendererVisualViewport.width;
+ },
+ entry => { entry.visualViewportInsets.width = Number.NaN; },
+ ]) {
+ const invalidScrollbarManifest = structuredClone(evidence.manifest);
+ mutate(invalidScrollbarManifest.screenshots.find(entry => entry.variant === 'zoom-200'));
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, invalidScrollbarManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ }
+ const invalidAccessibility = structuredClone(evidence.accessibility);
+ invalidAccessibility.checks.find(check => check.variant === 'zoom-200').visualViewportInsets.width = 8;
+ assert.throws(
+ () => validateAcceptanceEvidence(invalidAccessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200.*viewport metric evidence changed/,
+ );
+ const duplicateManifest = structuredClone(evidence.manifest);
+ const duplicateZoom = duplicateManifest.screenshots.find(entry => entry.variant === 'zoom-200');
+ const matchingHighDpi = duplicateManifest.screenshots.find(entry => (
+ entry.journey === duplicateZoom.journey && entry.variant === 'high-dpi'
+ ));
+ duplicateZoom.sha256 = matchingHighDpi.sha256;
+ duplicateZoom.repeatabilitySha256 = matchingHighDpi.sha256;
+ assert.throws(
+ () => validateAcceptanceEvidence(evidence.accessibility, duplicateManifest, evidence.summary, evidence.sanitizedLog),
+ /zoom-200 screenshot is not visually distinct/,
+ );
+ const screenshotPath = join(root, 'screenshots', expectedScreenshotNames()[0]);
+ await writeFile(screenshotPath, pngHeader(1, 1));
+ await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /bytes, digest, or dimensions/);
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('only recursively removes a dedicated non-link allowlisted leaf', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-cleanup-'));
+ try {
+ const leaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ await mkdir(join(leaf, 'nested'), { recursive: true });
+ await writeFile(join(leaf, 'nested', 'artifact'), 'safe');
+ await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] });
+ await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] });
+ await assert.rejects(safeRemoveAcceptanceLeaf(resolve(parent), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /dedicated allowlisted leaf/);
+ const tooDeep = join(parent, 'nested', ACCEPTANCE_ARTIFACT_LEAF);
+ await mkdir(tooDeep, { recursive: true });
+ await assert.rejects(safeRemoveAcceptanceLeaf(resolve(tooDeep), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /canonical allowlisted parent/);
+ const realParent = join(parent, 'real-parent');
+ const linkedParent = join(parent, 'linked-parent');
+ await mkdir(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), { recursive: true });
+ await symlink(realParent, linkedParent);
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(resolve(linkedParent, ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [linkedParent] }),
+ /allowed parent must be an existing non-link directory/,
+ );
+ const outside = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-escape-'));
+ try {
+ const escapedParent = join(parent, 'escaped-parent');
+ await symlink(outside, escapedParent);
+ await mkdir(join(outside, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { recursive: true });
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(join(escapedParent, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [parent] }),
+ /canonical allowlisted parent/,
+ );
+ } finally {
+ await rm(outside, { recursive: true, force: true });
+ }
+ const linkedLeaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF);
+ await symlink(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), linkedLeaf);
+ await assert.rejects(
+ safeRemoveAcceptanceLeaf(resolve(linkedLeaf), { kind: 'artifact', allowedWorkspaceParents: [parent] }),
+ /non-link directory/,
+ );
+ } finally { await rm(parent, { recursive: true, force: true }); }
+ });
+
+ it('authorizes the standard macOS temporary-directory alias by canonical parent identity', {
+ skip: process.platform !== 'darwin' ? 'macOS-only /var alias regression' : false,
+ }, async t => {
+ const aliasedParent = await mkdtemp(join(resolve(tmpdir()), 'propr-acceptance-macos-alias-'));
+ const canonicalParent = await realpath(aliasedParent);
+ if (aliasedParent === canonicalParent) {
+ await rm(aliasedParent, { recursive: true, force: true });
+ t.skip('the configured macOS temporary directory does not use an alias');
+ return;
+ }
+ const leaf = join(aliasedParent, ACCEPTANCE_ARTIFACT_LEAF);
+ try {
+ await mkdir(leaf);
+ await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] });
+ await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] });
+ } finally {
+ await rm(aliasedParent, { recursive: true, force: true });
+ }
+ });
+});
+
diff --git a/apps/desktop/scripts/packaged-acceptance-axe.mjs b/apps/desktop/scripts/packaged-acceptance-axe.mjs
new file mode 100644
index 000000000..631194f19
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-axe.mjs
@@ -0,0 +1,172 @@
+import { createHash } from 'node:crypto';
+import { createRequire } from 'node:module';
+import axeCore from 'axe-core';
+
+const require = createRequire(import.meta.url);
+const axePackage = require('axe-core/package.json');
+
+export const PACKAGED_AXE_VERSION = '4.10.3';
+export const PACKAGED_AXE_SOURCE_SHA256 = '5248a3ce82beae19e2bbd73dc209055747cda599e59a02f3272045206904254f';
+export const PACKAGED_AXE_FRAME_POLICY = 'reject-all-child-frames';
+export const PACKAGED_AXE_CLEANUP_TIMEOUT_MS = 5_000;
+
+const axeSource = axeCore?.source;
+const axeSourceSha256 = typeof axeSource === 'string'
+ ? createHash('sha256').update(axeSource).digest('hex')
+ : '';
+
+if (axePackage?.name !== 'axe-core' || axePackage?.version !== PACKAGED_AXE_VERSION
+ || axeCore?.version !== PACKAGED_AXE_VERSION || typeof axeSource !== 'string'
+ || axeSource.length === 0 || axeSourceSha256 !== PACKAGED_AXE_SOURCE_SHA256) {
+ throw new Error('Packaged accessibility axe-core source identity or version is invalid');
+}
+
+const validateProjectedViolations = violations => {
+ if (!Array.isArray(violations)) {
+ throw new Error('Packaged accessibility result schema is invalid');
+ }
+ for (const violation of violations) {
+ if (!violation || typeof violation !== 'object'
+ || Object.keys(violation).length !== 3
+ || typeof violation.id !== 'string' || violation.id.length === 0
+ || (violation.impact !== 'serious' && violation.impact !== 'critical')
+ || !Number.isInteger(violation.nodes) || violation.nodes < 1) {
+ throw new Error('Packaged accessibility result schema is invalid');
+ }
+ }
+ return violations;
+};
+
+/**
+ * Run the pinned axe-core engine in the already-authenticated renderer target.
+ *
+ * The acceptance UI is intentionally single-frame. Child frames are rejected
+ * instead of being silently skipped or aggregated in a new page. Within that
+ * document, axe.run(document) performs axe-core's native composed-tree walk,
+ * including all open shadow roots supported by the engine.
+ */
+export const analyzeExistingElectronRenderer = async page => {
+ if (!page || typeof page.evaluate !== 'function' || typeof page.frames !== 'function'
+ || typeof page.mainFrame !== 'function') {
+ throw new Error('Packaged accessibility renderer target is invalid');
+ }
+
+ const frames = page.frames();
+ if (PACKAGED_AXE_FRAME_POLICY !== 'reject-all-child-frames'
+ || frames.length !== 1 || frames[0] !== page.mainFrame()) {
+ throw new Error('Packaged accessibility does not permit child frames');
+ }
+
+ let injected = false;
+ let primaryError;
+ try {
+ const injectionBoundary = await page.evaluate(() => ({
+ childFrames: window.frames.length,
+ existingAxe: typeof globalThis.axe !== 'undefined',
+ }));
+ if (!injectionBoundary || injectionBoundary.childFrames !== 0 || injectionBoundary.existingAxe !== false) {
+ throw new Error('Packaged accessibility injection boundary is invalid');
+ }
+
+ try {
+ injected = true;
+ await page.evaluate(axeSource);
+ } catch (error) {
+ throw new Error('Packaged accessibility axe-core injection failed', { cause: error });
+ }
+
+ const identity = await page.evaluate(() => ({
+ version: globalThis.axe?.version,
+ run: typeof globalThis.axe?.run,
+ cleanup: typeof globalThis.axe?.cleanup,
+ }));
+ if (!identity || identity.version !== PACKAGED_AXE_VERSION
+ || identity.run !== 'function' || identity.cleanup !== 'function') {
+ throw new Error('Packaged accessibility injected engine identity or version is invalid');
+ }
+
+ let violations;
+ try {
+ violations = await page.evaluate(async expectedVersion => {
+ const engine = globalThis.axe;
+ const results = await engine.run(document, { resultTypes: ['violations'] });
+ const validImpact = new Set([null, 'minor', 'moderate', 'serious', 'critical']);
+ if (!results || typeof results !== 'object'
+ || results.testEngine?.name !== 'axe-core' || results.testEngine?.version !== expectedVersion
+ || !Array.isArray(results.violations)) {
+ throw new Error('axe-core returned an invalid result envelope');
+ }
+ const projected = [];
+ for (const violation of results.violations) {
+ if (!violation || typeof violation !== 'object'
+ || typeof violation.id !== 'string' || violation.id.length === 0
+ || !validImpact.has(violation.impact) || !Array.isArray(violation.nodes)
+ || violation.nodes.some(node => !node || typeof node !== 'object')) {
+ throw new Error('axe-core returned an invalid violation');
+ }
+ if (violation.impact === 'serious' || violation.impact === 'critical') {
+ projected.push({ id: violation.id, impact: violation.impact, nodes: violation.nodes.length });
+ }
+ }
+ return projected;
+ }, PACKAGED_AXE_VERSION);
+ } catch (error) {
+ throw new Error('Packaged accessibility axe-core execution or result validation failed', { cause: error });
+ }
+ return validateProjectedViolations(violations);
+ } catch (error) {
+ primaryError = error;
+ throw error;
+ } finally {
+ if (injected) {
+ let cleaned = false;
+ let cleanupError;
+ try {
+ cleaned = await page.evaluate(async cleanupTimeoutMs => {
+ const engine = globalThis.axe;
+ if (!engine || typeof engine.cleanup !== 'function') {
+ throw new Error('axe-core cleanup is unavailable');
+ }
+ // axe-core 4.10.3 cleanup completes through these callbacks after
+ // its plugin/frame queue drains; its direct return value is not completion.
+ await new Promise((resolve, reject) => {
+ let settled = false;
+ let timeout;
+ const settle = callback => value => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ callback(value);
+ };
+ const resolveCleanup = settle(resolve);
+ const rejectCleanup = settle(reject);
+ timeout = setTimeout(
+ () => rejectCleanup(new Error(`axe-core cleanup timed out after ${cleanupTimeoutMs}ms`)),
+ cleanupTimeoutMs,
+ );
+ try {
+ engine.cleanup(resolveCleanup, rejectCleanup);
+ } catch (error) {
+ rejectCleanup(error);
+ }
+ });
+ return Reflect.deleteProperty(globalThis, 'axe') && typeof globalThis.axe === 'undefined';
+ }, PACKAGED_AXE_CLEANUP_TIMEOUT_MS);
+ } catch (error) {
+ cleanupError = new Error('Packaged accessibility axe-core cleanup failed', { cause: error });
+ }
+ if (!cleaned && !cleanupError) {
+ cleanupError = new Error('Packaged accessibility axe-core cleanup failed');
+ }
+ if (cleanupError) {
+ if (!primaryError) throw cleanupError;
+ throw new AggregateError(
+ [primaryError, cleanupError],
+ primaryError instanceof Error ? primaryError.message : String(primaryError),
+ { cause: primaryError },
+ );
+ }
+ }
+ }
+};
+
diff --git a/apps/desktop/scripts/packaged-acceptance-clock.d.mts b/apps/desktop/scripts/packaged-acceptance-clock.d.mts
new file mode 100644
index 000000000..f0e0c9eb5
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-clock.d.mts
@@ -0,0 +1,3 @@
+export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS: number;
+export const PACKAGED_ACCEPTANCE_TIME: string;
+
diff --git a/apps/desktop/scripts/packaged-acceptance-clock.mjs b/apps/desktop/scripts/packaged-acceptance-clock.mjs
new file mode 100644
index 000000000..ec284f5c2
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-clock.mjs
@@ -0,0 +1,9 @@
+/**
+ * One epoch owns every deterministic packaged-acceptance clock. Production
+ * code must consume it only through the dual-authorized acceptance boundary.
+ */
+export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS = 1_767_323_045_000;
+export const PACKAGED_ACCEPTANCE_TIME = new Date(
+ PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+).toISOString();
+
diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.mjs
new file mode 100644
index 000000000..83bdc6c62
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-current-user.mjs
@@ -0,0 +1,189 @@
+import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared';
+
+export const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+
+export const scopedCurrentUserRequestGeneration = (method, url) => {
+ if (method !== 'GET' || typeof url !== 'string') return null;
+ let parsed;
+ try {
+ parsed = new URL(url, 'http://fixture.invalid');
+ } catch {
+ return null;
+ }
+ if (parsed.pathname !== '/api/auth/user') return null;
+ const entries = [...parsed.searchParams];
+ if (entries.length !== 1 || entries[0][0] !== CURRENT_USER_SCOPE_GENERATION_QUERY) return null;
+ const value = entries[0][1];
+ if (!/^(?:0|[1-9]\d{0,15})$/.test(value)
+ || parsed.search !== `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${value}`) return null;
+ const generation = Number(value);
+ return Number.isSafeInteger(generation) ? generation : null;
+};
+
+export const isExpectedScopedCurrentUserRequest = (method, url, expectedGeneration) => (
+ Number.isSafeInteger(expectedGeneration)
+ && expectedGeneration >= 0
+ && scopedCurrentUserRequestGeneration(method, url) === expectedGeneration
+);
+
+/** Classify only the two authenticated current-user request shapes used by desktop. */
+export const classifyCurrentUserRequestShape = (method, url, origin) => {
+ if (method !== 'GET' || typeof url !== 'string') return null;
+ let parsed;
+ try {
+ parsed = new URL(url, 'http://fixture.invalid');
+ } catch {
+ return null;
+ }
+ if (parsed.pathname !== '/api/auth/user') return null;
+
+ if (origin === DESKTOP_RENDERER_ORIGIN) {
+ const scopeGeneration = scopedCurrentUserRequestGeneration(method, url);
+ return scopeGeneration === null ? null : { source: 'renderer', scopeGeneration };
+ }
+
+ return url === '/api/auth/user' && parsed.search === '' && [...parsed.searchParams].length === 0
+ ? { source: 'main', scopeGeneration: null }
+ : null;
+};
+
+const boundedCount = records => Math.min(records.length, 9);
+
+/** Fixed, bounded, secret-free counts for Local Network Access decisions. */
+export const networkPermissionDecisionSummary = ({ journey, records, invalidCount = 0 }) => {
+ const selected = records.filter(record => record.journey === journey);
+ const count = predicate => boundedCount(selected.filter(predicate));
+ return {
+ schemaVersion: 1,
+ records: boundedCount(selected),
+ check: count(record => record.decision === 'check'),
+ request: count(record => record.decision === 'request'),
+ allowed: count(record => record.allowed === true),
+ denied: count(record => record.allowed === false),
+ localNetworkAccess: count(record => record.permissionCategory === 'local-network-access'),
+ localNetwork: count(record => record.permissionCategory === 'local-network'),
+ loopbackNetwork: count(record => record.permissionCategory === 'loopback-network'),
+ activeBindingCurrent: count(record => record.activeBindingCurrent === true),
+ webContentsPresent: count(record => record.webContentsPresent === true),
+ webContentsEqualsMainWindow: count(record => record.webContentsEqualsMainWindow === true),
+ mainWindowPresent: count(record => record.mainWindowPresent === true),
+ mainFrame: count(record => record.isMainFrame === true),
+ requestingUrlPresent: count(record => record.requestingUrlPresent === true),
+ requestingUrlTrusted: count(record => record.requestingUrlTrusted === true),
+ rendererDocumentUrlTrusted: count(record => record.rendererDocumentUrlTrusted === true),
+ requestingOriginAuthorityValid: count(record => record.requestingOriginAuthorityValid === true),
+ requestingOriginAuthorityEqual: count(record => record.requestingOriginAuthorityEqual === true),
+ invalid: Math.min(invalidCount, 9),
+ };
+};
+
+/** Fixed, bounded, secret-free diagnostics for the strict hosted boundary. */
+export const currentUserValidationPhaseSummary = ({
+ journey,
+ rendererRecords,
+ mainRecords,
+ fixtureRecords,
+ requestRecords,
+}) => {
+ const renderer = rendererRecords.filter(record => record.journey === journey
+ && record.activeScopePresent && record.scopeGeneration > 0);
+ const main = mainRecords.filter(record => record.journey === journey);
+ const fixture = fixtureRecords.filter(record => record.journey === journey
+ && record.source === 'renderer');
+ const currentUserRequests = requestRecords.filter(record => record.journey === journey
+ && record.origin === DESKTOP_RENDERER_ORIGIN
+ && scopedCurrentUserRequestGeneration('GET', record.url) !== null);
+ const phaseCount = phase => boundedCount(renderer.filter(record => record.phase === phase));
+ return {
+ schemaVersion: 1,
+ options: boundedCount(currentUserRequests.filter(record => record.method === 'OPTIONS')),
+ get: boundedCount(currentUserRequests.filter(record => record.method === 'GET')),
+ main: boundedCount(main),
+ mainAccepted: boundedCount(main.filter(record => record.accepted === true)),
+ fixture: boundedCount(fixture),
+ fixture200: boundedCount(fixture.filter(record => record.responseStatus === 200)),
+ requestIssued: phaseCount('request-issued'),
+ responseCompleted: phaseCount('response-completed'),
+ parsedUserAccepted: phaseCount('parsed-user-accepted'),
+ activeScopeAccepted: phaseCount('active-scope-accepted'),
+ rejected: boundedCount(renderer.filter(record => record.phase.endsWith('-rejected'))),
+ };
+};
+
+export const currentUserValidationFailureCategory = ({
+ journey,
+ evidenceInvalid,
+ rendererRecords,
+ mainRecords,
+ fixtureRecords,
+}) => {
+ if (evidenceInvalid) return 'current-user-evidence-invalid';
+ const renderer = rendererRecords.filter(record => record.journey === journey
+ && record.activeScopePresent && record.scopeGeneration > 0);
+ const issued = renderer.filter(record => record.phase === 'request-issued');
+ if (issued.length === 0) return 'current-user-renderer-request-not-issued';
+ if (issued.length !== 1) return 'current-user-renderer-request-duplicate';
+ const generation = issued[0].scopeGeneration;
+
+ // Correlate the exact journey first. Security predicates below must diagnose
+ // the selected request rather than making rejected evidence unobservable.
+ const main = mainRecords.filter(record => record.journey === journey);
+ if (main.length === 0) return 'current-user-main-proxy-not-observed';
+ if (main.length !== 1) return 'current-user-main-proxy-duplicate';
+ const observed = main[0];
+ if (!observed.scopeGenerationQueryValid || observed.scopeGenerationQueryCount !== 1
+ || observed.rendererScopeGeneration !== generation) {
+ return 'current-user-main-generation-correlation-invalid';
+ }
+ if (observed.scopeHeaderCount !== 1) {
+ if (observed.scopeHeaderCount === 0 && !observed.accepted
+ && observed.rejectionCategory === 'scope-missing') return 'current-user-main-scope-missing';
+ if (observed.scopeHeaderCount === 2 && !observed.accepted
+ && observed.rejectionCategory === 'scope-duplicate') return 'current-user-main-scope-duplicate';
+ return 'current-user-main-scope-header-count-invalid';
+ }
+ if (!observed.activeBindingPresent) {
+ return !observed.accepted && observed.rejectionCategory === 'no-active-binding'
+ ? 'current-user-main-no-active-binding'
+ : 'current-user-main-active-binding-missing';
+ }
+ if (!Number.isSafeInteger(observed.activeScopeGeneration) || observed.activeScopeGeneration < 0) {
+ return 'current-user-main-active-generation-invalid';
+ }
+ if (!observed.accepted) return `current-user-main-${observed.rejectionCategory}`;
+ if (observed.rejectionCategory !== 'none') return 'current-user-main-rejection-category-invalid';
+ if (!observed.profileGenerationCurrent || !observed.scopeEqualsActive || !observed.originEqualsActive) {
+ return 'current-user-main-active-scope-rejected';
+ }
+ if (observed.rendererBearerPresent || observed.rendererCookiePresent
+ || !observed.outboundBearerPresent || !observed.bearerMainInjected) {
+ return 'current-user-main-bearer-custody-invalid';
+ }
+ // Select the journey's exact renderer-originated upstream observation before
+ // asserting bearer custody so a malformed request cannot look unobserved.
+ const fixture = fixtureRecords.filter(record => record.journey === journey
+ && record.source === 'renderer');
+ if (fixture.length === 0) return 'current-user-upstream-request-not-arrived';
+ if (fixture.length !== 1) return 'current-user-upstream-request-duplicate';
+ if (fixture[0].scopeGeneration !== generation) return 'current-user-upstream-generation-correlation-invalid';
+ if (!fixture[0].authorizationPresent || !fixture[0].authorizationMatchesActivatedBearer
+ || fixture[0].cookiePresent) return 'current-user-upstream-bearer-custody-invalid';
+ if (fixture[0].responseStatus !== 200) return `current-user-response-http-${fixture[0].responseStatus}`;
+ const completed = renderer.filter(record => record.scopeGeneration === generation
+ && record.phase === 'response-completed');
+ if (completed.length === 0) return 'current-user-renderer-response-not-completed';
+ if (completed.length !== 1 || completed[0].responseStatus !== 200
+ || completed[0].classification !== 'success') return 'current-user-renderer-response-rejected';
+ const parsed = renderer.filter(record => record.scopeGeneration === generation
+ && (record.phase === 'parsed-user-accepted' || record.phase === 'parsed-user-rejected'));
+ if (parsed.length === 0) return 'current-user-parsed-schema-not-observed';
+ if (parsed.length !== 1 || parsed[0].phase !== 'parsed-user-accepted' || !parsed[0].schemaAccepted) {
+ return 'current-user-parsed-schema-rejected';
+ }
+ const accepted = renderer.filter(record => record.scopeGeneration === generation
+ && record.phase === 'active-scope-accepted');
+ if (accepted.length !== 1) return accepted.length === 0
+ ? 'current-user-active-scope-not-accepted'
+ : 'current-user-active-scope-accepted-duplicate';
+ return 'none';
+};
diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs
new file mode 100644
index 000000000..76b545962
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs
@@ -0,0 +1,299 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ classifyCurrentUserRequestShape,
+ currentUserValidationPhaseSummary,
+ currentUserValidationFailureCategory,
+ isExpectedScopedCurrentUserRequest,
+ networkPermissionDecisionSummary,
+ scopedCurrentUserRequestGeneration,
+} from './packaged-acceptance-current-user.mjs';
+
+const journey = 'dashboard-profile-manager';
+const rendererRecords = [
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' },
+ {
+ journey, activeScopePresent: true, scopeGeneration: 1, phase: 'response-completed',
+ responseStatus: 200, classification: 'success',
+ },
+ {
+ journey, activeScopePresent: true, scopeGeneration: 1, phase: 'parsed-user-accepted', schemaAccepted: true,
+ },
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-accepted' },
+];
+const acceptedMain = {
+ journey,
+ rendererScopeGeneration: 1,
+ scopeGenerationQueryCount: 1,
+ scopeGenerationQueryValid: true,
+ scopeHeaderCount: 1,
+ activeBindingPresent: true,
+ activeScopeGeneration: 0,
+ profileGenerationCurrent: true,
+ scopeEqualsActive: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+};
+const fixtureRecords = [{
+ journey,
+ source: 'renderer',
+ scopeGeneration: 1,
+ requestArrived: true,
+ authorizationPresent: true,
+ authorizationMatchesActivatedBearer: true,
+ cookiePresent: false,
+ responseStatus: 200,
+}];
+const classify = mainRecord => currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [mainRecord],
+ fixtureRecords,
+});
+
+describe('packaged scoped current-user request URL', () => {
+ it('accepts one canonical bounded desktop generation parameter', () => {
+ assert.equal(scopedCurrentUserRequestGeneration(
+ 'GET', '/api/auth/user?proprDesktopScopeGeneration=0',
+ ), 0);
+ assert.equal(scopedCurrentUserRequestGeneration(
+ 'GET', `/api/auth/user?proprDesktopScopeGeneration=${Number.MAX_SAFE_INTEGER}`,
+ ), Number.MAX_SAFE_INTEGER);
+ });
+
+ it('rejects hosted, non-GET, duplicate, unrelated, and unbounded query forms', () => {
+ for (const [method, url] of [
+ ['GET', '/api/auth/user'],
+ ['POST', '/api/auth/user?proprDesktopScopeGeneration=1'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&proprDesktopScopeGeneration=2'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&credential=secret'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=01'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=9007199254740992'],
+ ['GET', '/api/smoke/rest?proprDesktopScopeGeneration=1'],
+ ]) {
+ assert.equal(scopedCurrentUserRequestGeneration(method, url), null, `${method} ${url}`);
+ }
+ });
+
+ it('matches only the canonical bounded generation expected by an ordinary packaged launch', () => {
+ assert.equal(isExpectedScopedCurrentUserRequest(
+ 'GET', '/api/auth/user?proprDesktopScopeGeneration=1', 1,
+ ), true);
+ for (const [url, expectedGeneration] of [
+ ['/api/auth/user?proprDesktopScopeGeneration=0', 1],
+ ['/api/auth/user?proprDesktopScopeGeneration=2', 1],
+ ['/api/auth/user?proprDesktopScopeGeneration=1&extra=1', 1],
+ ['/api/auth/user?proprDesktopScopeGeneration=1', -1],
+ ]) {
+ assert.equal(isExpectedScopedCurrentUserRequest('GET', url, expectedGeneration), false);
+ }
+ });
+});
+
+describe('packaged current-user fixture request shapes', () => {
+ it('distinguishes the exact main probe from renderer-scoped validation', () => {
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user', undefined,
+ ), { source: 'main', scopeGeneration: null });
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user', 'https://non-renderer.example.test',
+ ), { source: 'main', scopeGeneration: null });
+ assert.deepEqual(classifyCurrentUserRequestShape(
+ 'GET', '/api/auth/user?proprDesktopScopeGeneration=7', 'propr-app://renderer',
+ ), { source: 'renderer', scopeGeneration: 7 });
+ });
+
+ it('rejects cross-assigned, duplicate, extra, and noncanonical encoded forms', () => {
+ for (const [method, url, origin] of [
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7', undefined],
+ ['GET', '/api/auth/user', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&proprDesktopScopeGeneration=8', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&extra=1', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?%70roprDesktopScopeGeneration=7', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=%37', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?proprDesktopScopeGeneration=07', 'propr-app://renderer'],
+ ['GET', '/api/auth/user?', undefined],
+ ['POST', '/api/auth/user', undefined],
+ ]) {
+ assert.equal(classifyCurrentUserRequestShape(method, url, origin), null, `${method} ${url}`);
+ }
+ });
+});
+
+describe('packaged current-user acceptance correlation', () => {
+ it('diagnoses a correlated zero-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, scopeHeaderCount: 0, accepted: false, rejectionCategory: 'scope-missing',
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-scope-missing');
+ });
+
+ it('diagnoses a correlated duplicate-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, scopeHeaderCount: 2, accepted: false, rejectionCategory: 'scope-duplicate',
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-scope-duplicate');
+ });
+
+ it('diagnoses a correlated no-binding record', () => {
+ assert.equal(classify({
+ ...acceptedMain, activeBindingPresent: false, accepted: false, rejectionCategory: 'no-active-binding',
+ profileGenerationCurrent: false, scopeEqualsActive: false, originEqualsActive: false,
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-no-active-binding');
+ });
+
+ it('diagnoses a correlated stale-scope record', () => {
+ assert.equal(classify({
+ ...acceptedMain, accepted: false, rejectionCategory: 'stale-scope', scopeEqualsActive: false,
+ outboundBearerPresent: false, bearerMainInjected: false,
+ }), 'current-user-main-stale-scope');
+ });
+
+ it('accepts the current scope with main-only bearer custody', () => {
+ assert.equal(classify(acceptedMain), 'none');
+ });
+
+ it('rejects a main or upstream record from another renderer generation', () => {
+ assert.equal(classify({
+ ...acceptedMain, rendererScopeGeneration: 2,
+ }), 'current-user-main-generation-correlation-invalid');
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [acceptedMain],
+ fixtureRecords: [{ ...fixtureRecords[0], scopeGeneration: 2 }],
+ }), 'current-user-upstream-generation-correlation-invalid');
+ });
+
+ it('correlates main evidence by journey and upstream evidence by renderer request origin', () => {
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [
+ { ...acceptedMain, journey: 'another-journey', accepted: false, rejectionCategory: 'stale-scope' },
+ acceptedMain,
+ ],
+ fixtureRecords: [
+ { ...fixtureRecords[0], source: 'main', authorizationMatchesActivatedBearer: false },
+ fixtureRecords[0],
+ ],
+ }), 'none');
+ });
+
+ it('diagnoses upstream bearer custody after correlating the fixture record', () => {
+ assert.equal(currentUserValidationFailureCategory({
+ journey,
+ evidenceInvalid: false,
+ rendererRecords,
+ mainRecords: [acceptedMain],
+ fixtureRecords: [{ ...fixtureRecords[0], authorizationMatchesActivatedBearer: false }],
+ }), 'current-user-upstream-bearer-custody-invalid');
+ });
+});
+
+describe('packaged current-user strict failure diagnostics', () => {
+ it('reports fixed bounded Local Network Access decision counts', () => {
+ const base = {
+ journey,
+ permissionCategory: 'loopback-network',
+ decision: 'check',
+ allowed: true,
+ activeBindingCurrent: true,
+ webContentsPresent: false,
+ webContentsEqualsMainWindow: false,
+ mainWindowPresent: true,
+ isMainFrame: true,
+ requestingUrlPresent: false,
+ requestingUrlTrusted: false,
+ rendererDocumentUrlTrusted: true,
+ requestingOriginAuthorityValid: true,
+ requestingOriginAuthorityEqual: true,
+ };
+ const summary = networkPermissionDecisionSummary({
+ journey,
+ records: [
+ base,
+ {
+ ...base,
+ permissionCategory: 'local-network-access',
+ decision: 'request',
+ allowed: false,
+ webContentsPresent: true,
+ webContentsEqualsMainWindow: true,
+ requestingUrlPresent: true,
+ requestingUrlTrusted: true,
+ },
+ ...Array.from({ length: 12 }, () => ({ ...base, journey: 'another-journey' })),
+ ],
+ invalidCount: 12,
+ });
+
+ assert.deepEqual(summary, {
+ schemaVersion: 1,
+ records: 2,
+ check: 1,
+ request: 1,
+ allowed: 1,
+ denied: 1,
+ localNetworkAccess: 1,
+ localNetwork: 0,
+ loopbackNetwork: 1,
+ activeBindingCurrent: 2,
+ webContentsPresent: 1,
+ webContentsEqualsMainWindow: 1,
+ mainWindowPresent: 2,
+ mainFrame: 2,
+ requestingUrlPresent: 1,
+ requestingUrlTrusted: 1,
+ rendererDocumentUrlTrusted: 2,
+ requestingOriginAuthorityValid: 2,
+ requestingOriginAuthorityEqual: 2,
+ invalid: 9,
+ });
+ assert.doesNotMatch(JSON.stringify(summary), /example\.test|propr_it_|Bearer|renderer\.html/);
+ });
+
+ it('reports fixed bounded phase counts without retaining request secrets', () => {
+ const summary = currentUserValidationPhaseSummary({
+ journey,
+ rendererRecords: [
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' },
+ { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-rejected' },
+ ],
+ mainRecords: [],
+ fixtureRecords: [],
+ requestRecords: Array.from({ length: 12 }, () => ({
+ journey,
+ method: 'OPTIONS',
+ origin: 'propr-app://renderer',
+ url: '/api/auth/user?proprDesktopScopeGeneration=1',
+ authorization: 'Bearer must-not-appear',
+ })),
+ });
+
+ assert.deepEqual(summary, {
+ schemaVersion: 1,
+ options: 9,
+ get: 0,
+ main: 0,
+ mainAccepted: 0,
+ fixture: 0,
+ fixture200: 0,
+ requestIssued: 1,
+ responseCompleted: 0,
+ parsedUserAccepted: 0,
+ activeScopeAccepted: 0,
+ rejected: 1,
+ });
+ assert.equal(JSON.stringify(summary).includes('must-not-appear'), false);
+ });
+});
diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.mjs
new file mode 100644
index 000000000..841212faa
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-renderer.mjs
@@ -0,0 +1,341 @@
+const defaultSleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
+
+const closeEnough = (actual, expected) => Number.isFinite(actual) && Math.abs(actual - expected) < 0.01;
+const ELECTRON_ZOOM_MECHANISM = 'electron-web-frame';
+const MAX_VIEWPORT_INSET_CSS_PIXELS = 64;
+
+const diagnosticSuffix = diagnostics => `; measurements=${JSON.stringify(diagnostics)}`;
+
+const assertRendererMetrics = (actual, expected, description, diagnostics) => {
+ if (!closeEnough(actual, expected)) {
+ throw new Error(`Packaged Electron ${description} changed: expected ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`);
+ }
+};
+
+const assertExactRendererMetric = (actual, expected, description, diagnostics) => {
+ if (actual !== expected) {
+ throw new Error(`Packaged Electron ${description} changed: expected exactly ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`);
+ }
+};
+
+const assertExactKeys = (value, keys, description) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)
+ || Object.keys(value).sort().join('\n') !== [...keys].sort().join('\n')) {
+ throw new Error(`Packaged Electron ${description} acknowledgement is malformed`);
+ }
+};
+
+const measureRenderer = async (page, cdp) => {
+ const [layout, renderer] = await Promise.all([
+ cdp.send('Page.getLayoutMetrics'),
+ page.evaluate(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ documentClientViewport: {
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight,
+ },
+ devicePixelRatio: window.devicePixelRatio,
+ reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ visualViewport: window.visualViewport && {
+ width: window.visualViewport.width,
+ height: window.visualViewport.height,
+ scale: window.visualViewport.scale,
+ },
+ })),
+ ]);
+ return { layout, renderer };
+};
+
+const waitForStableRendererMeasurement = async (
+ page,
+ cdp,
+ { settle, settleMs, measurementTimeoutMs, stableMeasurements },
+) => {
+ const deadline = Date.now() + measurementTimeoutMs;
+ let previous;
+ let stable = 0;
+ let measurement;
+ do {
+ measurement = await measureRenderer(page, cdp);
+ const serialized = JSON.stringify(measurement);
+ stable = serialized === previous ? stable + 1 : 1;
+ previous = serialized;
+ if (stable >= stableMeasurements) return measurement;
+ await settle(settleMs);
+ } while (Date.now() <= deadline);
+ throw new Error(`Packaged Electron renderer zoom metrics did not stabilize within ${measurementTimeoutMs}ms`);
+};
+
+/**
+ * Configure the visible Electron renderer without relying on Browser-domain
+ * commands on a target-scoped CDP session. Playwright owns the viewport/window
+ * resize and target DPR. The authenticated context-isolated preload bridge is
+ * the sole zoom authority; CDP page scale is intentionally not used.
+ */
+export const configureElectronRendererVariant = async (
+ page,
+ cdp,
+ config,
+ {
+ colorScheme = 'light',
+ settle = defaultSleep,
+ settleMs = 25,
+ measurementTimeoutMs = 3_000,
+ stableMeasurements = 3,
+ } = {},
+) => {
+ const { viewport, deviceScaleFactor, zoom, reducedMotion } = config || {};
+ if (!Number.isInteger(viewport?.width) || viewport.width <= 0
+ || !Number.isInteger(viewport?.height) || viewport.height <= 0
+ || !Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0
+ || (zoom !== 1 && zoom !== 2) || typeof reducedMotion !== 'boolean'
+ || !Number.isInteger(measurementTimeoutMs) || measurementTimeoutMs <= 0
+ || !Number.isInteger(stableMeasurements) || stableMeasurements < 2) {
+ throw new Error('Packaged Electron renderer variant configuration is invalid');
+ }
+
+ await page.emulateMedia({
+ reducedMotion: reducedMotion ? 'reduce' : 'no-preference',
+ colorScheme,
+ });
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
+ width: viewport.width,
+ height: viewport.height,
+ deviceScaleFactor,
+ mobile: false,
+ screenWidth: viewport.width,
+ screenHeight: viewport.height,
+ });
+ const zoomAcknowledgement = await page.evaluate(async requestedFactor => {
+ const bridge = globalThis.__PROPR_PACKAGED_ACCEPTANCE__;
+ if (!bridge || typeof bridge !== 'object' || typeof bridge.setZoomFactor !== 'function') {
+ throw new Error('Packaged acceptance Electron zoom bridge is unavailable');
+ }
+ return bridge.setZoomFactor(requestedFactor);
+ }, zoom);
+ assertExactKeys(
+ zoomAcknowledgement,
+ ['requestedFactor', 'resetFactor', 'appliedFactor', 'mechanism'],
+ 'zoom bridge',
+ );
+ if (zoomAcknowledgement.requestedFactor !== zoom || zoomAcknowledgement.resetFactor !== 1
+ || zoomAcknowledgement.appliedFactor !== zoom || zoomAcknowledgement.mechanism !== ELECTRON_ZOOM_MECHANISM) {
+ throw new Error(`Packaged Electron zoom bridge acknowledgement changed: ${JSON.stringify(zoomAcknowledgement)}`);
+ }
+
+ const playwrightViewport = page.viewportSize();
+ const { layout, renderer } = await waitForStableRendererMeasurement(page, cdp, {
+ settle, settleMs, measurementTimeoutMs, stableMeasurements,
+ });
+ const effectiveVisibleCssSpan = {
+ width: renderer?.width,
+ height: renderer?.height,
+ };
+ const geometryZoom = {
+ width: playwrightViewport?.width / effectiveVisibleCssSpan.width,
+ height: playwrightViewport?.height / effectiveVisibleCssSpan.height,
+ };
+ const expectedEffective = {
+ width: viewport.width / zoom,
+ height: viewport.height / zoom,
+ };
+ const layoutViewport = {
+ width: layout?.cssLayoutViewport?.clientWidth,
+ height: layout?.cssLayoutViewport?.clientHeight,
+ };
+ const cdpVisualViewport = {
+ width: layout?.cssVisualViewport?.clientWidth,
+ height: layout?.cssVisualViewport?.clientHeight,
+ scale: layout?.cssVisualViewport?.scale,
+ };
+ const rendererVisualViewport = {
+ width: renderer?.visualViewport?.width,
+ height: renderer?.visualViewport?.height,
+ scale: renderer?.visualViewport?.scale,
+ };
+ const documentClientViewport = {
+ width: renderer?.documentClientViewport?.width,
+ height: renderer?.documentClientViewport?.height,
+ };
+ const scrollbarInsets = {
+ width: renderer?.width - documentClientViewport.width,
+ height: renderer?.height - documentClientViewport.height,
+ };
+ const visualViewportInsets = {
+ width: renderer?.width - rendererVisualViewport.width,
+ height: renderer?.height - rendererVisualViewport.height,
+ };
+ // Keep failure output limited to explicitly selected numeric geometry. This
+ // makes a native CDP mismatch actionable without serializing page content.
+ const measurementDiagnostics = {
+ requestedViewport: viewport,
+ playwrightViewport,
+ rendererViewport: effectiveVisibleCssSpan,
+ documentClientViewport,
+ layoutViewport,
+ cdpVisualViewport,
+ rendererVisualViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ geometryZoom,
+ };
+
+ assertExactRendererMetric(playwrightViewport?.width, viewport.width, 'Playwright viewport width', measurementDiagnostics);
+ assertExactRendererMetric(playwrightViewport?.height, viewport.height, 'Playwright viewport height', measurementDiagnostics);
+ assertExactRendererMetric(renderer?.width, expectedEffective.width, 'renderer viewport width', measurementDiagnostics);
+ assertExactRendererMetric(renderer?.height, expectedEffective.height, 'renderer viewport height', measurementDiagnostics);
+ assertRendererMetrics(renderer?.devicePixelRatio, deviceScaleFactor * zoom, 'renderer device pixel ratio', measurementDiagnostics);
+ assertRendererMetrics(geometryZoom.width, zoom, 'measured renderer geometry width ratio', measurementDiagnostics);
+ assertRendererMetrics(geometryZoom.height, zoom, 'measured renderer geometry height ratio', measurementDiagnostics);
+ for (const [kind, insets] of Object.entries({ scrollbar: scrollbarInsets, 'visual viewport': visualViewportInsets })) {
+ for (const [axis, inset] of Object.entries(insets)) {
+ if (!Number.isFinite(inset) || inset < 0 || inset > MAX_VIEWPORT_INSET_CSS_PIXELS) {
+ throw new Error(`Packaged Electron ${axis} ${kind} inset is invalid: expected 0-${MAX_VIEWPORT_INSET_CSS_PIXELS}, received ${inset}${diagnosticSuffix(measurementDiagnostics)}`);
+ }
+ }
+ }
+ assertExactRendererMetric(layoutViewport.width, documentClientViewport.width, 'layout viewport width versus document client width', measurementDiagnostics);
+ assertExactRendererMetric(layoutViewport.height, documentClientViewport.height, 'layout viewport height versus document client height', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.width, cdpVisualViewport.width, 'renderer visual viewport width versus raw CDP visual width', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.height, cdpVisualViewport.height, 'renderer visual viewport height versus raw CDP visual height', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.scale, cdpVisualViewport.scale, 'renderer visual viewport scale versus raw CDP visual scale', measurementDiagnostics);
+ assertExactRendererMetric(rendererVisualViewport.scale, 1, 'raw renderer page scale', measurementDiagnostics);
+ if (renderer?.reducedMotion !== reducedMotion) {
+ throw new Error(`Packaged Electron reduced-motion emulation changed: expected ${reducedMotion}, received ${renderer?.reducedMotion}${diagnosticSuffix(measurementDiagnostics)}`);
+ }
+
+ return {
+ requestedViewport: { ...viewport },
+ playwrightViewport: { ...playwrightViewport },
+ rendererViewport: { width: renderer.width, height: renderer.height },
+ documentClientViewport,
+ scrollbarInsets,
+ visualViewportInsets,
+ layoutViewport,
+ cdpVisualViewport,
+ rendererVisualViewport,
+ effectiveVisibleCssSpan,
+ geometryZoom,
+ requestedDeviceScaleFactor: deviceScaleFactor,
+ rendererDevicePixelRatio: renderer.devicePixelRatio,
+ requestedZoomFactor: zoom,
+ appliedZoomFactor: zoomAcknowledgement.appliedFactor,
+ zoomResetFactor: zoomAcknowledgement.resetFactor,
+ zoomMechanism: zoomAcknowledgement.mechanism,
+ reducedMotion: renderer.reducedMotion,
+ };
+};
+
+export const forEachElectronRendererVariant = async (
+ page,
+ cdp,
+ variants,
+ capture,
+ options,
+) => {
+ if (!variants || typeof variants !== 'object' || typeof capture !== 'function') {
+ throw new Error('Packaged Electron renderer variant capture configuration is invalid');
+ }
+ for (const [variant, config] of Object.entries(variants)) {
+ const metrics = await configureElectronRendererVariant(page, cdp, config, options);
+ await capture({ variant, config, metrics });
+ }
+};
+
+export const captureElectronRendererScreenshot = async cdp => {
+ const result = await cdp.send('Page.captureScreenshot', {
+ format: 'png',
+ fromSurface: true,
+ captureBeyondViewport: false,
+ });
+ if (typeof result?.data !== 'string' || result.data.length === 0) {
+ throw new Error('Packaged Electron renderer screenshot capture returned invalid data');
+ }
+ return Buffer.from(result.data, 'base64');
+};
+
+const processExitError = (code, signal) => new Error(
+ `Packaged Electron exited before its renderer target was usable (${code ?? signal ?? 'unknown'})`,
+);
+
+/**
+ * CDP can become reachable before Electron publishes its renderer target.
+ * Keep this wait bounded independently from later renderer initialization and
+ * reject immediately if either side of the CDP connection goes away.
+ */
+export const waitForUsableElectronRenderer = async (
+ browser,
+ child,
+ {
+ expectedUrlPrefix,
+ timeoutMs = 15_000,
+ pollIntervalMs = 25,
+ sleep = defaultSleep,
+ } = {},
+) => {
+ if (!expectedUrlPrefix || timeoutMs < 0 || pollIntervalMs < 0) {
+ throw new Error('Packaged Electron renderer wait configuration is invalid');
+ }
+
+ let resolveTermination;
+ const termination = new Promise(resolve => { resolveTermination = resolve; });
+ let terminated = false;
+ let terminationError;
+ const terminate = error => {
+ if (terminated) return;
+ terminated = true;
+ terminationError = error;
+ resolveTermination(error);
+ };
+ const onClose = (code, signal) => terminate(processExitError(code, signal));
+ const onError = error => terminate(new Error('Packaged Electron failed before its renderer target was usable', { cause: error }));
+ const onDisconnected = () => terminate(new Error('Packaged Electron CDP disconnected before its renderer target was usable'));
+
+ child.once('close', onClose);
+ child.once('error', onError);
+ browser.once('disconnected', onDisconnected);
+ const timeout = setTimeout(
+ () => terminate(new Error(`Packaged Electron renderer target did not become usable within ${timeoutMs}ms`)),
+ timeoutMs,
+ );
+
+ if (child.exitCode !== null || child.signalCode !== null) onClose(child.exitCode, child.signalCode);
+ else if (!browser.isConnected()) onDisconnected();
+
+ const bounded = async promise => {
+ if (terminationError) throw terminationError;
+ const result = await Promise.race([
+ Promise.resolve(promise).then(
+ value => ({ value }),
+ error => ({ probeError: error }),
+ ),
+ termination.then(error => ({ terminationError: error })),
+ ]);
+ if (terminationError) throw terminationError;
+ if (result.terminationError) throw result.terminationError;
+ return result;
+ };
+
+ try {
+ while (true) {
+ for (const context of browser.contexts()) {
+ for (const page of context.pages()) {
+ const pageUrl = page.url();
+ if (page.isClosed() || (pageUrl !== expectedUrlPrefix && !pageUrl.startsWith(`${expectedUrlPrefix}/`))) continue;
+ const probe = await bounded(page.evaluate(() => ({ rendererTargetUsable: true })));
+ if (probe.probeError || probe.value?.rendererTargetUsable !== true || page.isClosed()) continue;
+ return { context, page };
+ }
+ }
+ await bounded(sleep(pollIntervalMs));
+ }
+ } finally {
+ clearTimeout(timeout);
+ child.off('close', onClose);
+ child.off('error', onError);
+ browser.off('disconnected', onDisconnected);
+ }
+};
+
diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
new file mode 100644
index 000000000..9ccc6dbfe
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs
@@ -0,0 +1,520 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { existsSync } from 'node:fs';
+import { after, before, describe, it } from 'node:test';
+import { chromium } from 'playwright';
+import { ACCEPTANCE_VARIANTS } from './acceptance-artifacts.mjs';
+import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs';
+import {
+ captureElectronRendererScreenshot,
+ configureElectronRendererVariant,
+ forEachElectronRendererVariant,
+ waitForUsableElectronRenderer,
+} from './packaged-acceptance-renderer.mjs';
+
+const processFixture = () => Object.assign(new EventEmitter(), { exitCode: null, signalCode: null });
+const browserFixture = context => Object.assign(new EventEmitter(), {
+ contexts: () => [context],
+ isConnected: () => true,
+});
+const REAL_CHROMIUM_BOUNDARY_ENVIRONMENT = 'PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY';
+const REAL_CHROMIUM_BOUNDARY_OPT_IN = '1';
+const realChromiumBoundaryEnabled = value => {
+ if (value === undefined) return false;
+ if (value !== REAL_CHROMIUM_BOUNDARY_OPT_IN) {
+ throw new Error(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly "${REAL_CHROMIUM_BOUNDARY_OPT_IN}"`);
+ }
+ return true;
+};
+const runRealChromiumBoundary = realChromiumBoundaryEnabled(
+ process.env[REAL_CHROMIUM_BOUNDARY_ENVIRONMENT],
+);
+const installedChromium = () => [
+ chromium.executablePath(),
+ '/usr/bin/chromium',
+ '/usr/bin/google-chrome',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
+].find(candidate => existsSync(candidate));
+
+const rendererVariantFixture = ({
+ acknowledgement,
+ appliedFactor,
+ cdpVisualWidthAdjustment = 0,
+ layoutWidthAdjustment = 0,
+ rendererVisualWidthAdjustment = 0,
+ rendererSpan = 'effective',
+ scrollbarInsets = { width: 0, height: 0 },
+ visualViewportInsets = { width: 0, height: 0 },
+ unstable = false,
+} = {}) => {
+ let viewport;
+ let metrics;
+ let reducedMotion = false;
+ let measurement = 0;
+ const commands = [];
+ const dimensionsFor = span => {
+ if (span === 'invalid') return { width: viewport.width - 17, height: viewport.height - 11 };
+ if (unstable) measurement += 1;
+ const adjustment = unstable ? measurement % 2 : 0;
+ const effective = { width: viewport.width / metrics.zoom + adjustment, height: viewport.height / metrics.zoom };
+ if (span === 'document') {
+ return { width: effective.width - scrollbarInsets.width, height: effective.height - scrollbarInsets.height };
+ }
+ if (span === 'visual') {
+ return { width: effective.width - visualViewportInsets.width, height: effective.height - visualViewportInsets.height };
+ }
+ return effective;
+ };
+ const page = {
+ emulateMedia: async media => { reducedMotion = media.reducedMotion === 'reduce'; },
+ setViewportSize: async value => { viewport = { ...value }; },
+ viewportSize: () => ({ ...viewport }),
+ evaluate: async (_fn, argument) => {
+ if (typeof argument === 'number') {
+ metrics.zoom = appliedFactor ?? argument;
+ return acknowledgement ?? {
+ requestedFactor: argument,
+ resetFactor: 1,
+ appliedFactor: metrics.zoom,
+ mechanism: 'electron-web-frame',
+ };
+ }
+ const dimensions = dimensionsFor(rendererSpan);
+ const visualDimensions = dimensionsFor('visual');
+ return {
+ ...dimensions,
+ documentClientViewport: dimensionsFor('document'),
+ devicePixelRatio: metrics.deviceScaleFactor * metrics.zoom + (unstable ? measurement / 1_000 : 0),
+ reducedMotion,
+ visualViewport: {
+ ...visualDimensions,
+ width: visualDimensions.width + rendererVisualWidthAdjustment,
+ scale: 1,
+ },
+ };
+ },
+ };
+ const cdp = {
+ send: async (method, params) => {
+ commands.push({ method, params });
+ if (method === 'Browser.getWindowForTarget') throw new Error('Browser.getWindowForTarget is unavailable');
+ if (method === 'Emulation.setDeviceMetricsOverride') {
+ metrics = { deviceScaleFactor: params.deviceScaleFactor, zoom: metrics?.zoom || 1 };
+ return {};
+ }
+ if (method === 'Page.getLayoutMetrics') {
+ const documentClientViewport = dimensionsFor('document');
+ const rawVisualViewport = dimensionsFor('visual');
+ return {
+ cssLayoutViewport: {
+ clientWidth: documentClientViewport.width + layoutWidthAdjustment,
+ clientHeight: documentClientViewport.height,
+ },
+ cssVisualViewport: {
+ clientWidth: rawVisualViewport.width + cdpVisualWidthAdjustment,
+ clientHeight: rawVisualViewport.height,
+ scale: 1,
+ },
+ };
+ }
+ if (method === 'Page.captureScreenshot') {
+ return { data: Buffer.from(`${viewport.width}x${viewport.height}@${metrics.deviceScaleFactor}/${metrics.zoom}/${reducedMotion}`).toString('base64') };
+ }
+ throw new Error(`Unexpected CDP command: ${method}`);
+ },
+ };
+ return { commands, cdp, page };
+};
+
+describe('packaged acceptance renderer discovery', () => {
+ it('waits deterministically when CDP precedes the first Electron renderer page', async () => {
+ const pages = [];
+ const context = { pages: () => pages };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ let polls = 0;
+ const page = {
+ evaluate: async () => ({ rendererTargetUsable: true }),
+ isClosed: () => false,
+ url: () => 'propr-app://renderer/index.html',
+ };
+
+ const renderer = await waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1_000,
+ sleep: async () => {
+ polls += 1;
+ pages.push(page);
+ },
+ });
+
+ assert.equal(polls, 1);
+ assert.deepEqual(renderer, { context, page });
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(child.listenerCount('error'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+
+ it('rejects process exit while waiting and removes its listeners', async () => {
+ const context = { pages: () => [] };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ await assert.rejects(waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1_000,
+ sleep: async () => child.emit('close', 17, null),
+ }), /exited.*17/);
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+
+ it('rejects a strictly bounded timeout when no renderer target appears', async () => {
+ const context = { pages: () => [] };
+ const child = processFixture();
+ const browser = browserFixture(context);
+ await assert.rejects(waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: 'propr-app://renderer',
+ timeoutMs: 1,
+ sleep: async () => new Promise(() => undefined),
+ }), /within 1ms/);
+ assert.equal(child.listenerCount('close'), 0);
+ assert.equal(browser.listenerCount('disconnected'), 0);
+ });
+});
+
+describe('packaged acceptance renderer variants', () => {
+ it('fails closed unless the real Chromium boundary has its exact explicit opt-in', () => {
+ assert.equal(realChromiumBoundaryEnabled(undefined), false);
+ assert.equal(realChromiumBoundaryEnabled(REAL_CHROMIUM_BOUNDARY_OPT_IN), true);
+ for (const malformed of ['', '0', 'true', ' 1', '1 ']) {
+ assert.throws(
+ () => realChromiumBoundaryEnabled(malformed),
+ new RegExp(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly`),
+ );
+ }
+ });
+
+ it('configures and captures all five variants when Browser.getWindowForTarget is unavailable', async () => {
+ const { commands, cdp, page } = rendererVariantFixture();
+ const captures = [];
+
+ await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics: actual }) => {
+ captures.push({ variant, bytes: await captureElectronRendererScreenshot(cdp), actual });
+ const effective = { width: config.viewport.width / config.zoom, height: config.viewport.height / config.zoom };
+ assert.deepEqual(actual.requestedViewport, config.viewport);
+ assert.deepEqual(actual.playwrightViewport, config.viewport);
+ assert.deepEqual(actual.rendererViewport, effective);
+ assert.deepEqual(actual.documentClientViewport, effective);
+ assert.deepEqual(actual.scrollbarInsets, { width: 0, height: 0 });
+ assert.deepEqual(actual.visualViewportInsets, { width: 0, height: 0 });
+ assert.deepEqual(actual.layoutViewport, effective);
+ assert.deepEqual(actual.cdpVisualViewport, {
+ width: config.viewport.width / config.zoom,
+ height: config.viewport.height / config.zoom,
+ scale: 1,
+ });
+ assert.deepEqual(actual.rendererVisualViewport, actual.cdpVisualViewport);
+ assert.deepEqual(actual.effectiveVisibleCssSpan, effective);
+ assert.deepEqual(actual.geometryZoom, { width: config.zoom, height: config.zoom });
+ assert.equal(actual.requestedDeviceScaleFactor, config.deviceScaleFactor);
+ assert.equal(actual.rendererDevicePixelRatio, config.deviceScaleFactor * config.zoom);
+ assert.equal(actual.requestedZoomFactor, config.zoom);
+ assert.equal(actual.appliedZoomFactor, config.zoom);
+ assert.equal(actual.zoomResetFactor, 1);
+ assert.equal(actual.zoomMechanism, 'electron-web-frame');
+ assert.equal(actual.reducedMotion, config.reducedMotion);
+ }, { settle: async () => undefined });
+
+ assert.deepEqual(captures.map(capture => capture.variant), Object.keys(ACCEPTANCE_VARIANTS));
+ assert.equal(captures.length, 5);
+ assert.equal(new Set(captures.map(capture => capture.bytes.toString())).size, 5);
+ assert.equal(commands.some(command => command.method.startsWith('Browser.')), false);
+ assert.equal(commands.filter(command => command.method === 'Emulation.setDeviceMetricsOverride').length, 5);
+ assert.equal(commands.filter(command => command.method === 'Emulation.setPageScaleFactor').length, 0);
+ assert.equal(commands.filter(command => command.method === 'Page.getLayoutMetrics').length, 15);
+ assert.equal(commands.filter(command => command.method === 'Page.captureScreenshot').length, 5);
+ });
+
+ it('proves packaged Electron zoom-200 from webFrame acknowledgement and independent renderer geometry', async () => {
+ const { commands, cdp, page } = rendererVariantFixture({
+ scrollbarInsets: { width: 8, height: 0 },
+ visualViewportInsets: { width: 7.5, height: 0 },
+ });
+ const config = ACCEPTANCE_VARIANTS['zoom-200'];
+
+ const actual = await configureElectronRendererVariant(page, cdp, config, { settle: async () => undefined });
+
+ assert.deepEqual(actual.requestedViewport, { width: 1280, height: 820 });
+ assert.deepEqual(actual.rendererViewport, { width: 640, height: 410 });
+ assert.deepEqual(actual.documentClientViewport, { width: 632, height: 410 });
+ assert.deepEqual(actual.scrollbarInsets, { width: 8, height: 0 });
+ assert.deepEqual(actual.visualViewportInsets, { width: 7.5, height: 0 });
+ assert.deepEqual(actual.layoutViewport, { width: 632, height: 410 });
+ assert.deepEqual(actual.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(actual.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 });
+ assert.deepEqual(actual.effectiveVisibleCssSpan, { width: 640, height: 410 });
+ assert.deepEqual(actual.geometryZoom, { width: 2, height: 2 });
+ assert.equal(actual.rendererDevicePixelRatio, 2);
+ assert.equal(actual.appliedZoomFactor, 2);
+ assert.equal(actual.zoomMechanism, 'electron-web-frame');
+ assert.equal(commands.some(command => command.method === 'Emulation.setPageScaleFactor'), false);
+ });
+
+ it('fails closed on a wrong or malformed Electron zoom acknowledgement', async () => {
+ const unavailable = rendererVariantFixture();
+ unavailable.page.evaluate = async () => { throw new Error('Packaged acceptance Electron zoom bridge is unavailable'); };
+ await assert.rejects(
+ configureElectronRendererVariant(unavailable.page, unavailable.cdp, ACCEPTANCE_VARIANTS.standard, { settle: async () => undefined }),
+ /zoom bridge is unavailable/,
+ );
+ const wrong = rendererVariantFixture({ appliedFactor: 1 });
+ await assert.rejects(
+ configureElectronRendererVariant(wrong.page, wrong.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /zoom bridge acknowledgement changed/,
+ );
+ const malformed = rendererVariantFixture({ acknowledgement: { appliedFactor: 2 } });
+ await assert.rejects(
+ configureElectronRendererVariant(malformed.page, malformed.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /acknowledgement is malformed/,
+ );
+ });
+
+ it('fails closed on renderer geometry mismatch and an unstable measurement timeout', async () => {
+ const mismatch = rendererVariantFixture({ rendererSpan: 'invalid' });
+ await assert.rejects(
+ configureElectronRendererVariant(mismatch.page, mismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /renderer viewport width changed/,
+ );
+ const unstable = rendererVariantFixture({ unstable: true });
+ await assert.rejects(
+ configureElectronRendererVariant(unstable.page, unstable.cdp, ACCEPTANCE_VARIANTS['zoom-200'], {
+ settle: async () => undefined, measurementTimeoutMs: 1,
+ }),
+ /did not stabilize within 1ms/,
+ );
+ });
+
+ it('fails closed on arbitrary layout spans, invalid insets, and exact renderer/CDP visual mismatches', async () => {
+ const arbitraryLayout = rendererVariantFixture({
+ layoutWidthAdjustment: 1,
+ scrollbarInsets: { width: 8, height: 0 },
+ });
+ await assert.rejects(
+ configureElectronRendererVariant(arbitraryLayout.page, arbitraryLayout.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /layout viewport width versus document client width changed: expected exactly 632, received 633/,
+ );
+
+ for (const width of [-1, 65]) {
+ const invalidScrollbar = rendererVariantFixture({ scrollbarInsets: { width, height: 0 } });
+ await assert.rejects(
+ configureElectronRendererVariant(invalidScrollbar.page, invalidScrollbar.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /width scrollbar inset is invalid/,
+ );
+ }
+
+ for (const width of [-1, 65, Number.NaN]) {
+ const invalidVisualInset = rendererVariantFixture({ visualViewportInsets: { width, height: 0 } });
+ await assert.rejects(
+ configureElectronRendererVariant(invalidVisualInset.page, invalidVisualInset.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /width visual viewport inset is invalid/,
+ );
+ }
+
+ const visualMismatch = rendererVariantFixture({
+ cdpVisualWidthAdjustment: -0.5,
+ scrollbarInsets: { width: 8, height: 0 },
+ visualViewportInsets: { width: 7.5, height: 0 },
+ });
+ await assert.rejects(
+ configureElectronRendererVariant(visualMismatch.page, visualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ error => /renderer visual viewport width versus raw CDP visual width changed: expected exactly 632, received 632.5/.test(error?.message)
+ && /"documentClientViewport":\{"width":632,"height":410\}/.test(error.message)
+ && /"cdpVisualViewport":\{"width":632,"height":410,"scale":1\}/.test(error.message)
+ && /"rendererVisualViewport":\{"width":632.5,"height":410,"scale":1\}/.test(error.message)
+ && !error.message.includes('propr-app://'),
+ );
+
+ const rendererVisualMismatch = rendererVariantFixture({ rendererVisualWidthAdjustment: -0.5 });
+ await assert.rejects(
+ configureElectronRendererVariant(rendererVisualMismatch.page, rendererVisualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }),
+ /renderer visual viewport width versus raw CDP visual width changed/,
+ );
+ });
+
+ describe('real Chromium existing-target axe boundary', {
+ skip: !runRealChromiumBoundary
+ ? `Real Chromium is owned by the required predecessor (${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT}=1)`
+ : process.platform !== 'linux' || process.arch !== 'x64'
+ ? 'Linux x64 packaged acceptance boundary'
+ : false,
+ }, () => {
+ let browser;
+ let cdp;
+ let context;
+ let createTargetAttempts = 0;
+ let evaluateRenderer;
+ let injectionMode = 'success';
+ let newPageAttempts = 0;
+ let page;
+ let session;
+
+ before(async () => {
+ const executablePath = installedChromium();
+ assert.ok(executablePath, 'A Chromium executable is required for the in-renderer axe boundary regression');
+ browser = await chromium.launch({ executablePath, headless: true });
+ context = await browser.newContext();
+ page = await context.newPage();
+ await page.setContent(`
+
Acceptance boundary
+ Acceptance boundary
+ `);
+
+ Object.defineProperty(context, 'newPage', {
+ configurable: true,
+ value: async () => {
+ newPageAttempts += 1;
+ throw new Error('Target.createTarget is unsupported');
+ },
+ });
+ session = await context.newCDPSession(page);
+ cdp = {
+ send: async (method, params) => {
+ if (method === 'Target.createTarget') {
+ createTargetAttempts += 1;
+ throw new Error('Target.createTarget is unsupported');
+ }
+ return session.send(method, params);
+ },
+ };
+
+ evaluateRenderer = page.evaluate.bind(page);
+ await evaluateRenderer(() => {
+ globalThis.__acceptanceAxeCleanup = { completed: 0, started: 0, callbackBased: 0 };
+ });
+ page.evaluate = async (pageFunction, argument) => {
+ const result = await evaluateRenderer(pageFunction, argument);
+ if (typeof pageFunction === 'string') {
+ await evaluateRenderer(mode => {
+ if (mode === 'timeout') {
+ globalThis.axe.cleanup = () => undefined;
+ return;
+ }
+ if (mode === 'combined-failure') {
+ globalThis.axe.run = async () => { throw new Error('specific axe execution failure'); };
+ globalThis.axe.cleanup = (_resolve, reject) => {
+ setTimeout(() => reject(new Error('asynchronous axe cleanup failure')), 25);
+ };
+ return;
+ }
+ const cleanupState = globalThis.__acceptanceAxeCleanup;
+ const cleanup = globalThis.axe.cleanup.bind(globalThis.axe);
+ globalThis.axe.cleanup = (resolve, reject) => {
+ cleanupState.started += 1;
+ if (typeof resolve === 'function' && typeof reject === 'function') {
+ cleanupState.callbackBased += 1;
+ }
+ cleanup(
+ value => setTimeout(() => {
+ cleanupState.completed += 1;
+ if (typeof resolve === 'function') resolve(value);
+ }, 25),
+ error => setTimeout(() => {
+ cleanupState.completed += 1;
+ if (typeof reject === 'function') reject(error);
+ }, 25),
+ );
+ };
+ }, injectionMode);
+ }
+ return result;
+ };
+ }, { timeout: 30_000 });
+
+ after(async () => {
+ try {
+ if (session) await session.detach();
+ } finally {
+ if (browser) await browser.close();
+ }
+ }, { timeout: 15_000 });
+
+ it('proves five-variant axe success and reinjection without claiming stock Chromium proves Electron zoom', {
+ timeout: 45_000,
+ }, async () => {
+ await assert.rejects(context.newPage(), /Target\.createTarget is unsupported/);
+ await assert.rejects(cdp.send('Target.createTarget', { url: 'about:blank' }), /Target\.createTarget is unsupported/);
+
+ const checks = [];
+ assert.equal(await page.evaluate(() => typeof globalThis.__PROPR_PACKAGED_ACCEPTANCE__), 'undefined');
+ for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) {
+ await page.setViewportSize(config.viewport);
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
+ width: config.viewport.width,
+ height: config.viewport.height,
+ deviceScaleFactor: config.deviceScaleFactor,
+ mobile: false,
+ screenWidth: config.viewport.width,
+ screenHeight: config.viewport.height,
+ });
+ const violations = await analyzeExistingElectronRenderer(page);
+ const cleanupState = await evaluateRenderer(() => ({ ...globalThis.__acceptanceAxeCleanup }));
+ checks.push({ variant, violations, cleanupState });
+ assert.equal(cleanupState.started, checks.length);
+ assert.equal(cleanupState.callbackBased, checks.length);
+ assert.equal(cleanupState.completed, checks.length);
+ assert.equal(await page.evaluate(() => typeof globalThis.axe), 'undefined');
+ }
+
+ assert.deepEqual(checks.map(check => check.variant), Object.keys(ACCEPTANCE_VARIANTS));
+ assert.equal(checks.length, 5);
+ for (const check of checks) {
+ assert.ok(check.violations.some(violation => violation.id === 'button-name'
+ && violation.impact === 'critical' && violation.nodes === 1));
+ }
+ assert.equal(newPageAttempts, 1);
+ assert.equal(createTargetAttempts, 1);
+ assert.equal(page.isClosed(), false);
+ });
+
+ it('fails closed at the production axe cleanup timeout', {
+ timeout: 10_000,
+ }, async () => {
+ injectionMode = 'timeout';
+ await assert.rejects(
+ analyzeExistingElectronRenderer(page),
+ error => error?.message === 'Packaged accessibility axe-core cleanup failed'
+ && /cleanup timed out after 5000ms/.test(error.cause?.message),
+ );
+ assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object');
+ await evaluateRenderer(() => Reflect.deleteProperty(globalThis, 'axe'));
+ assert.equal(page.isClosed(), false);
+ });
+
+ it('preserves the primary axe error alongside asynchronous cleanup failure', {
+ timeout: 10_000,
+ }, async () => {
+ injectionMode = 'combined-failure';
+ let combinedFailure;
+ try {
+ await analyzeExistingElectronRenderer(page);
+ } catch (error) {
+ combinedFailure = error;
+ }
+ assert.ok(combinedFailure instanceof AggregateError);
+ assert.equal(combinedFailure.message, 'Packaged accessibility axe-core execution or result validation failed');
+ assert.equal(combinedFailure.errors.length, 2);
+ assert.match(combinedFailure.errors[0].message, /execution or result validation failed/);
+ assert.match(combinedFailure.errors[1].message, /axe-core cleanup failed/);
+ assert.match(combinedFailure.errors[1].cause?.message, /asynchronous axe cleanup failure/);
+ assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object');
+ assert.equal(newPageAttempts, 1);
+ assert.equal(createTargetAttempts, 1);
+ assert.equal(page.isClosed(), false);
+ });
+ });
+});
+
diff --git a/apps/desktop/scripts/packaged-acceptance-socket.test.mjs b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs
new file mode 100644
index 000000000..5103d287b
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs
@@ -0,0 +1,109 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from 'node:test';
+
+const readSource = relativePath => readFileSync(
+ fileURLToPath(new URL(relativePath, import.meta.url)),
+ 'utf8',
+);
+
+const runnerSource = readSource('./run-packaged-acceptance.mjs');
+const socketProviderSource = readSource('../../../propr-ui/src/contexts/SocketProvider.tsx');
+
+const sourceBetween = (source, startMarker, endMarker) => {
+ const start = source.indexOf(startMarker);
+ assert.notEqual(start, -1, `Missing source marker: ${startMarker}`);
+ const end = source.indexOf(endMarker, start);
+ assert.notEqual(end, -1, `Missing source marker: ${endMarker}`);
+ return source.slice(start, end);
+};
+
+const occurrences = (source, value) => source.split(value).length - 1;
+
+describe('packaged acceptance Socket.IO application synchronization', () => {
+ it('uses the exact production subscription event and emits only from its one-shot handler', () => {
+ assert.match(socketProviderSource, /socket\.emit\('subscribe:queue:stats'\)/);
+ assert.match(runnerSource, /const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats';/);
+
+ const connectionCallback = sourceBetween(
+ runnerSource,
+ "io.on('connection', socket => {",
+ ' server.listen(',
+ );
+ const connectionRecord = connectionCallback.indexOf(
+ "socketRecords.push({ journey, mode, event: 'connection', authenticated });",
+ );
+ const subscriptionHandler = connectionCallback.indexOf(
+ 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => {',
+ );
+ const subscriptionReceived = connectionCallback.indexOf(
+ 'recordApplicationSubscription();',
+ subscriptionHandler,
+ );
+ const serverEmit = connectionCallback.indexOf(
+ 'socket.emit(QUEUE_STATS_UPDATE, {',
+ subscriptionReceived,
+ );
+ const fixtureEventRecord = connectionCallback.indexOf(
+ "direction: 'fixture-to-renderer',",
+ serverEmit,
+ );
+
+ assert.ok(connectionRecord < subscriptionHandler);
+ assert.ok(subscriptionHandler < subscriptionReceived);
+ assert.ok(subscriptionReceived < serverEmit);
+ assert.ok(serverEmit < fixtureEventRecord);
+ assert.equal(occurrences(connectionCallback, 'socket.emit(QUEUE_STATS_UPDATE, {'), 1);
+ assert.doesNotMatch(connectionCallback.slice(0, subscriptionHandler), /socket\.emit\(QUEUE_STATS_UPDATE/);
+ assert.doesNotMatch(connectionCallback, /setTimeout|sleep\(/);
+ assert.match(connectionCallback, /timestamp: FIXED_TIME/);
+ assert.match(connectionCallback, /completed: 12, failed: 0, delayed: 0, total: 12/);
+ });
+
+ it('records a bounded duplicate subscription without producing another fixture event', () => {
+ const connectionCallback = sourceBetween(
+ runnerSource,
+ "io.on('connection', socket => {",
+ ' server.listen(',
+ );
+
+ assert.equal(
+ occurrences(connectionCallback, 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT'),
+ 2,
+ );
+ assert.match(
+ connectionCallback,
+ /recordApplicationSubscription\(\);\s+socket\.once\(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription\);\s+socket\.emit\(QUEUE_STATS_UPDATE/,
+ );
+ assert.equal(occurrences(connectionCallback, 'genuineApplicationSubscription: true'), 1);
+ assert.equal(occurrences(connectionCallback, 'genuineApplicationEvent: true'), 1);
+ });
+
+ it('requires exact connection, subscription, emit, and renderer-observation counts', () => {
+ const waitSource = sourceBetween(
+ runnerSource,
+ 'const waitForAuthenticatedSocket = async journey => {',
+ '\nconst observedServiceSummary = () => {',
+ );
+ const summarySource = sourceBetween(
+ runnerSource,
+ 'const observedServiceSummary = () => {',
+ '\ntry {\n readyOrigin = await createFixture',
+ );
+
+ assert.match(waitSource, /connected\.length === 1 && applicationSubscriptions\.length === 1/);
+ assert.match(waitSource, /applicationEvents\.length === 1 && rendererObservedApplicationEvents\.length === 1/);
+ assert.match(waitSource, /record\.event === QUEUE_STATS_SUBSCRIBE_EVENT && record\.authenticated/);
+ assert.match(waitSource, /record\.event === QUEUE_STATS_UPDATE && record\.authenticated/);
+ assert.match(runnerSource, /if \(subscriptions\.length > 1\) return 'duplicate-application-subscription';/);
+ assert.match(runnerSource, /if \(applicationEvents\.length > 1\) return 'duplicate-application-event';/);
+ assert.match(runnerSource, /return 'application-subscription-not-observed';/);
+ assert.match(runnerSource, /return 'renderer-application-event-not-observed';/);
+ assert.match(summarySource, /socketSubscriptions\.length !== 1/);
+ assert.match(summarySource, /services\.socketIo\.events !== 1/);
+ assert.match(summarySource, /rendererObservedApplicationEvents\.length !== 1/);
+ assert.doesNotMatch(waitSource, /applicationEvents\.length >= 1/);
+ });
+});
+
diff --git a/apps/desktop/scripts/packaged-acceptance-stats.test.mjs b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs
new file mode 100644
index 000000000..c4f1d2eb0
--- /dev/null
+++ b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs
@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { runInNewContext } from 'node:vm';
+import { describe, it } from 'node:test';
+
+const readSource = relativePath => readFileSync(
+ fileURLToPath(new URL(relativePath, import.meta.url)),
+ 'utf8',
+);
+
+const runnerSource = readSource('./run-packaged-acceptance.mjs');
+const taskStatsApiSource = readSource('../../../propr-ui/src/api/taskStatsApi.ts');
+const dashboardSource = readSource('../../../propr-ui/src/components/Dashboard.tsx');
+const taskStatsChartSource = readSource('../../../propr-ui/src/components/TaskStatsChart.tsx');
+const topModelsSource = readSource('../../../propr-ui/src/components/TopModels.tsx');
+const repositoryBreakdownSource = readSource('../../../propr-ui/src/components/RepositoryBreakdown.tsx');
+
+const fixturePayload = pathname => {
+ const route = `if (requestUrl.pathname === '${pathname}')`;
+ const routeStart = runnerSource.indexOf(route);
+ assert.notEqual(routeStart, -1, `${pathname} exact route is missing`);
+ const responseStart = runnerSource.indexOf('return json(response, 200, ', routeStart);
+ assert.notEqual(responseStart, -1, `${pathname} response is missing`);
+ const valueStart = responseStart + 'return json(response, 200, '.length;
+ const valueEnd = runnerSource.indexOf(');', valueStart);
+ assert.notEqual(valueEnd, -1, `${pathname} response terminator is missing`);
+ const value = runInNewContext(`(${runnerSource.slice(valueStart, valueEnd)})`);
+ return JSON.parse(JSON.stringify(value));
+};
+
+const interfaceFields = name => {
+ const match = taskStatsApiSource.match(new RegExp(`export interface ${name} \\{([\\s\\S]*?)\\n\\}`));
+ assert.ok(match, `${name} interface is missing`);
+ return [...match[1].matchAll(/^\s{2}([A-Za-z_][A-Za-z0-9_]*):/gm)].map(field => field[1]);
+};
+
+describe('packaged acceptance stats fixtures', () => {
+ it('dispatches exact stats pathnames before the unchanged generic fallback', () => {
+ const generatingPlans = runnerSource.indexOf("requestUrl.pathname === '/api/stats/generating-plans'");
+ const tasks = runnerSource.indexOf("requestUrl.pathname === '/api/stats/tasks'");
+ const overview = runnerSource.indexOf("requestUrl.pathname === '/api/stats/overview'");
+ const repositories = runnerSource.indexOf("requestUrl.pathname === '/api/stats/repositories'");
+ const genericFallback = runnerSource.indexOf("request.url?.startsWith('/api/stats/')");
+
+ assert.ok(generatingPlans < tasks && tasks < overview && overview < repositories);
+ assert.ok(repositories < genericFallback);
+ assert.deepEqual(fixturePayload('/api/stats/generating-plans'), { count: 0 });
+ });
+
+ it('matches TaskStatsResponse and the fields synchronously mapped by Dashboard and TaskStatsChart', () => {
+ const payload = fixturePayload('/api/stats/tasks');
+
+ assert.deepEqual(Object.keys(payload), interfaceFields('TaskStatsResponse'));
+ assert.deepEqual(payload, {
+ dailyCounts: [],
+ statusDistribution: [
+ { status: 'completed', count: 12 },
+ { status: 'failed', count: 0 },
+ ],
+ avgProcessingTime: [],
+ summary: { total: 12, completed: 12, failed: 0 },
+ });
+ assert.match(dashboardSource, /taskStats\?\.dailyCounts\?\.map/);
+ assert.match(dashboardSource, /const \{ completed, total \} = taskStats\.summary/);
+ assert.match(dashboardSource, /taskStats\?\.summary\?\.total\?\.toLocaleString/);
+ assert.match(dashboardSource, /taskStats\?\.summary\?\.failed/);
+ for (const field of ['dailyCounts', 'statusDistribution', 'avgProcessingTime']) {
+ assert.match(taskStatsChartSource, new RegExp(`stats\\.${field}\\.map`));
+ assert.ok(Array.isArray(payload[field]), `${field} must be synchronously mappable`);
+ }
+ assert.equal(payload.summary.total, 12);
+ assert.equal(payload.summary.completed, 12);
+ assert.equal(payload.summary.failed, 0);
+ });
+
+ it('matches StatsOverviewResponse and RepositoryStatsResponse consumer contracts', () => {
+ const overview = fixturePayload('/api/stats/overview');
+ const repositories = fixturePayload('/api/stats/repositories');
+
+ assert.deepEqual(Object.keys(overview), interfaceFields('StatsOverviewResponse'));
+ assert.deepEqual(Object.keys(overview.tasks), interfaceFields('StatsOverviewTasks'));
+ assert.deepEqual(Object.keys(overview.usage), interfaceFields('StatsOverviewUsage'));
+ assert.deepEqual(Object.keys(overview.system), interfaceFields('StatsOverviewSystem'));
+ assert.deepEqual(overview, {
+ tasks: {
+ completed: 12,
+ planned: 0,
+ pr_iterations_avg: 0,
+ merged_prs: 0,
+ total_followups: 0,
+ },
+ usage: { total_tokens: 0, total_cost_usd: 0, models: {} },
+ system: { repos_indexed: 0 },
+ });
+ assert.deepEqual(Object.keys(repositories), interfaceFields('RepositoryStatsResponse'));
+ assert.deepEqual(repositories, { repositories: [] });
+ assert.match(topModelsSource, /Object\.keys\(metrics\.usage\.models\)/);
+ assert.match(topModelsSource, /Object\.entries\(metrics\.usage\.models\)/);
+ assert.match(dashboardSource, /overviewStats\?\.usage\?\.total_cost_usd/);
+ assert.equal(typeof overview.usage.models, 'object');
+ assert.equal(Array.isArray(overview.usage.models), false);
+ assert.match(repositoryBreakdownSource, /setRepositories\(data\.repositories \|\| \[\]\)/);
+ assert.ok(Array.isArray(repositories.repositories));
+ });
+});
+
diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
index 8754d7261..bae4a597c 100644
--- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
+++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
@@ -727,6 +727,8 @@ describe('packaged Connect fixture cleanup', () => {
let attempts = 0;
const result = await removeAuthorizedConnectFixture({
...identityOptions,
+ // This test proves retry semantics; timeout-specific tests retain their tight wall-clock bounds.
+ retryBoundMs: 250,
rmImpl: async removed => {
assert.equal(removed, fixture);
attempts += 1;
diff --git a/apps/desktop/scripts/run-packaged-acceptance.mjs b/apps/desktop/scripts/run-packaged-acceptance.mjs
new file mode 100644
index 000000000..85017fd49
--- /dev/null
+++ b/apps/desktop/scripts/run-packaged-acceptance.mjs
@@ -0,0 +1,1633 @@
+import { createHash } from 'node:crypto';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { createServer } from 'node:http';
+import { constants as fsConstants } from 'node:fs';
+import { access, lstat, mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { chromium } from 'playwright';
+import { Server as SocketIOServer } from 'socket.io';
+import {
+ DESKTOP_RENDERER_ORIGIN,
+ DESKTOP_TRANSPORT_SCOPE_QUERY,
+ PROPR_API_COMPATIBILITY,
+ PROPR_UI_COMPATIBILITY,
+ QUEUE_STATS_UPDATE,
+} from '@propr/shared';
+import {
+ ACCEPTANCE_JOURNEYS,
+ ACCEPTANCE_PROFILE_PREFIX,
+ ACCEPTANCE_SURFACES_PREFIX,
+ ACCEPTANCE_VARIANTS,
+ DETERMINISTIC_INPUTS,
+ FIXED_ACCEPTANCE_ORIGINS,
+ FIXED_TIME,
+ defaultAcceptanceOutputDirectory,
+ prepareAcceptanceArtifactDirectory,
+ readPngDimensions,
+ safeRemoveAcceptanceLeaf,
+ scanAcceptancePaths,
+ screenshotName,
+ verifyAcceptanceArtifacts,
+ writeAcceptanceManifest,
+} from './acceptance-artifacts.mjs';
+import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from './packaged-acceptance-clock.mjs';
+import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs';
+import {
+ classifyCurrentUserRequestShape,
+ currentUserValidationPhaseSummary,
+ currentUserValidationFailureCategory as classifyCurrentUserValidation,
+ networkPermissionDecisionSummary,
+} from './packaged-acceptance-current-user.mjs';
+import {
+ captureElectronRendererScreenshot,
+ forEachElectronRendererVariant,
+ waitForUsableElectronRenderer,
+} from './packaged-acceptance-renderer.mjs';
+
+if (process.platform !== 'linux' || process.arch !== 'x64') {
+ throw new Error('Packaged desktop visual acceptance must run on Linux x64');
+}
+
+const outputDirectory = resolve(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || defaultAcceptanceOutputDirectory());
+const binaryPath = resolve('out', 'propr-desktop-linux-x64', 'propr-desktop');
+const DEVICE_SECRET = 'D'.repeat(43);
+const INSTANCE_TOKEN = `propr_it_${'T'.repeat(43)}`;
+const ACTIVATION_TICKET = 'A'.repeat(43);
+const PAIRING_ID = `dpr_${'P'.repeat(22)}`;
+const RECEIPT = 'R'.repeat(22);
+const SENTINELS = [DEVICE_SECRET, INSTANCE_TOKEN, ACTIVATION_TICKET];
+const FIXED_MILLIS = PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS;
+const consoleRecords = [];
+const pendingConsoleRecords = [];
+const pageErrorRecords = [];
+const processRecords = [];
+const requestRecords = [];
+const socketRecords = [];
+const fixtureHandshakeRecords = [];
+const mainHandshakeRecords = [];
+const rendererLifecycleRecords = [];
+const rendererCurrentUserRecords = [];
+const mainCurrentUserRecords = [];
+const fixtureCurrentUserRecords = [];
+const networkPermissionRecords = [];
+const screenshotMetadata = [];
+const accessibilityChecks = [];
+const axeFindings = [];
+const liveAnnouncements = {};
+const surfaces = [];
+const boundaryJourneys = new Set();
+let activeJourney = 'startup';
+let keyboardOrder = false;
+let visibleFocus = false;
+let modalFocusTrap = false;
+let modalFocusRestore = false;
+let traceWritten = false;
+const rendererLifecycleInvalidStates = new Map();
+let currentUserEvidenceInvalid = false;
+let networkPermissionEvidenceInvalidCount = 0;
+
+const digest = value => createHash('sha256').update(value).digest('hex');
+const unique = values => [...new Set(values)].sort((a, b) => ACCEPTANCE_JOURNEYS.indexOf(a) - ACCEPTANCE_JOURNEYS.indexOf(b));
+const sleep = milliseconds => new Promise(resolveValue => setTimeout(resolveValue, milliseconds));
+const MAIN_HANDSHAKE_EVENT = 'desktop.acceptance.websocket_handshake';
+const MAIN_CURRENT_USER_EVENT = 'desktop.acceptance.current_user_proxy';
+const NETWORK_PERMISSION_EVENT = 'desktop.acceptance.network_permission';
+const RENDERER_LIFECYCLE_PREFIX = '[ProPR Acceptance Renderer Lifecycle]';
+const RENDERER_CURRENT_USER_PREFIX = '[ProPR Acceptance Current User]';
+const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats';
+const RENDERER_LIFECYCLE_PHASES = new Set([
+ 'profile-activation-published', 'socket-provider-mounted', 'socket-effect-disabled',
+ 'socket-effect-scope-unavailable', 'socket-effect-ready', 'socket-construction-invoked', 'socket-constructed',
+ 'socket-connect-invoked',
+]);
+const RENDERER_LIFECYCLE_KEYS = [
+ 'schemaVersion', 'phase', 'profileActivationPublished', 'socketProviderMounted',
+ 'providerDisabled', 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent', 'desktopRuntime',
+ 'connectionScope', 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations',
+].sort();
+const MAIN_HANDSHAKE_CATEGORIES = new Set([
+ 'none', 'untrusted-http-origin', 'wrong-path', 'wrong-transport', 'wrong-resource-type',
+ 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding',
+ 'stale-generation', 'wrong-origin', 'stale-scope',
+]);
+const CURRENT_USER_PROXY_CATEGORIES = new Set([
+ 'none', 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding',
+ 'stale-generation', 'wrong-origin', 'stale-scope',
+]);
+const CURRENT_USER_RENDERER_PHASES = new Set([
+ 'request-issued', 'response-completed', 'parsed-user-accepted', 'parsed-user-rejected',
+ 'active-scope-accepted', 'stale-scope-rejected', 'revoked-scope-rejected', 'active-scope-rejected',
+]);
+const CURRENT_USER_CLASSIFICATIONS = new Set([
+ 'pending', 'success', 'unauthenticated', 'revoked', 'forbidden', 'server-error',
+ 'network-error', 'invalid-schema',
+]);
+const NETWORK_PERMISSION_CATEGORIES = new Set([
+ 'local-network-access', 'local-network', 'loopback-network',
+]);
+
+const recordRendererLifecycleInvalid = (journey, categories) => {
+ const current = rendererLifecycleInvalidStates.get(journey);
+ if (current) {
+ current.count = Math.min(current.count + 1, 9);
+ categories.forEach(category => current.categories.add(category));
+ return;
+ }
+ rendererLifecycleInvalidStates.set(journey, {
+ count: 1,
+ firstCategory: categories[0],
+ categories: new Set(categories),
+ });
+};
+
+const captureMainHandshakeEvidence = (line, journey) => {
+ if (!line.includes(MAIN_HANDSHAKE_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ path: record.path,
+ transport: record.transport,
+ resource: record.resource,
+ scopeQueryPresent: record.scopeQueryPresent,
+ scopeQueryCount: record.scopeQueryCount,
+ scopeEqualsActive: record.scopeEqualsActive,
+ activeBindingPresent: record.activeBindingPresent,
+ profileGenerationCurrent: record.profileGenerationCurrent,
+ originEqualsActive: record.originEqualsActive,
+ rendererBearerPresent: record.rendererBearerPresent,
+ rendererCookiePresent: record.rendererCookiePresent,
+ outboundBearerPresent: record.outboundBearerPresent,
+ bearerMainInjected: record.bearerMainInjected,
+ accepted: record.accepted,
+ rejectionCategory: record.rejectionCategory,
+ };
+ const valid = record.event === MAIN_HANDSHAKE_EVENT
+ && evidence.schemaVersion === 1
+ && ['socket-io', 'other'].includes(evidence.path)
+ && ['websocket', 'other'].includes(evidence.transport)
+ && ['websocket', 'other'].includes(evidence.resource)
+ && Number.isSafeInteger(evidence.scopeQueryCount) && evidence.scopeQueryCount >= 0
+ && MAIN_HANDSHAKE_CATEGORIES.has(evidence.rejectionCategory)
+ && [
+ evidence.scopeQueryPresent,
+ evidence.scopeEqualsActive,
+ evidence.activeBindingPresent,
+ evidence.profileGenerationCurrent,
+ evidence.originEqualsActive,
+ evidence.rendererBearerPresent,
+ evidence.rendererCookiePresent,
+ evidence.outboundBearerPresent,
+ evidence.bearerMainInjected,
+ evidence.accepted,
+ ].every(value => typeof value === 'boolean');
+ mainHandshakeRecords.push(valid
+ ? { journey, ...evidence }
+ : { journey, accepted: false, rejectionCategory: 'invalid-main-evidence' });
+ } catch {
+ mainHandshakeRecords.push({ journey, accepted: false, rejectionCategory: 'invalid-main-evidence' });
+ }
+};
+
+const captureMainCurrentUserEvidence = (line, journey) => {
+ if (!line.includes(MAIN_CURRENT_USER_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ correlation: record.correlation,
+ requestObserved: record.requestObserved,
+ method: record.method,
+ rendererScopeGeneration: record.rendererScopeGeneration,
+ scopeGenerationQueryCount: record.scopeGenerationQueryCount,
+ scopeGenerationQueryValid: record.scopeGenerationQueryValid,
+ scopeHeaderCount: record.scopeHeaderCount,
+ activeBindingPresent: record.activeBindingPresent,
+ activeScopeGeneration: record.activeScopeGeneration,
+ profileGenerationCurrent: record.profileGenerationCurrent,
+ scopeEqualsActive: record.scopeEqualsActive,
+ originEqualsActive: record.originEqualsActive,
+ rendererBearerPresent: record.rendererBearerPresent,
+ rendererCookiePresent: record.rendererCookiePresent,
+ outboundBearerPresent: record.outboundBearerPresent,
+ bearerMainInjected: record.bearerMainInjected,
+ accepted: record.accepted,
+ rejectionCategory: record.rejectionCategory,
+ };
+ const valid = record.event === MAIN_CURRENT_USER_EVENT
+ && evidence.schemaVersion === 2 && evidence.correlation === 'current-scope-user-validation'
+ && evidence.requestObserved === true && evidence.method === 'get'
+ && (evidence.rendererScopeGeneration === null
+ || (Number.isSafeInteger(evidence.rendererScopeGeneration) && evidence.rendererScopeGeneration >= 0))
+ && Number.isSafeInteger(evidence.scopeGenerationQueryCount)
+ && evidence.scopeGenerationQueryCount >= 0 && evidence.scopeGenerationQueryCount <= 2
+ && typeof evidence.scopeGenerationQueryValid === 'boolean'
+ && Number.isSafeInteger(evidence.scopeHeaderCount) && evidence.scopeHeaderCount >= 0 && evidence.scopeHeaderCount <= 2
+ && Number.isSafeInteger(evidence.activeScopeGeneration) && evidence.activeScopeGeneration >= 0
+ && CURRENT_USER_PROXY_CATEGORIES.has(evidence.rejectionCategory)
+ && [
+ evidence.activeBindingPresent, evidence.profileGenerationCurrent, evidence.scopeEqualsActive,
+ evidence.originEqualsActive, evidence.rendererBearerPresent, evidence.rendererCookiePresent,
+ evidence.outboundBearerPresent, evidence.bearerMainInjected, evidence.accepted,
+ ].every(value => typeof value === 'boolean')
+ && mainCurrentUserRecords.filter(item => item.journey === journey).length < 4;
+ if (!valid) currentUserEvidenceInvalid = true;
+ else mainCurrentUserRecords.push({ journey, ...evidence });
+ } catch {
+ currentUserEvidenceInvalid = true;
+ }
+};
+
+const captureNetworkPermissionEvidence = (line, journey) => {
+ if (!line.includes(NETWORK_PERMISSION_EVENT)) return;
+ try {
+ const record = JSON.parse(line.slice(line.indexOf('{')));
+ const evidence = {
+ schemaVersion: record.schemaVersion,
+ permissionCategory: record.permissionCategory,
+ decision: record.decision,
+ allowed: record.allowed,
+ activeBindingCurrent: record.activeBindingCurrent,
+ webContentsPresent: record.webContentsPresent,
+ webContentsEqualsMainWindow: record.webContentsEqualsMainWindow,
+ mainWindowPresent: record.mainWindowPresent,
+ isMainFrame: record.isMainFrame,
+ requestingUrlPresent: record.requestingUrlPresent,
+ requestingUrlTrusted: record.requestingUrlTrusted,
+ rendererDocumentUrlTrusted: record.rendererDocumentUrlTrusted,
+ requestingOriginAuthorityValid: record.requestingOriginAuthorityValid,
+ requestingOriginAuthorityEqual: record.requestingOriginAuthorityEqual,
+ };
+ const valid = record.event === NETWORK_PERMISSION_EVENT
+ && evidence.schemaVersion === 1
+ && NETWORK_PERMISSION_CATEGORIES.has(evidence.permissionCategory)
+ && ['check', 'request'].includes(evidence.decision)
+ && [
+ evidence.allowed,
+ evidence.activeBindingCurrent,
+ evidence.webContentsPresent,
+ evidence.webContentsEqualsMainWindow,
+ evidence.mainWindowPresent,
+ evidence.isMainFrame,
+ evidence.requestingUrlPresent,
+ evidence.requestingUrlTrusted,
+ evidence.rendererDocumentUrlTrusted,
+ evidence.requestingOriginAuthorityValid,
+ evidence.requestingOriginAuthorityEqual,
+ ].every(value => typeof value === 'boolean');
+ if (!valid || networkPermissionRecords.filter(item => item.journey === journey).length >= 24) {
+ networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9);
+ return;
+ }
+ networkPermissionRecords.push({ journey, ...evidence });
+ } catch {
+ networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9);
+ }
+};
+
+const captureRendererLifecycleEvidence = (text, journey) => {
+ if (!text.startsWith(RENDERER_LIFECYCLE_PREFIX)) return;
+ const serializedPrefix = `${RENDERER_LIFECYCLE_PREFIX} `;
+ let evidence;
+ try {
+ if (!text.startsWith(serializedPrefix)) {
+ recordRendererLifecycleInvalid(journey, ['serialization-prefix']);
+ return;
+ }
+ evidence = JSON.parse(text.slice(serializedPrefix.length));
+ } catch {
+ recordRendererLifecycleInvalid(journey, ['serialization-json']);
+ return;
+ }
+ const exactKeys = evidence && typeof evidence === 'object' && !Array.isArray(evidence)
+ && Object.keys(evidence).sort().join('\n') === RENDERER_LIFECYCLE_KEYS.join('\n');
+ const booleans = exactKeys && [
+ 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled', 'desktopRuntime',
+ 'disabledByDemoModeLoading', 'disabledByDemoMode',
+ 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent',
+ ].every(key => typeof evidence[key] === 'boolean');
+ const counts = exactKeys && [
+ 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations',
+ ].every(key => Number.isInteger(evidence[key]) && evidence[key] >= 0 && evidence[key] <= 2);
+ const disableReasonsConsistent = booleans && evidence.providerDisabled === [
+ evidence.disabledByDemoModeLoading,
+ evidence.disabledByDemoMode,
+ evidence.disabledByCurrentUserLoading,
+ evidence.disabledByCurrentUserAbsent,
+ ].some(Boolean);
+ const missingKey = evidence && typeof evidence === 'object' && !Array.isArray(evidence)
+ ? RENDERER_LIFECYCLE_KEYS.find(key => !Object.hasOwn(evidence, key))
+ : null;
+ const invalidCategory = !evidence || typeof evidence !== 'object' || Array.isArray(evidence) ? 'evidence-kind'
+ : missingKey ? `missing-${missingKey}`
+ : !exactKeys ? 'unexpected-keys'
+ : evidence.schemaVersion !== 2 ? 'schema-version'
+ : !RENDERER_LIFECYCLE_PHASES.has(evidence.phase) ? 'phase'
+ : !['unknown', 'available', 'unavailable'].includes(evidence.connectionScope) ? 'connection-scope'
+ : !booleans ? 'booleans'
+ : !counts ? 'counts'
+ : !disableReasonsConsistent ? 'disable-reasons'
+ : null;
+ const overflow = rendererLifecycleRecords.filter(record => record.journey === journey).length >= 12;
+ if (invalidCategory || overflow) {
+ recordRendererLifecycleInvalid(journey, [
+ ...(invalidCategory ? [invalidCategory] : []),
+ ...(overflow ? ['overflow'] : []),
+ ]);
+ return;
+ }
+ rendererLifecycleRecords.push({ journey, ...evidence });
+};
+
+const captureRendererCurrentUserEvidence = (argumentsValue, journey) => {
+ if (argumentsValue[0] !== RENDERER_CURRENT_USER_PREFIX) return;
+ const evidence = argumentsValue[1];
+ const expectedKeys = [
+ 'activeScopePresent', 'classification', 'correlation', 'phase', 'responseStatus', 'schemaAccepted',
+ 'schemaVersion', 'scopeGeneration',
+ ].sort();
+ const valid = evidence && typeof evidence === 'object' && !Array.isArray(evidence)
+ && Object.keys(evidence).sort().join('\n') === expectedKeys.join('\n')
+ && evidence.schemaVersion === 1
+ && evidence.correlation === 'current-scope-user-validation'
+ && CURRENT_USER_RENDERER_PHASES.has(evidence.phase)
+ && CURRENT_USER_CLASSIFICATIONS.has(evidence.classification)
+ && Number.isSafeInteger(evidence.scopeGeneration) && evidence.scopeGeneration >= 0
+ && Number.isSafeInteger(evidence.responseStatus) && evidence.responseStatus >= 0 && evidence.responseStatus <= 599
+ && typeof evidence.activeScopePresent === 'boolean'
+ && typeof evidence.schemaAccepted === 'boolean'
+ && rendererCurrentUserRecords.filter(item => item.journey === journey).length < 10;
+ if (!valid) currentUserEvidenceInvalid = true;
+ else rendererCurrentUserRecords.push({ journey, ...evidence });
+};
+
+await access(binaryPath, fsConstants.X_OK);
+const binaryStats = await lstat(binaryPath);
+if (!binaryStats.isFile() || binaryStats.isSymbolicLink()) throw new Error('Packaged acceptance binary must be an executable non-link file');
+await prepareAcceptanceArtifactDirectory(outputDirectory);
+
+const json = (response, status, value, extra = {}) => {
+ response.writeHead(status, {
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Access-Control-Allow-Headers': 'Content-Type, X-ProPR-Desktop-Transport-Scope',
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
+ 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN,
+ 'Access-Control-Allow-Private-Network': 'true',
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/json',
+ ...extra,
+ });
+ response.end(JSON.stringify(value));
+};
+
+const fixtures = [];
+const fixtureHandshakeCategory = evidence => {
+ if (evidence.path !== 'socket-io') return 'wrong-path';
+ if (evidence.transport !== 'websocket') return 'wrong-transport';
+ if (evidence.resource !== 'websocket-upgrade') return 'wrong-resource-type';
+ if (evidence.scopeQueryCount === 0) return 'scope-missing';
+ if (evidence.scopeQueryCount !== 1) return 'scope-duplicate';
+ if (!evidence.scopeQueryWellFormed) return 'scope-malformed';
+ if (evidence.cookiePresent) return 'cookie-present';
+ if (!evidence.authorizationPresent) return 'authorization-missing';
+ if (!evidence.authorizationMatchesActivatedBearer) return 'authorization-mismatch';
+ return 'none';
+};
+
+const createFixture = async (mode, fixedOrigin) => {
+ const expectedUrl = new URL(fixedOrigin);
+ let binding = null;
+ let authChecks = 0;
+ const recordCurrentUserFixture = record => {
+ if (fixtureCurrentUserRecords.filter(item => item.journey === activeJourney).length >= 4) {
+ currentUserEvidenceInvalid = true;
+ return;
+ }
+ fixtureCurrentUserRecords.push(record);
+ };
+ const server = createServer(async (request, response) => {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ const bodyText = Buffer.concat(chunks).toString('utf8');
+ const requestUrl = new URL(request.url || '/', fixedOrigin);
+ let body = {};
+ try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { body = {}; }
+ requestRecords.push({
+ journey: activeJourney,
+ mode,
+ method: request.method || '',
+ url: request.url || '',
+ fullUrl: `${fixedOrigin}${request.url || ''}`,
+ origin: request.headers.origin || '',
+ hasAuthorization: Boolean(request.headers.authorization),
+ bodyBytes: Buffer.byteLength(bodyText),
+ });
+ if (request.method === 'OPTIONS') return json(response, 204, {});
+ if (request.url === '/api/desktop/discovery') {
+ return json(response, 200, {
+ schemaVersion: 1,
+ product: 'ProPR', version: mode === 'incompatible' ? '99.0.0' : '0.8.15',
+ apiCompatibility: mode === 'incompatible' ? '2099-01-01' : PROPR_API_COMPATIBILITY,
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
+ canonicalEndpoint: null,
+ publicInstanceIdentity: mode === 'ready'
+ ? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+ : mode === 'revoked'
+ ? 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+ : 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
+ desktopAuthentication: { protocolVersion: 2, browserPairing: true, instanceBearerTokens: true, socketIoBearerAuthentication: true },
+ });
+ }
+ if (request.method === 'POST' && request.url === '/api/desktop/pairings') {
+ binding = body;
+ return json(response, 200, {
+ pairingId: PAIRING_ID,
+ deviceSecret: DEVICE_SECRET,
+ approvalUrl: `${fixedOrigin}/api/desktop/pairings/${PAIRING_ID}/browser`,
+ expiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(),
+ interval: 1,
+ });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/poll`) {
+ return json(response, 200, {
+ status: 'provisional', token: INSTANCE_TOKEN, tokenType: 'Bearer', activationTicket: ACTIVATION_TICKET,
+ activationExpiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(), instanceId: binding?.instanceId,
+ origin: binding?.origin, scope: binding?.scope, credentialGeneration: binding?.credentialGeneration,
+ });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/activate`) {
+ return json(response, 200, { status: 'active', receipt: RECEIPT, activatedAt: FIXED_TIME, expiresAt: null });
+ }
+ if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/cancel`) {
+ return json(response, 200, { status: 'cancelled', cancelledAt: FIXED_TIME });
+ }
+ if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') return json(response, 204, {});
+ const currentUserShape = classifyCurrentUserRequestShape(
+ request.method,
+ request.url,
+ request.headers.origin,
+ );
+ if (currentUserShape !== null) {
+ if (currentUserShape.source === 'renderer') authChecks += 1;
+ const source = currentUserShape.source;
+ const record = {
+ journey: activeJourney,
+ correlation: 'current-scope-user-validation',
+ source,
+ scopeGeneration: currentUserShape.scopeGeneration,
+ requestArrived: true,
+ authorizationPresent: typeof request.headers.authorization === 'string',
+ authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`,
+ cookiePresent: typeof request.headers.cookie === 'string',
+ responseStatus: 200,
+ classification: 'success',
+ };
+ if (source === 'renderer' && mode === 'revoked' && authChecks >= 1) {
+ record.responseStatus = 401;
+ record.classification = 'revoked';
+ recordCurrentUserFixture(record);
+ return json(response, 401, { code: 'INSTANCE_TOKEN_REVOKED' });
+ }
+ if (request.headers.authorization !== `Bearer ${INSTANCE_TOKEN}`) {
+ record.responseStatus = 401;
+ record.classification = 'unauthenticated';
+ recordCurrentUserFixture(record);
+ return json(response, 401, { code: 'INVALID_INSTANCE_TOKEN' });
+ }
+ recordCurrentUserFixture(record);
+ return json(response, 200, {
+ id: 'acceptance-user', login: 'acceptance-admin', username: 'acceptance-admin', displayName: 'Acceptance Admin',
+ email: null, avatarUrl: null, role: 'admin',
+ permissions: ['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings'],
+ authorizationSource: 'local',
+ });
+ }
+ if (request.url === '/api/compatibility') return json(response, 200, { apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY });
+ // Socket lifecycle evidence requires the same boolean consumed from the
+ // production endpoint; the generic API fixture shape is not compatible.
+ if (request.url === '/api/auth/demo-mode') return json(response, 200, { demoMode: false });
+ if (request.url?.startsWith('/api/status')) return json(response, 200, { daemon: 'Running', redis: 'Connected', githubAuth: 'Authenticated', claudeAuth: 'Ready', agents: [], githubEventIntake: 'ProPR Connect', githubEventIntakeStatus: 'Connected' });
+ if (request.url?.startsWith('/api/queue/stats')) return json(response, 200, { active: 0, waiting: 0, completed: 12, failed: 0, delayed: 0, paused: 0 });
+ if (requestUrl.pathname === '/api/stats/generating-plans') return json(response, 200, { count: 0 });
+ if (requestUrl.pathname === '/api/stats/tasks') {
+ return json(response, 200, {
+ dailyCounts: [],
+ statusDistribution: [
+ { status: 'completed', count: 12 },
+ { status: 'failed', count: 0 },
+ ],
+ avgProcessingTime: [],
+ summary: { total: 12, completed: 12, failed: 0 },
+ });
+ }
+ if (requestUrl.pathname === '/api/stats/overview') {
+ return json(response, 200, {
+ tasks: {
+ completed: 12,
+ planned: 0,
+ pr_iterations_avg: 0,
+ merged_prs: 0,
+ total_followups: 0,
+ },
+ usage: { total_tokens: 0, total_cost_usd: 0, models: {} },
+ system: { repos_indexed: 0 },
+ });
+ }
+ if (requestUrl.pathname === '/api/stats/repositories') {
+ return json(response, 200, { repositories: [] });
+ }
+ if (request.url?.startsWith('/api/stats/')) return json(response, 200, { total: 12, pending: 0, inProgress: 0, completed: 12, failed: 0, dailyCounts: [] });
+ if (request.url?.startsWith('/api/tasks')) return json(response, 200, { tasks: [], total: 0 });
+ if (request.url?.startsWith('/api/')) return json(response, 200, { agents: [], repositories: [], items: [], count: 0 });
+ return json(response, 404, { code: 'NOT_FOUND' });
+ });
+ const io = new SocketIOServer(server, {
+ path: '/socket.io/',
+ transports: ['websocket'],
+ cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false },
+ });
+ io.engine.use((request, _response, next) => {
+ const requestUrl = new URL(request.url || '/', 'http://fixture.invalid');
+ const queryScopes = requestUrl.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
+ const evidence = {
+ journey: activeJourney,
+ mode,
+ path: requestUrl.pathname === '/socket.io/' ? 'socket-io' : 'other',
+ transport: requestUrl.searchParams.getAll('transport').length === 1
+ && requestUrl.searchParams.get('transport') === 'websocket' ? 'websocket' : 'other',
+ resource: request.method === 'GET' && request.headers.upgrade?.toLowerCase() === 'websocket'
+ ? 'websocket-upgrade' : 'other',
+ scopeQueryPresent: queryScopes.length > 0,
+ scopeQueryCount: queryScopes.length,
+ scopeQueryWellFormed: queryScopes.length === 1 && /^[A-Za-z0-9_-]{22}$/.test(queryScopes[0]),
+ authorizationPresent: typeof request.headers.authorization === 'string',
+ authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`,
+ cookiePresent: typeof request.headers.cookie === 'string',
+ accepted: false,
+ rejectionCategory: 'none',
+ };
+ evidence.rejectionCategory = fixtureHandshakeCategory(evidence);
+ evidence.accepted = evidence.rejectionCategory === 'none';
+ fixtureHandshakeRecords.push(evidence);
+ if (!evidence.accepted) {
+ next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED'));
+ return;
+ }
+ next();
+ });
+ io.use((socket, next) => {
+ const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid').searchParams
+ .getAll(DESKTOP_TRANSPORT_SCOPE_QUERY);
+ if (socket.handshake.headers.authorization !== `Bearer ${INSTANCE_TOKEN}` || queryScopes.length !== 1) {
+ next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED'));
+ return;
+ }
+ next();
+ });
+ io.on('connection', socket => {
+ const journey = activeJourney;
+ const authenticated = socket.handshake.headers.authorization === `Bearer ${INSTANCE_TOKEN}`;
+ const recordApplicationSubscription = () => socketRecords.push({
+ journey,
+ mode,
+ event: QUEUE_STATS_SUBSCRIBE_EVENT,
+ authenticated,
+ direction: 'client-to-fixture',
+ genuineApplicationSubscription: true,
+ });
+ socketRecords.push({ journey, mode, event: 'connection', authenticated });
+ socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => {
+ recordApplicationSubscription();
+ socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription);
+ socket.emit(QUEUE_STATS_UPDATE, {
+ eventType: QUEUE_STATS_UPDATE,
+ stats: { waiting: 0, active: 0, completed: 12, failed: 0, delayed: 0, total: 12 },
+ timestamp: FIXED_TIME,
+ });
+ socketRecords.push({
+ journey,
+ mode,
+ event: QUEUE_STATS_UPDATE,
+ authenticated,
+ direction: 'fixture-to-renderer',
+ genuineApplicationEvent: true,
+ });
+ });
+ });
+ server.listen(Number(expectedUrl.port), expectedUrl.hostname);
+ await Promise.race([
+ once(server, 'listening'),
+ once(server, 'error').then(([error]) => { throw error; }),
+ ]);
+ const address = server.address();
+ fixtures.push({ server, io });
+ const observedOrigin = `http://${address.address}:${address.port}`;
+ if (observedOrigin !== fixedOrigin) throw new Error(`Acceptance fixture origin changed: ${observedOrigin}`);
+ return fixedOrigin;
+};
+
+let readyOrigin;
+let revokedOrigin;
+let incompatibleOrigin;
+
+const isolatedEnvironment = (userData, scenario) => {
+ const allowed = {};
+ for (const name of ['DBUS_SESSION_BUS_ADDRESS', 'GNOME_KEYRING_CONTROL', 'DISPLAY', 'XAUTHORITY', 'PATH']) {
+ if (process.env[name]) allowed[name] = process.env[name];
+ }
+ return {
+ ...allowed,
+ HOME: userData,
+ LANG: 'C.UTF-8',
+ LANGUAGE: 'en_US:en',
+ LC_ALL: 'C.UTF-8',
+ TZ: 'UTC',
+ SOURCE_DATE_EPOCH: String(Math.floor(FIXED_MILLIS / 1000)),
+ XDG_CONFIG_HOME: join(userData, 'xdg-config'),
+ XDG_DATA_HOME: join(userData, 'xdg-data'),
+ XDG_CACHE_HOME: join(userData, 'xdg-cache'),
+ PROPR_DESKTOP_ACCEPTANCE_TEST: '1',
+ PROPR_DESKTOP_ACCEPTANCE_SCENARIO: scenario,
+ };
+};
+
+const terminateProcessTree = async (child, childClosed) => {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ if (!Number.isInteger(child.pid)) { await childClosed.catch(() => undefined); return; }
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error?.code !== 'ESRCH') throw error; }
+ await Promise.race([childClosed, sleep(5_000)]).catch(() => undefined);
+ if (child.exitCode === null && child.signalCode === null) {
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') throw error; }
+ await childClosed.catch(() => undefined);
+ }
+};
+
+const launchApplication = async (scenario, initialDeepLink) => {
+ const userData = await mkdtemp(join(tmpdir(), ACCEPTANCE_PROFILE_PREFIX));
+ let child;
+ let childClosed;
+ let browser;
+ let cleaned = false;
+ let output = '';
+ const cleanup = async () => {
+ if (cleaned) return;
+ cleaned = true;
+ const failures = [];
+ if (browser) await browser.close().catch(error => failures.push(error));
+ if (child && childClosed) await terminateProcessTree(child, childClosed).catch(error => failures.push(error));
+ await scanAcceptancePaths([userData], SENTINELS).catch(error => failures.push(error));
+ surfaces.push({ kind: 'packaged-process-output', journey: activeJourney, value: output });
+ processRecords.push({ journey: activeJourney, source: 'packaged-process', level: 'combined', bytes: Buffer.byteLength(output), sha256: digest(output) });
+ await safeRemoveAcceptanceLeaf(userData, { kind: 'profile' }).catch(error => failures.push(error));
+ if (failures.length) throw new AggregateError(failures, 'Packaged acceptance cleanup failed');
+ };
+
+ try {
+ const args = [
+ '--disable-background-networking', '--disable-gpu', '--force-device-scale-factor=1', '--lang=en-US',
+ '--remote-debugging-port=0', '--propr-acceptance-test', `--user-data-dir=${userData}`,
+ '--password-store=gnome-libsecret', ...(initialDeepLink ? [initialDeepLink] : []),
+ ];
+ child = spawn(binaryPath, args, {
+ cwd: userData,
+ detached: true,
+ env: isolatedEnvironment(userData, scenario),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ childClosed = once(child, 'close');
+ let resolveEndpoint;
+ let rejectEndpoint;
+ const endpoint = new Promise((resolveValue, rejectValue) => { resolveEndpoint = resolveValue; rejectEndpoint = rejectValue; });
+ const capture = () => {
+ let remainder = '';
+ return chunk => {
+ const text = chunk.toString();
+ output += text;
+ const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
+ if (match) resolveEndpoint(match[1]);
+ const lines = `${remainder}${text}`.split(/\r?\n/);
+ remainder = lines.pop() || '';
+ lines.forEach(line => {
+ captureMainHandshakeEvidence(line, activeJourney);
+ captureMainCurrentUserEvidence(line, activeJourney);
+ captureNetworkPermissionEvidence(line, activeJourney);
+ });
+ };
+ };
+ child.stdout.on('data', capture());
+ child.stderr.on('data', capture());
+ child.once('error', rejectEndpoint);
+ child.once('close', code => rejectEndpoint(new Error(`Packaged Electron exited before CDP startup (${code ?? 'signal'})`)));
+ const timeout = setTimeout(() => rejectEndpoint(new Error('Packaged Electron CDP endpoint did not start')), 20_000);
+ const wsEndpoint = await endpoint.finally(() => clearTimeout(timeout));
+ browser = await chromium.connectOverCDP(wsEndpoint);
+ const { context, page } = await waitForUsableElectronRenderer(browser, child, {
+ expectedUrlPrefix: DESKTOP_RENDERER_ORIGIN,
+ timeoutMs: 15_000,
+ });
+ page.on('console', message => {
+ const journey = activeJourney;
+ // Lifecycle evidence is a single serialized renderer snapshot. Capture it
+ // synchronously so its strict transition order does not depend on CDP
+ // JSHandle resolution or page lifetime.
+ captureRendererLifecycleEvidence(message.text(), journey);
+ const pending = Promise.all(message.args().map(async argument => {
+ try { return await argument.jsonValue(); } catch { return { unserializable: argument.toString() }; }
+ })).then(argumentsValue => {
+ const record = {
+ journey,
+ type: message.type(),
+ text: message.text(),
+ arguments: argumentsValue,
+ location: message.location(),
+ };
+ consoleRecords.push(record);
+ captureRendererCurrentUserEvidence(record.arguments, journey);
+ });
+ pendingConsoleRecords.push(pending);
+ });
+ page.on('pageerror', error => pageErrorRecords.push({ journey: activeJourney, name: error.name, message: error.message, stack: error.stack || '' }));
+ await page.evaluate(({ fixedTime, fixedMillis }) => {
+ const NativeDate = Date;
+ class AcceptanceDate extends NativeDate {
+ constructor(...args) { super(...(args.length ? args : [fixedMillis])); }
+ static now() { return fixedMillis; }
+ }
+ Object.defineProperty(window, 'Date', { configurable: false, value: AcceptanceDate });
+ let uuidCounter = 0;
+ Object.defineProperty(Crypto.prototype, 'randomUUID', {
+ configurable: false,
+ value: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`,
+ });
+ document.documentElement.dataset.acceptanceFixedTime = fixedTime;
+ }, { fixedTime: FIXED_TIME, fixedMillis: FIXED_MILLIS });
+ await page.waitForFunction(() => Boolean(document.querySelector('.desktop-entry, .desktop-app')), null, { timeout: 15_000 });
+ await page.addStyleTag({ content: `
+ *, *::before, *::after { animation: none !important; caret-color: transparent !important; transition: none !important; scroll-behavior: auto !important; }
+ html body, html body button, html body input, html body select, html body textarea { font-family: "Liberation Sans", sans-serif !important; }
+ ` });
+ const initialization = await page.evaluate(fixedTime => ({
+ rendererOrigin: `${location.protocol}//${location.host}`,
+ preloadBridge: typeof window.proprDesktop === 'object' && window.proprDesktop !== null,
+ acceptanceZoomBridge: typeof window.__PROPR_PACKAGED_ACCEPTANCE__ === 'object'
+ && window.__PROPR_PACKAGED_ACCEPTANCE__ !== null
+ && Object.keys(window.__PROPR_PACKAGED_ACCEPTANCE__).join('\n') === 'setZoomFactor'
+ && typeof window.__PROPR_PACKAGED_ACCEPTANCE__.setZoomFactor === 'function',
+ fontLoaded: document.fonts.check('12px "Liberation Sans"'),
+ locale: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ rendererTime: new Date().toISOString(),
+ fixedMarker: document.documentElement.dataset.acceptanceFixedTime === fixedTime,
+ }), FIXED_TIME);
+ if (initialization.rendererOrigin !== 'propr-app://renderer' || !initialization.preloadBridge
+ || !initialization.acceptanceZoomBridge
+ || !initialization.fontLoaded || initialization.locale !== DETERMINISTIC_INPUTS.locale
+ || initialization.timezone !== DETERMINISTIC_INPUTS.timezone || initialization.rendererTime !== FIXED_TIME
+ || !initialization.fixedMarker) {
+ throw new Error(`Packaged acceptance deterministic boundary initialization failed: ${JSON.stringify(initialization)}`);
+ }
+ boundaryJourneys.add(activeJourney);
+ return { context, page, close: cleanup };
+ } catch (error) {
+ try { await cleanup(); } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'Packaged acceptance launch and cleanup failed');
+ }
+ throw error;
+ }
+};
+
+const fillEndpoint = async (page, origin, name = 'Acceptance ProPR') => {
+ await page.getByRole('button', { name: /Connect to an existing instance/ }).click();
+ await page.getByLabel('Display name').fill(name);
+ await page.getByLabel('Instance URL').fill(origin);
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+};
+
+const pair = async (page, origin, name = 'Acceptance ProPR') => {
+ await fillEndpoint(page, origin, name);
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ await page.getByRole('button', { name: 'Sign in in browser' }).click();
+};
+
+const liveRegionSnapshot = () => [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({
+ ariaLive: element.getAttribute('aria-live') || '',
+ role: element.getAttribute('role') || '',
+ text: element.textContent || '',
+}));
+
+const observeLiveMutation = async (page, kind, journey, action) => {
+ const before = await page.evaluate(liveRegionSnapshot);
+ await action();
+ await page.waitForFunction(({ beforeJson, kindName }) => {
+ const snapshot = [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({
+ ariaLive: element.getAttribute('aria-live') || '', role: element.getAttribute('role') || '', text: element.textContent || '',
+ }));
+ const changed = JSON.stringify(snapshot) !== beforeJson && snapshot.some(entry => entry.text.trim());
+ if (!changed) return false;
+ return kindName === 'status' || snapshot.some(entry => entry.role === 'alert' || /could not|unavailable|error|attention/i.test(entry.text));
+ }, { beforeJson: JSON.stringify(before), kindName: kind }, { timeout: 15_000 });
+ const after = await page.evaluate(liveRegionSnapshot);
+ const beforeJson = JSON.stringify(before);
+ const afterJson = JSON.stringify(after);
+ if (beforeJson === afterJson) throw new Error(`Acceptance ${kind} live region did not mutate`);
+ liveAnnouncements[kind] = { journey, beforeHash: digest(beforeJson), afterHash: digest(afterJson), mutated: true };
+};
+
+const collectRendererSurface = async (page, journey, name) => {
+ const renderer = await page.evaluate(async ({ journeyName, screenshot }) => {
+ const attributes = element => Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]));
+ const forms = [...document.querySelectorAll('input, select, textarea, button')].map(element => ({
+ tagName: element.tagName,
+ attributes: attributes(element),
+ value: 'value' in element ? element.value : '',
+ checked: 'checked' in element ? element.checked : false,
+ selectedValues: element instanceof HTMLSelectElement ? [...element.selectedOptions].map(option => option.value) : [],
+ }));
+ const databases = [];
+ if (typeof indexedDB.databases === 'function') {
+ for (const databaseInfo of await indexedDB.databases()) {
+ if (!databaseInfo.name) continue;
+ const contents = await new Promise((resolveValue, rejectValue) => {
+ const request = indexedDB.open(databaseInfo.name);
+ request.onerror = () => rejectValue(request.error);
+ request.onsuccess = () => {
+ const database = request.result;
+ const stores = [...database.objectStoreNames];
+ if (stores.length === 0) { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: {} }); return; }
+ const transaction = database.transaction(stores, 'readonly');
+ const values = {};
+ transaction.onerror = () => rejectValue(transaction.error);
+ transaction.oncomplete = () => { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: values }); };
+ stores.forEach(store => {
+ const all = transaction.objectStore(store).getAll();
+ all.onsuccess = () => { values[store] = all.result; };
+ });
+ };
+ });
+ databases.push(contents);
+ }
+ }
+ const cachesData = [];
+ if ('caches' in window) {
+ for (const cacheName of await caches.keys()) {
+ const cache = await caches.open(cacheName);
+ const entries = [];
+ for (const request of await cache.keys()) {
+ const response = await cache.match(request);
+ entries.push({ requestUrl: request.url, responseUrl: response?.url || '', body: response ? await response.clone().text() : '' });
+ }
+ cachesData.push({ name: cacheName, entries });
+ }
+ }
+ return {
+ kind: 'renderer', journey: journeyName, screenshot, url: location.href, title: document.title,
+ html: document.documentElement.outerHTML, forms,
+ localStorage: Object.fromEntries(Object.entries(localStorage)),
+ sessionStorage: Object.fromEntries(Object.entries(sessionStorage)),
+ indexedDB: databases, caches: cachesData,
+ };
+ }, { journeyName: journey, screenshot: name });
+ renderer.cookies = await page.context().cookies();
+ surfaces.push(renderer);
+};
+
+const inspectAccessibility = async (page, journey, variant, config, metrics, name) => {
+ const seriousFindings = await analyzeExistingElectronRenderer(page);
+ for (const violation of seriousFindings) {
+ axeFindings.push({ name, journey, variant, id: violation.id, impact: violation.impact, nodes: violation.nodes });
+ }
+ const deterministic = await page.evaluate(({ expectedReducedMotion, fixedTime }) => {
+ const visibleControls = [...document.querySelectorAll('button, a[href], input, select, textarea')].filter(element => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ });
+ const unnamed = visibleControls.filter(element => {
+ const label = element.getAttribute('aria-label') || element.getAttribute('title')
+ || (element instanceof HTMLInputElement ? element.labels?.[0]?.textContent : '') || element.textContent;
+ return !label?.trim();
+ }).length;
+ return {
+ accessibleNames: unnamed === 0,
+ locale: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ fontLoaded: document.fonts.check('12px "Liberation Sans"'),
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
+ rendererTime: new Date().toISOString(),
+ animationsDisabled: document.getAnimations({ subtree: true }).every(animation => animation.playState !== 'running'),
+ expectedReducedMotion,
+ };
+ }, { expectedReducedMotion: config.reducedMotion, fixedTime: FIXED_TIME });
+ if (deterministic.locale !== DETERMINISTIC_INPUTS.locale || deterministic.timezone !== DETERMINISTIC_INPUTS.timezone
+ || deterministic.rendererTime !== FIXED_TIME || deterministic.reducedMotion !== config.reducedMotion
+ || deterministic.reducedMotion !== deterministic.expectedReducedMotion || !deterministic.fontLoaded
+ || !deterministic.animationsDisabled) {
+ throw new Error(`Acceptance deterministic renderer inputs changed for ${name}: ${JSON.stringify(deterministic)}`);
+ }
+ return {
+ name, journey, variant,
+ serious: seriousFindings.filter(finding => finding.impact === 'serious').length,
+ critical: seriousFindings.filter(finding => finding.impact === 'critical').length,
+ accessibleNames: deterministic.accessibleNames,
+ locale: deterministic.locale,
+ timezone: deterministic.timezone,
+ fontLoaded: deterministic.fontLoaded,
+ reducedMotion: deterministic.reducedMotion,
+ requestedViewport: metrics.requestedViewport,
+ playwrightViewport: metrics.playwrightViewport,
+ rendererViewport: metrics.rendererViewport,
+ documentClientViewport: metrics.documentClientViewport,
+ scrollbarInsets: metrics.scrollbarInsets,
+ visualViewportInsets: metrics.visualViewportInsets,
+ layoutViewport: metrics.layoutViewport,
+ cdpVisualViewport: metrics.cdpVisualViewport,
+ rendererVisualViewport: metrics.rendererVisualViewport,
+ effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan,
+ geometryZoom: metrics.geometryZoom,
+ requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor,
+ rendererDevicePixelRatio: metrics.rendererDevicePixelRatio,
+ requestedZoomFactor: metrics.requestedZoomFactor,
+ appliedZoomFactor: metrics.appliedZoomFactor,
+ zoomResetFactor: metrics.zoomResetFactor,
+ zoomMechanism: metrics.zoomMechanism,
+ animationsDisabled: deterministic.animationsDisabled,
+ rendererTime: deterministic.rendererTime,
+ };
+};
+
+const captureVariants = async (page, journey) => {
+ const cdp = await page.context().newCDPSession(page);
+ try {
+ await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics }) => {
+ const name = screenshotName(journey, variant);
+ const accessibilityCheck = await inspectAccessibility(page, journey, variant, config, metrics, name);
+ await collectRendererSurface(page, journey, name);
+ const first = await captureElectronRendererScreenshot(cdp);
+ const second = await captureElectronRendererScreenshot(cdp);
+ const firstDigest = digest(first);
+ if (!first.equals(second)) throw new Error(`Acceptance screenshot was not repeatable for ${name}`);
+ const dimensions = readPngDimensions(first);
+ const expectedDimensions = {
+ width: metrics.requestedViewport.width * metrics.requestedDeviceScaleFactor,
+ height: metrics.requestedViewport.height * metrics.requestedDeviceScaleFactor,
+ };
+ if (dimensions.width !== expectedDimensions.width || dimensions.height !== expectedDimensions.height) {
+ throw new Error(`Acceptance screenshot dimensions changed for ${name}: ${JSON.stringify(dimensions)}`);
+ }
+ const physicalPngDimensions = { ...dimensions };
+ accessibilityChecks.push({ ...accessibilityCheck, physicalPngDimensions });
+ await writeFile(join(outputDirectory, 'screenshots', name), first, { mode: 0o600 });
+ screenshotMetadata.push({
+ name, journey, variant,
+ width: dimensions.width,
+ height: dimensions.height,
+ physicalPngDimensions,
+ requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor,
+ rendererDevicePixelRatio: metrics.rendererDevicePixelRatio,
+ requestedZoomFactor: metrics.requestedZoomFactor,
+ appliedZoomFactor: metrics.appliedZoomFactor,
+ zoomResetFactor: metrics.zoomResetFactor,
+ zoomMechanism: metrics.zoomMechanism,
+ reducedMotion: metrics.reducedMotion,
+ requestedViewport: metrics.requestedViewport,
+ playwrightViewport: metrics.playwrightViewport,
+ rendererViewport: metrics.rendererViewport,
+ documentClientViewport: metrics.documentClientViewport,
+ scrollbarInsets: metrics.scrollbarInsets,
+ visualViewportInsets: metrics.visualViewportInsets,
+ layoutViewport: metrics.layoutViewport,
+ cdpVisualViewport: metrics.cdpVisualViewport,
+ rendererVisualViewport: metrics.rendererVisualViewport,
+ effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan,
+ geometryZoom: metrics.geometryZoom,
+ locale: DETERMINISTIC_INPUTS.locale,
+ timezone: DETERMINISTIC_INPUTS.timezone,
+ font: DETERMINISTIC_INPUTS.font,
+ colorScheme: DETERMINISTIC_INPUTS.colorScheme,
+ rendererTime: FIXED_TIME,
+ originPolicy: DETERMINISTIC_INPUTS.originPolicy,
+ visibleData: DETERMINISTIC_INPUTS.visibleData,
+ animations: DETERMINISTIC_INPUTS.animations,
+ repeatabilitySha256: firstDigest,
+ });
+ }, { colorScheme: DETERMINISTIC_INPUTS.colorScheme });
+ } finally {
+ await cdp.detach();
+ }
+};
+
+const runJourney = async (journey, scenario, initialDeepLink, prepare) => {
+ activeJourney = journey;
+ const application = await launchApplication(scenario, initialDeepLink);
+ try {
+ const afterCapture = await prepare(application.page, application);
+ await captureVariants(application.page, journey);
+ if (typeof afterCapture === 'function') await afterCapture();
+ } finally {
+ await application.close();
+ }
+};
+
+const waitForObserved = async (predicate, description) => {
+ const deadline = Date.now() + 15_000;
+ while (!predicate()) {
+ if (Date.now() >= deadline) throw new Error(`Acceptance did not observe ${description}`);
+ await sleep(50);
+ }
+};
+
+const currentUserValidationFailureCategory = journey => {
+ return classifyCurrentUserValidation({
+ journey,
+ evidenceInvalid: currentUserEvidenceInvalid,
+ rendererRecords: rendererCurrentUserRecords,
+ mainRecords: mainCurrentUserRecords,
+ fixtureRecords: fixtureCurrentUserRecords,
+ });
+};
+
+const currentUserPhaseSummary = journey => currentUserValidationPhaseSummary({
+ journey,
+ rendererRecords: rendererCurrentUserRecords,
+ mainRecords: mainCurrentUserRecords,
+ fixtureRecords: fixtureCurrentUserRecords,
+ requestRecords,
+});
+
+const networkPermissionSummary = journey => networkPermissionDecisionSummary({
+ journey,
+ records: networkPermissionRecords,
+ invalidCount: networkPermissionEvidenceInvalidCount,
+});
+
+const settlePendingRendererConsoleCaptures = async () => {
+ let settledCount = 0;
+ while (settledCount < pendingConsoleRecords.length) {
+ const unsettled = pendingConsoleRecords.slice(settledCount);
+ settledCount += unsettled.length;
+ await Promise.allSettled(unsettled);
+ }
+};
+
+const boundedAcceptanceDiagnosticCount = records => Math.min(records.length, 9);
+
+const rendererConsoleErrorCategory = record => {
+ if (record.text.startsWith('Failed to refresh instance authorization:')
+ || record.text.startsWith('Failed to synchronize instance authorization:')) return 'currentUserSync';
+ if (record.text.startsWith('[SocketContext]')) return 'socketContext';
+ if (record.text.startsWith('Failed to load or render a route:')
+ || record.text.startsWith('Root container missing in index.html')
+ || record.text.startsWith('Uncaught ')
+ || record.text.startsWith('Error: Minified React error')
+ || record.text.startsWith('Warning: React')) return 'reactRuntime';
+ if (record.text.startsWith('Failed to fetch ')
+ || record.text.startsWith('Failed to load ')
+ || record.text.startsWith('Error fetching ')
+ || record.text.startsWith('Silent refresh failed:')
+ || record.text.startsWith('[useGenerationPolling] Poll error:')) return 'apiLoad';
+ return 'other';
+};
+
+const rendererConsoleErrorCategoryCounts = records => {
+ const counts = { currentUserSync: 0, apiLoad: 0, socketContext: 0, reactRuntime: 0, other: 0 };
+ for (const record of records) {
+ const category = rendererConsoleErrorCategory(record);
+ counts[category] = Math.min(counts[category] + 1, 9);
+ }
+ return counts;
+};
+
+const rendererErrorCountSummary = journey => {
+ const rendererConsole = consoleRecords.filter(record => record.journey === journey);
+ const rendererPageErrors = pageErrorRecords.filter(record => record.journey === journey);
+ const rendererConsoleErrors = rendererConsole.filter(record => record.type === 'error');
+ return {
+ schemaVersion: 2,
+ console: boundedAcceptanceDiagnosticCount(rendererConsole),
+ consoleErrors: boundedAcceptanceDiagnosticCount(rendererConsoleErrors),
+ pageErrors: boundedAcceptanceDiagnosticCount(rendererPageErrors),
+ consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts(rendererConsoleErrors),
+ };
+};
+
+const rendererLifecycleDiagnosticSummary = journey => {
+ const invalid = rendererLifecycleInvalidStates.get(journey);
+ return {
+ schemaVersion: 2,
+ recordCount: rendererLifecycleRecords.filter(record => record.journey === journey).length,
+ invalidCount: invalid?.count ?? 0,
+ firstInvalidCategory: invalid?.firstCategory ?? 'none',
+ invalidCategories: invalid ? [...invalid.categories].sort() : [],
+ };
+};
+
+const rendererLifecycleInvalidSummary = () => {
+ const [firstJourney, firstInvalid] = rendererLifecycleInvalidStates.entries().next().value ?? [];
+ return {
+ schemaVersion: 1,
+ journeyCount: Math.min(rendererLifecycleInvalidStates.size, ACCEPTANCE_JOURNEYS.length),
+ invalidCount: Math.min([...rendererLifecycleInvalidStates.values()]
+ .reduce((total, invalid) => total + invalid.count, 0), 9),
+ firstJourney: ACCEPTANCE_JOURNEYS.includes(firstJourney) ? firstJourney : 'unknown',
+ firstInvalidCategory: firstInvalid?.firstCategory ?? 'none',
+ };
+};
+
+const rendererUiStateSummary = async page => {
+ const absent = {
+ schemaVersion: 1,
+ connectionPillPresent: false,
+ connectionStatus: 'absent',
+ accessibleLabelCategory: 'other',
+ navigatorOnline: false,
+ desktopTitleBarPresent: false,
+ routeLayoutPresent: false,
+ loadingSpinnerPresent: false,
+ validatedCurrentUserMarkerPresent: false,
+ dashboardMarkerPresent: false,
+ };
+ try {
+ return await page.evaluate(() => {
+ const pill = document.querySelector('.desktop-connection-pill');
+ const classStatuses = ['ready', 'offline', 'incompatible'].filter(status =>
+ pill?.classList.contains(`desktop-connection-${status}`));
+ const connectionStatus = pill === null
+ ? 'absent'
+ : classStatuses.length === 1 ? classStatuses[0] : 'unknown';
+ const label = pill?.getAttribute('aria-label');
+ const accessibleLabelCategory = label === 'Connected: Operations'
+ ? 'Connected: Operations'
+ : label === 'Offline: Operations'
+ ? 'Offline: Operations'
+ : label === 'Update required: Operations' ? 'Update required: Operations' : 'other';
+ const routeLayout = document.querySelector(
+ '.desktop-shell .desktop-shell-content main.mobile-content-clearance',
+ );
+ const loadingSpinner = document.querySelector(
+ '[class~="h-screen"][class~="w-full"] [class~="h-12"][class~="w-12"][class~="animate-spin"]',
+ );
+ const validatedCurrentUserMarker = document.querySelector('a[href="/admin/members"]');
+ const dashboardMarker = [...document.querySelectorAll('main h3')]
+ .some(element => element.textContent?.trim() === 'Recent Activity');
+ return {
+ schemaVersion: 1,
+ connectionPillPresent: pill !== null,
+ connectionStatus,
+ accessibleLabelCategory,
+ navigatorOnline: navigator.onLine,
+ desktopTitleBarPresent: document.querySelector('.desktop-titlebar') !== null,
+ routeLayoutPresent: routeLayout !== null,
+ loadingSpinnerPresent: loadingSpinner !== null,
+ validatedCurrentUserMarkerPresent: validatedCurrentUserMarker !== null,
+ dashboardMarkerPresent: dashboardMarker,
+ };
+ });
+ } catch {
+ return absent;
+ }
+};
+
+const rendererSurfacePhase = async page => {
+ try {
+ return await page.evaluate(() => {
+ if (document.querySelector('.desktop-app')) return 'app';
+ if (document.querySelector('.desktop-entry')) return 'entry';
+ return 'loading';
+ });
+ } catch {
+ return 'loading';
+ }
+};
+
+const socketHandshakeFailureCategory = journey => {
+ const currentUserCategory = currentUserValidationFailureCategory(journey);
+ if (currentUserCategory !== 'none') return currentUserCategory;
+ const rejectedMain = mainHandshakeRecords.find(record => record.journey === journey && !record.accepted);
+ if (rejectedMain) return `main-${rejectedMain.rejectionCategory}`;
+ const rejectedFixture = fixtureHandshakeRecords.find(record => record.journey === journey && !record.accepted);
+ if (rejectedFixture) return `fixture-${rejectedFixture.rejectionCategory}`;
+ const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const connected = socketRecords.filter(record => record.journey === journey
+ && record.event === 'connection' && record.authenticated);
+ const subscriptions = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated
+ && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture');
+ const applicationEvents = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_UPDATE && record.authenticated
+ && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer');
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ if (mainAccepted.length > 1) return 'duplicate-main-upgrade';
+ if (fixtureAccepted.length > 1) return 'duplicate-fixture-upgrade';
+ if (connected.length > 1) return 'duplicate-authenticated-connection';
+ if (subscriptions.length > 1) return 'duplicate-application-subscription';
+ if (applicationEvents.length > 1) return 'duplicate-application-event';
+ if (rendererObservedApplicationEvents.length > 1) return 'duplicate-renderer-application-event';
+ if (mainAccepted.length === 1 && fixtureAccepted.length === 0) return 'fixture-not-reached';
+ if (fixtureAccepted.length === 1 && connected.length === 0) return 'namespace-not-connected';
+ if (connected.length === 1 && subscriptions.length === 0) return 'application-subscription-not-observed';
+ if (subscriptions.length === 1 && applicationEvents.length === 0) return 'application-event-not-produced';
+ if (applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 0) {
+ return 'renderer-application-event-not-observed';
+ }
+ const lifecycleCategory = rendererLifecycleCategory(journey);
+ return lifecycleCategory === 'none'
+ ? 'renderer-connect-invoked-upgrade-not-produced'
+ : lifecycleCategory;
+};
+
+const rendererLifecycleCategory = journey => {
+ if (rendererLifecycleInvalidStates.has(journey)) return 'renderer-lifecycle-evidence-invalid';
+ const records = rendererLifecycleRecords.filter(record => record.journey === journey);
+ const latest = records.at(-1);
+ if (!latest?.profileActivationPublished) return 'renderer-profile-activation-not-propagated';
+ if (!latest.socketProviderMounted) return 'renderer-socket-provider-not-mounted';
+ if (latest.providerDisabled) {
+ if (latest.disabledByDemoModeLoading) return 'renderer-socket-provider-disabled-demo-mode-loading';
+ if (latest.disabledByDemoMode) return 'renderer-socket-provider-disabled-demo-mode';
+ if (latest.disabledByCurrentUserLoading) return 'renderer-socket-provider-disabled-current-user-loading';
+ if (latest.disabledByCurrentUserAbsent) return 'renderer-socket-provider-disabled-current-user-absent';
+ return 'renderer-socket-provider-disabled-unattributed';
+ }
+ if (!latest.desktopRuntime) return 'renderer-desktop-runtime-unavailable';
+ if (latest.connectionScope !== 'available') return 'renderer-connection-scope-unavailable';
+ if (latest.socketConstructionInvocations === 0) return 'renderer-socket-construction-not-invoked';
+ if (latest.socketConstructions === 0) return 'renderer-socket-construction-not-produced';
+ if (latest.connectInvocations === 0) return 'renderer-socket-connect-not-invoked';
+ if (latest.socketConstructionInvocations !== 1 || latest.socketConstructions !== 1
+ || latest.connectInvocations !== 1) return 'renderer-socket-lifecycle-duplicate';
+ return 'none';
+};
+
+const waitForAuthenticatedSocket = async journey => {
+ const deadline = Date.now() + 15_000;
+ while (true) {
+ const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted);
+ const connected = socketRecords.filter(record => record.journey === journey
+ && record.event === 'connection' && record.authenticated);
+ const applicationSubscriptions = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated
+ && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture');
+ const applicationEvents = socketRecords.filter(record => record.journey === journey
+ && record.event === QUEUE_STATS_UPDATE && record.authenticated
+ && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer');
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ const rendererLifecycle = rendererLifecycleCategory(journey);
+ if (mainAccepted.length === 1 && fixtureAccepted.length === 1
+ && connected.length === 1 && applicationSubscriptions.length === 1
+ && applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 1
+ && rendererLifecycle === 'none') return;
+ const category = socketHandshakeFailureCategory(journey);
+ if (category.startsWith('main-') || category.startsWith('fixture-') || category.startsWith('duplicate-')) {
+ throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; renderer-lifecycle=${JSON.stringify(rendererLifecycleDiagnosticSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`);
+ }
+ if (Date.now() >= deadline) {
+ throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; renderer-lifecycle=${JSON.stringify(rendererLifecycleDiagnosticSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`);
+ }
+ await sleep(50);
+ }
+};
+
+const observedServiceSummary = () => {
+ const restRequests = requestRecords.filter(record => record.method !== 'OPTIONS' && record.url.startsWith('/api/'));
+ const authenticatedRequests = restRequests.filter(record => record.hasAuthorization);
+ const socketConnections = socketRecords.filter(record => record.event === 'connection' && record.authenticated);
+ const socketSubscriptions = socketRecords.filter(record => record.event === QUEUE_STATS_SUBSCRIBE_EVENT
+ && record.authenticated && record.genuineApplicationSubscription === true
+ && record.direction === 'client-to-fixture');
+ const socketEvents = socketRecords.filter(record => record.event === QUEUE_STATS_UPDATE
+ && record.authenticated && record.genuineApplicationEvent === true
+ && record.direction === 'fixture-to-renderer');
+ const mainHandshakes = mainHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted);
+ const fixtureHandshakes = fixtureHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted);
+ const mainHandshake = mainHandshakes[0];
+ const fixtureHandshake = fixtureHandshakes[0];
+ const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === 'dashboard-profile-manager'
+ && record.text.startsWith('[SocketContext] Received queue stats update:'));
+ const rendererLifecycle = rendererLifecycleRecords
+ .filter(record => record.journey === 'dashboard-profile-manager').at(-1);
+ const pairingStarted = requestRecords.filter(record => record.method === 'POST' && record.url === '/api/desktop/pairings');
+ const pairingPolled = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`);
+ const pairingActivated = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`);
+ const connectConfirmed = requestRecords.filter(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery');
+ const currentUserCategory = currentUserValidationFailureCategory('dashboard-profile-manager');
+ const currentUserIssued = rendererCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'request-issued');
+ const currentUserMain = mainCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager');
+ const currentUserFixture = fixtureCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager'
+ && record.source === 'renderer');
+ const services = {
+ rest: {
+ requestCount: restRequests.length,
+ authenticatedRequestCount: authenticatedRequests.length,
+ journeys: unique(restRequests.map(record => record.journey)),
+ currentUserValidation: {
+ correlation: 'current-scope-user-validation',
+ rendererRequestIssued: Boolean(currentUserIssued),
+ rendererScopeGeneration: currentUserIssued?.scopeGeneration ?? 0,
+ mainProxyObserved: Boolean(currentUserMain),
+ mainActiveScopeGeneration: currentUserMain?.activeScopeGeneration ?? 0,
+ activeScopeAccepted: currentUserMain?.accepted === true,
+ mainOnlyBearerInjection: currentUserMain?.rendererBearerPresent === false
+ && currentUserMain?.rendererCookiePresent === false
+ && currentUserMain?.bearerMainInjected === true,
+ upstreamRequestArrived: currentUserFixture?.requestArrived === true,
+ responseStatus: currentUserFixture?.responseStatus ?? 0,
+ responseClassification: currentUserFixture?.classification ?? 'not-observed',
+ parsedUserSchemaAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'parsed-user-accepted' && record.schemaAccepted),
+ rendererActiveScopeAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager'
+ && record.activeScopePresent && record.phase === 'active-scope-accepted'),
+ firstFailedPredicate: currentUserCategory,
+ },
+ },
+ socketIo: {
+ authenticatedConnections: socketConnections.length,
+ events: socketEvents.length,
+ journeys: unique(socketRecords.map(record => record.journey)),
+ handshake: {
+ mainAttempts: mainHandshakes.length,
+ fixtureAttempts: fixtureHandshakes.length,
+ scopeQueryPresent: mainHandshake?.scopeQueryPresent === true && fixtureHandshake?.scopeQueryPresent === true,
+ scopeQueryCount: mainHandshake?.scopeQueryCount ?? 0,
+ scopeEqualsActivatedBinding: mainHandshake?.scopeEqualsActive === true,
+ activeBindingPresent: mainHandshake?.activeBindingPresent === true,
+ profileGenerationCurrent: mainHandshake?.profileGenerationCurrent === true,
+ originEqualsActivatedBinding: mainHandshake?.originEqualsActive === true,
+ path: mainHandshake?.path ?? 'other',
+ transport: mainHandshake?.transport ?? 'other',
+ resource: mainHandshake?.resource ?? 'other',
+ rendererBearerPresent: mainHandshake?.rendererBearerPresent ?? false,
+ rendererCookiePresent: mainHandshake?.rendererCookiePresent ?? false,
+ authorizationHeaderPresent: fixtureHandshake?.authorizationPresent ?? false,
+ authorizationHeaderExactlyMainInjected: mainHandshake?.bearerMainInjected === true
+ && fixtureHandshake?.authorizationMatchesActivatedBearer === true,
+ rendererObservedApplicationEvent: rendererObservedApplicationEvents.length === 1,
+ rendererLifecycle: rendererLifecycle ? {
+ phase: rendererLifecycle.phase,
+ profileActivationPublished: rendererLifecycle.profileActivationPublished,
+ socketProviderMounted: rendererLifecycle.socketProviderMounted,
+ providerDisabled: rendererLifecycle.providerDisabled,
+ disabledByDemoModeLoading: rendererLifecycle.disabledByDemoModeLoading,
+ disabledByDemoMode: rendererLifecycle.disabledByDemoMode,
+ disabledByCurrentUserLoading: rendererLifecycle.disabledByCurrentUserLoading,
+ disabledByCurrentUserAbsent: rendererLifecycle.disabledByCurrentUserAbsent,
+ desktopRuntime: rendererLifecycle.desktopRuntime,
+ connectionScope: rendererLifecycle.connectionScope,
+ socketConstructionInvocations: rendererLifecycle.socketConstructionInvocations,
+ socketConstructions: rendererLifecycle.socketConstructions,
+ connectInvocations: rendererLifecycle.connectInvocations,
+ } : null,
+ rejectionCategory: mainHandshake?.rejectionCategory ?? socketHandshakeFailureCategory('dashboard-profile-manager'),
+ },
+ },
+ pairing: { started: pairingStarted.length, polled: pairingPolled.length, activated: pairingActivated.length, journeys: unique([...pairingStarted, ...pairingPolled, ...pairingActivated].map(record => record.journey)) },
+ connect: { confirmedRequests: connectConfirmed.length, journeys: unique(connectConfirmed.map(record => record.journey)) },
+ };
+ if (services.rest.requestCount <= 0 || services.rest.authenticatedRequestCount <= 0
+ || currentUserCategory !== 'none'
+ || services.socketIo.authenticatedConnections !== 1 || socketSubscriptions.length !== 1
+ || services.socketIo.events !== 1 || rendererObservedApplicationEvents.length !== 1
+ || services.socketIo.handshake.mainAttempts !== 1 || services.socketIo.handshake.fixtureAttempts !== 1
+ || !services.socketIo.handshake.scopeQueryPresent || services.socketIo.handshake.scopeQueryCount !== 1
+ || !services.socketIo.handshake.scopeEqualsActivatedBinding
+ || !services.socketIo.handshake.activeBindingPresent
+ || !services.socketIo.handshake.profileGenerationCurrent
+ || !services.socketIo.handshake.originEqualsActivatedBinding
+ || services.socketIo.handshake.path !== 'socket-io'
+ || services.socketIo.handshake.transport !== 'websocket'
+ || services.socketIo.handshake.resource !== 'websocket'
+ || services.socketIo.handshake.rendererBearerPresent
+ || services.socketIo.handshake.rendererCookiePresent
+ || !services.socketIo.handshake.authorizationHeaderPresent
+ || !services.socketIo.handshake.authorizationHeaderExactlyMainInjected
+ || !services.socketIo.handshake.rendererObservedApplicationEvent
+ || services.socketIo.handshake.rendererLifecycle?.phase !== 'socket-connect-invoked'
+ || services.socketIo.handshake.rendererLifecycle?.profileActivationPublished !== true
+ || services.socketIo.handshake.rendererLifecycle?.socketProviderMounted !== true
+ || services.socketIo.handshake.rendererLifecycle?.providerDisabled !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByDemoModeLoading !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByDemoMode !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserLoading !== false
+ || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserAbsent !== false
+ || services.socketIo.handshake.rendererLifecycle?.desktopRuntime !== true
+ || services.socketIo.handshake.rendererLifecycle?.connectionScope !== 'available'
+ || services.socketIo.handshake.rendererLifecycle?.socketConstructionInvocations !== 1
+ || services.socketIo.handshake.rendererLifecycle?.socketConstructions !== 1
+ || services.socketIo.handshake.rendererLifecycle?.connectInvocations !== 1
+ || services.socketIo.handshake.rejectionCategory !== 'none'
+ || services.pairing.started <= 0 || services.pairing.polled <= 0 || services.pairing.activated <= 0
+ || services.connect.confirmedRequests <= 0) {
+ throw new Error(`Acceptance packaged-renderer service journeys were incomplete: ${JSON.stringify(services)}`);
+ }
+ return services;
+};
+
+try {
+ readyOrigin = await createFixture('ready', FIXED_ACCEPTANCE_ORIGINS.ready);
+ revokedOrigin = await createFixture('revoked', FIXED_ACCEPTANCE_ORIGINS.revoked);
+ incompatibleOrigin = await createFixture('incompatible', FIXED_ACCEPTANCE_ORIGINS.incompatible);
+ await runJourney('first-run-chooser', 'default', null, async page => {
+ await page.getByRole('heading', { name: 'Let’s set up this computer' }).waitFor();
+ const seen = [];
+ for (let index = 0; index < 3; index += 1) {
+ await page.keyboard.press('Tab');
+ seen.push(await page.evaluate(() => document.activeElement?.textContent?.trim() || document.activeElement?.getAttribute('aria-label')));
+ }
+ keyboardOrder = seen.every(Boolean) && new Set(seen).size === seen.length
+ && seen[0].includes('Set up this computer')
+ && seen[1].includes('Connect to an existing instance')
+ && seen[2].includes('Search for instances on this network');
+ visibleFocus = await page.evaluate(() => {
+ const element = document.activeElement;
+ if (!(element instanceof HTMLElement)) return false;
+ const style = getComputedStyle(element);
+ return style.outlineStyle !== 'none' || style.boxShadow !== 'none';
+ });
+ });
+ await runJourney('manual-endpoint-confirmation', 'default', null, async page => {
+ await page.getByRole('button', { name: /Connect to an existing instance/ }).click();
+ await page.getByLabel('Display name').fill('Design Review');
+ await page.getByLabel('Instance URL').fill(readyOrigin);
+ });
+ await runJourney('connect-confirmation', 'default', `propr://connect?api=${encodeURIComponent(readyOrigin)}`, async page => {
+ await page.getByText('Review this untrusted instance address').waitFor();
+ return async () => {
+ await page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ await waitForObserved(() => requestRecords.some(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery'), 'the confirmed Connect request');
+ };
+ });
+ await runJourney('remote-pairing', 'default', null, async page => {
+ await observeLiveMutation(page, 'status', 'remote-pairing', () => fillEndpoint(page, readyOrigin, 'Remote Team'));
+ await page.getByRole('button', { name: 'Sign in in browser' }).waitFor();
+ });
+ await runJourney('local-setup-prerequisites', 'default', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'Check the essentials' }).waitFor();
+ });
+ await runJourney('local-setup-progress', 'default', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ for (let index = 0; index < 5; index += 1) await page.getByRole('button', { name: /Continue/ }).click();
+ await page.getByRole('button', { name: /Install ProPR/ }).click();
+ await page.getByRole('heading', { name: 'Setting up ProPR' }).waitFor();
+ });
+ await runJourney('local-setup-error', 'setup-error', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'Setup needs attention' }).waitFor();
+ });
+ await runJourney('local-setup-completion', 'setup-complete', null, async page => {
+ await page.getByRole('button', { name: /Set up this computer/ }).click();
+ await page.getByRole('heading', { name: 'ProPR is ready' }).waitFor();
+ });
+ await runJourney('dashboard-profile-manager', 'default', null, async (page, application) => {
+ await pair(page, readyOrigin, 'Operations');
+ const opener = page.getByRole('button', { name: /Connected: Operations/ });
+ try {
+ await opener.waitFor({ timeout: 15_000 });
+ } catch (error) {
+ if (!(error instanceof Error) || error.name !== 'TimeoutError') throw error;
+ await settlePendingRendererConsoleCaptures();
+ const journey = 'dashboard-profile-manager';
+ const diagnostic = {
+ schemaVersion: 2,
+ currentUserPhases: currentUserPhaseSummary(journey),
+ networkPermissions: networkPermissionSummary(journey),
+ currentUserCategory: currentUserValidationFailureCategory(journey),
+ rendererLifecycleCategory: rendererLifecycleCategory(journey),
+ rendererLifecycle: rendererLifecycleDiagnosticSummary(journey),
+ surfacePhase: await rendererSurfacePhase(page),
+ uiState: await rendererUiStateSummary(page),
+ rendererErrors: rendererErrorCountSummary(journey),
+ };
+ throw new Error(`Acceptance dashboard Connected control timed out: ${JSON.stringify(diagnostic)}`);
+ }
+ await waitForObserved(() => {
+ const journeyRequests = requestRecords.filter(record => record.journey === 'dashboard-profile-manager');
+ return journeyRequests.some(record => record.method === 'POST' && record.url === '/api/desktop/pairings')
+ && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`)
+ && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`);
+ }, `the complete pairing flow for ${PAIRING_ID}`);
+ await waitForAuthenticatedSocket('dashboard-profile-manager');
+ await application.context.tracing.start({ screenshots: true, snapshots: true, sources: false });
+ await opener.focus();
+ await opener.click();
+ const dialog = page.getByRole('dialog', { name: 'Manage instances' });
+ await dialog.waitFor();
+ const focusables = dialog.locator('button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])');
+ const focusableCount = await focusables.count();
+ if (focusableCount < 2) throw new Error('Acceptance modal has insufficient focus targets');
+ await focusables.nth(focusableCount - 1).focus();
+ await page.keyboard.press('Tab');
+ const wrappedForward = await focusables.first().evaluate(element => element === document.activeElement);
+ await focusables.first().focus();
+ await page.keyboard.press('Shift+Tab');
+ const wrappedBackward = await focusables.nth(focusableCount - 1).evaluate(element => element === document.activeElement);
+ modalFocusTrap = wrappedForward && wrappedBackward;
+ await page.keyboard.press('Escape');
+ modalFocusRestore = await opener.evaluate(element => element === document.activeElement);
+ await opener.click();
+ await application.context.tracing.stop({ path: join(outputDirectory, 'sanitized-trace.zip') });
+ traceWritten = true;
+ });
+ await runJourney('offline', 'default', null, async page => {
+ await observeLiveMutation(page, 'error', 'offline', () => fillEndpoint(page, FIXED_ACCEPTANCE_ORIGINS.offline, 'Offline Lab'));
+ await page.getByText(/could not discover|could not check|unavailable/i).first().waitFor({ timeout: 15_000 });
+ });
+ await runJourney('revoked', 'default', null, async page => {
+ await pair(page, revokedOrigin, 'Revoked Lab');
+ await page.getByText(/revoked or expired/i).waitFor({ timeout: 15_000 });
+ });
+ await runJourney('incompatible-instance', 'default', null, async page => {
+ await fillEndpoint(page, incompatibleOrigin, 'Future Instance');
+ await page.getByText(/Update required/i).waitFor({ timeout: 15_000 });
+ });
+
+ if (!traceWritten) throw new Error('Playwright trace was not produced');
+ await Promise.all(pendingConsoleRecords);
+ if (rendererLifecycleInvalidStates.size > 0) {
+ throw new Error(`Acceptance renderer lifecycle evidence was invalid: ${JSON.stringify(rendererLifecycleInvalidSummary())}`);
+ }
+ if (boundaryJourneys.size !== ACCEPTANCE_JOURNEYS.length
+ || ACCEPTANCE_JOURNEYS.some(journey => !boundaryJourneys.has(journey))) throw new Error('Packaged main/preload/renderer boundary was not observed for every journey');
+ const serious = axeFindings.filter(finding => finding.impact === 'serious').length;
+ const critical = axeFindings.filter(finding => finding.impact === 'critical').length;
+ const accessibility = {
+ schemaVersion: 6,
+ generatedAt: FIXED_TIME,
+ serious,
+ critical,
+ findings: axeFindings,
+ checks: accessibilityChecks,
+ keyboardOrder,
+ visibleFocus,
+ modalFocusTrap,
+ modalFocusRestore,
+ accessibleNames: accessibilityChecks.every(check => check.accessibleNames),
+ liveAnnouncements: { status: liveAnnouncements.status, error: liveAnnouncements.error },
+ };
+ await writeFile(join(outputDirectory, 'accessibility.json'), `${JSON.stringify(accessibility, null, 2)}\n`, { mode: 0o600 });
+
+ const services = observedServiceSummary();
+ const sanitizedSummary = {
+ schemaVersion: 5,
+ generatedAt: FIXED_TIME,
+ status: 'passed',
+ journeys: ACCEPTANCE_JOURNEYS.length,
+ screenshots: screenshotMetadata.length,
+ boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS },
+ console: { records: consoleRecords.length + pageErrorRecords.length, errors: consoleRecords.filter(record => record.type === 'error').length + pageErrorRecords.length },
+ services,
+ redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.',
+ };
+ await writeFile(join(outputDirectory, 'sanitized-summary.json'), `${JSON.stringify(sanitizedSummary, null, 2)}\n`, { mode: 0o600 });
+
+ const sanitizedLogRecords = [
+ ...consoleRecords.map(record => ({
+ journey: record.journey, source: 'renderer-console', level: record.type,
+ bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)),
+ })),
+ ...pageErrorRecords.map(record => ({
+ journey: record.journey, source: 'renderer-page-error', level: 'error',
+ bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)),
+ })),
+ ...processRecords,
+ ];
+ await writeFile(join(outputDirectory, 'sanitized-log.json'), `${JSON.stringify({
+ schemaVersion: 1, generatedAt: FIXED_TIME, records: sanitizedLogRecords,
+ }, null, 2)}\n`, { mode: 0o600 });
+
+ const scanRoot = await mkdtemp(join(tmpdir(), ACCEPTANCE_SURFACES_PREFIX));
+ try {
+ surfaces.push({ kind: 'renderer-console', records: consoleRecords });
+ surfaces.push({ kind: 'renderer-page-errors', records: pageErrorRecords });
+ surfaces.push({ kind: 'service-urls', records: requestRecords });
+ surfaces.push({ kind: 'socket-events', records: socketRecords });
+ surfaces.push({ kind: 'socket-main-handshakes', records: mainHandshakeRecords });
+ surfaces.push({ kind: 'socket-fixture-handshakes', records: fixtureHandshakeRecords });
+ surfaces.push({ kind: 'current-user-renderer-validation', records: rendererCurrentUserRecords });
+ surfaces.push({ kind: 'current-user-main-proxy', records: mainCurrentUserRecords });
+ surfaces.push({ kind: 'current-user-fixture-responses', records: fixtureCurrentUserRecords });
+ surfaces.push({ kind: 'screenshot-metadata', records: screenshotMetadata });
+ surfaces.push({ kind: 'accessibility-metadata', value: accessibility });
+ await writeFile(join(scanRoot, 'complete-renderer-process-network-and-metadata-surfaces.json'), JSON.stringify(surfaces), { mode: 0o600 });
+ await scanAcceptancePaths([scanRoot], SENTINELS);
+ } finally {
+ await safeRemoveAcceptanceLeaf(scanRoot, { kind: 'surfaces' });
+ }
+ await writeAcceptanceManifest(outputDirectory, screenshotMetadata);
+ await verifyAcceptanceArtifacts(outputDirectory, { sentinels: SENTINELS });
+ console.log(`Packaged desktop acceptance produced ${screenshotMetadata.length} deterministic screenshots with zero serious/critical findings.`);
+} finally {
+ activeJourney = 'fixture-cleanup';
+ for (const fixture of fixtures) {
+ await new Promise(resolveValue => fixture.io.close(resolveValue));
+ if (fixture.server.listening) await new Promise(resolveValue => fixture.server.close(resolveValue));
+ }
+}
diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs
index b6cebc1bf..cadf74a8b 100644
--- a/apps/desktop/scripts/smoke-packaged-connect.mjs
+++ b/apps/desktop/scripts/smoke-packaged-connect.mjs
@@ -29,6 +29,7 @@ import {
collectAcceptedSocketEvidence,
evaluatePackagedConnectEvidence,
} from './packaged-connect-evidence.mjs';
+import { isExpectedScopedCurrentUserRequest } from './packaged-acceptance-current-user.mjs';
import {
canonicalizeWindowsFixtureEntry,
encodedWindowsFixtureAcl,
@@ -58,6 +59,12 @@ let resourcesPath = process.platform === 'darwin'
let unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds');
const endpoint = 'https://t-packaged123.propr.dev';
const identity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+const EXPECTED_CURRENT_USER_SCOPE_GENERATION = 1;
+const isExpectedCurrentUserRequest = request => isExpectedScopedCurrentUserRequest(
+ request.method,
+ request.url,
+ EXPECTED_CURRENT_USER_SCOPE_GENERATION,
+);
const secrets = [
'tunnel-secret-SENTINEL', 'connector-secret-SENTINEL',
'relay-secret-SENTINEL', 'github-secret-SENTINEL',
@@ -160,7 +167,7 @@ const createPackagedJourneyFixture = async () => {
}
if (request.method === 'GET' && request.url === '/__packaged/evidence') {
const authenticatedRest = requests.filter(item => item.socketIo === false
- && item.url === '/api/auth/user'
+ && isExpectedCurrentUserRequest(item)
&& item.authorization === `Bearer ${token}`
&& item.transportScope === null);
const authenticatedSockets = requests.filter(item => item.socketIo === true
@@ -263,7 +270,7 @@ const createPackagedJourneyFixture = async () => {
response.end();
return;
}
- if (request.method === 'GET' && request.url === '/api/auth/user'
+ if (isExpectedCurrentUserRequest(request)
&& active && record.authorization === `Bearer ${token}`) {
response.writeHead(200, cors);
response.end(JSON.stringify({
@@ -639,7 +646,7 @@ try {
&& requests.every((request, index) => request.fixtureMode === intendedPairingModes[index]);
const authenticatedRest = applicationRequests.filter(request =>
request.socketIo === false
- && request.url === '/api/auth/user'
+ && isExpectedCurrentUserRequest(request)
&& request.authorization === `Bearer ${journeyFixture.secrets[2]}`);
const socketEvidence = collectAcceptedSocketEvidence({
requests: applicationRequests,
diff --git a/apps/desktop/src/acceptance-test-authorization.test.ts b/apps/desktop/src/acceptance-test-authorization.test.ts
new file mode 100644
index 000000000..8050dafec
--- /dev/null
+++ b/apps/desktop/src/acceptance-test-authorization.test.ts
@@ -0,0 +1,237 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, it } from 'node:test';
+import { parseDesktopPairingStart, ProprClientError } from '@propr/client';
+import {
+ PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+ PACKAGED_ACCEPTANCE_TIME,
+} from '../scripts/packaged-acceptance-clock.mjs';
+import {
+ authorizePackagedAcceptanceTest,
+ packagedAcceptancePairingTiming,
+} from './acceptance-test-authorization';
+import { sanitizeDesktopLogFields } from './logger';
+
+const acceptanceUserData = resolve('/tmp/propr-desktop-acceptance-a1');
+const defaultUserData = resolve('/tmp/default');
+const input = {
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${acceptanceUserData}`],
+ defaultUserDataDirectory: defaultUserData,
+ environmentTriggered: true,
+ isPackaged: true,
+ platform: 'linux' as const,
+};
+
+describe('packaged acceptance authorization', () => {
+ it('accepts only the dual-trigger packaged Linux launch with an isolated profile', () => {
+ assert.equal(authorizePackagedAcceptanceTest(input), acceptanceUserData);
+ assert.equal(authorizePackagedAcceptanceTest({ ...input, argv: ['app'], environmentTriggered: false }), null);
+ });
+
+ it('fails closed for partial triggers, other platforms, and the default profile', () => {
+ assert.throws(() => authorizePackagedAcceptanceTest({ ...input, environmentTriggered: false }), /both/);
+ assert.throws(() => authorizePackagedAcceptanceTest({ ...input, platform: 'darwin' }), /Linux/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${defaultUserData}`],
+ }), /must use/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(acceptanceUserData, '..', 'default')}`],
+ }), /must use/);
+ assert.throws(() => authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(dirname(acceptanceUserData), 'propr-desktop-acceptance-a1', '..', 'escaped')}`],
+ }), /must use/);
+ });
+
+ it('supplies sleep and the shared clock only through the dual-authorized acceptance result', async () => {
+ const authorizedDirectory = authorizePackagedAcceptanceTest(input);
+ const timing = packagedAcceptancePairingTiming(authorizedDirectory);
+
+ assert.ok(timing);
+ assert.deepEqual(Object.keys(timing).sort(), ['now', 'sleep']);
+ assert.equal(timing.now(), PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS);
+ assert.equal(new Date(timing.now()).toISOString(), PACKAGED_ACCEPTANCE_TIME);
+ assert.equal(PACKAGED_ACCEPTANCE_TIME, '2026-01-02T03:04:05.000Z');
+ await timing.sleep(60_000);
+
+ for (const mode of ['production', 'packaged smoke', 'ordinary runtime']) {
+ assert.equal(packagedAcceptancePairingTiming(null), undefined, mode);
+ }
+ const untriggered = authorizePackagedAcceptanceTest({
+ ...input,
+ argv: ['app'],
+ environmentTriggered: false,
+ });
+ assert.equal(packagedAcceptancePairingTiming(untriggered), undefined);
+
+ const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8');
+ assert.match(
+ main,
+ /packagedAcceptancePairingTiming\(packagedAcceptanceUserDataDirectory\)/,
+ );
+ assert.match(main, /acceptancePairingTiming \? \{ pairingTiming: acceptancePairingTiming \} : \{\}/);
+ assert.match(main, /packagedAcceptanceTest \? \{\s*reportWebSocketHandshake:/);
+ assert.match(main, /desktop\.acceptance\.websocket_handshake/);
+ assert.match(main, /desktop\.acceptance\.current_user_proxy/);
+ assert.match(main, /packagedAcceptanceTest \? \{\s*reportNetworkPermissionDecision:/);
+ assert.match(main, /desktop\.acceptance\.network_permission/);
+ const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8');
+ const currentUserClassifier = readFileSync(fileURLToPath(new URL(
+ '../scripts/packaged-acceptance-current-user.mjs', import.meta.url,
+ )), 'utf8');
+ assert.match(acceptanceRunner, /'Access-Control-Allow-Private-Network': 'true'/);
+ assert.match(acceptanceRunner, /network-permissions=/);
+ assert.match(acceptanceRunner, /request\.url === '\/api\/auth\/demo-mode'\) return json\(response, 200, \{ demoMode: false \}\)/);
+ const demoModeFixture = acceptanceRunner.indexOf("request.url === '/api/auth/demo-mode'");
+ const genericApiFixture = acceptanceRunner.indexOf("request.url?.startsWith('/api/')");
+ assert.ok(demoModeFixture >= 0 && demoModeFixture < genericApiFixture);
+ assert.match(currentUserClassifier, /networkPermissionDecisionSummary/);
+ assert.match(currentUserClassifier, /current-user-upstream-request-not-arrived/);
+ assert.match(currentUserClassifier, /current-user-parsed-schema-rejected/);
+ });
+
+ it('bounds the dashboard Connected timeout diagnostic after flushing renderer evidence', () => {
+ const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8');
+ const dashboardStart = acceptanceRunner.indexOf("await runJourney('dashboard-profile-manager'");
+ const dashboardEnd = acceptanceRunner.indexOf("await runJourney('offline'", dashboardStart);
+ assert.notEqual(dashboardStart, -1);
+ assert.notEqual(dashboardEnd, -1);
+ const dashboardJourney = acceptanceRunner.slice(dashboardStart, dashboardEnd);
+
+ assert.match(dashboardJourney, /await opener\.waitFor\(\{ timeout: 15_000 \}\)/);
+ assert.match(dashboardJourney, /error instanceof Error\) \|\| error\.name !== 'TimeoutError'/);
+ const flush = dashboardJourney.indexOf('await settlePendingRendererConsoleCaptures()');
+ const diagnostic = dashboardJourney.indexOf('const diagnostic = {');
+ const rethrow = dashboardJourney.indexOf('Acceptance dashboard Connected control timed out:');
+ assert.ok(flush >= 0 && flush < diagnostic && diagnostic < rethrow);
+ const diagnosticCatch = dashboardJourney.slice(
+ dashboardJourney.indexOf('} catch (error)'),
+ dashboardJourney.indexOf(' await waitForObserved'),
+ );
+
+ for (const boundedField of [
+ 'currentUserPhases: currentUserPhaseSummary(journey)',
+ 'networkPermissions: networkPermissionSummary(journey)',
+ 'currentUserCategory: currentUserValidationFailureCategory(journey)',
+ 'rendererLifecycleCategory: rendererLifecycleCategory(journey)',
+ 'rendererLifecycle: rendererLifecycleDiagnosticSummary(journey)',
+ 'surfacePhase: await rendererSurfacePhase(page)',
+ 'uiState: await rendererUiStateSummary(page)',
+ 'rendererErrors: rendererErrorCountSummary(journey)',
+ ]) assert.ok(dashboardJourney.includes(boundedField), boundedField);
+ assert.doesNotMatch(diagnosticCatch, /error\.(?:message|stack)|record\.(?:text|arguments|location|url)/);
+
+ assert.match(
+ acceptanceRunner,
+ /const settlePendingRendererConsoleCaptures = async \(\) => \{[\s\S]*?await Promise\.allSettled\(unsettled\);[\s\S]*?\n\};/,
+ );
+ assert.match(acceptanceRunner, /const boundedAcceptanceDiagnosticCount = records => Math\.min\(records\.length, 9\)/);
+ assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-app'\)\) return 'app'/);
+ assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-entry'\)\) return 'entry'/);
+ assert.match(acceptanceRunner, /return 'loading'/);
+
+ const uiSummaryStart = acceptanceRunner.indexOf('const rendererUiStateSummary = async page =>');
+ const uiSummaryEnd = acceptanceRunner.indexOf('\nconst rendererSurfacePhase', uiSummaryStart);
+ assert.notEqual(uiSummaryStart, -1);
+ assert.notEqual(uiSummaryEnd, -1);
+ const uiSummary = acceptanceRunner.slice(uiSummaryStart, uiSummaryEnd);
+ for (const status of ["'ready'", "'offline'", "'incompatible'", "'unknown'", "'absent'"]) {
+ assert.ok(uiSummary.includes(status), status);
+ }
+ for (const labelCategory of [
+ "'Connected: Operations'",
+ "'Offline: Operations'",
+ "'Update required: Operations'",
+ "'other'",
+ ]) assert.ok(uiSummary.includes(labelCategory), labelCategory);
+ for (const booleanField of [
+ 'connectionPillPresent',
+ 'navigatorOnline',
+ 'desktopTitleBarPresent',
+ 'routeLayoutPresent',
+ 'loadingSpinnerPresent',
+ 'validatedCurrentUserMarkerPresent',
+ 'dashboardMarkerPresent',
+ ]) assert.ok(uiSummary.includes(booleanField), booleanField);
+ assert.match(uiSummary, /document\.querySelector\('\.desktop-connection-pill'\)/);
+ assert.match(uiSummary, /document\.querySelector\('\.desktop-titlebar'\)/);
+ assert.match(uiSummary, /main\.mobile-content-clearance/);
+ assert.match(uiSummary, /a\[href="\/admin\/members"\]/);
+ assert.doesNotMatch(uiSummary, /outerHTML|innerHTML|document\.body\.textContent/);
+
+ assert.match(acceptanceRunner, /const rendererLifecycleDiagnosticSummary = journey => \{[\s\S]*?invalidCount:[\s\S]*?firstInvalidCategory:[\s\S]*?invalidCategories:/);
+ assert.match(
+ acceptanceRunner,
+ /recordCount: rendererLifecycleRecords\.filter\(record => record\.journey === journey\)\.length/,
+ );
+ assert.match(acceptanceRunner, /recordRendererLifecycleInvalid\(journey, \[/);
+ assert.match(acceptanceRunner, /\.\.\.\(invalidCategory \? \[invalidCategory\] : \[\]\)/);
+ assert.match(acceptanceRunner, /\.\.\.\(overflow \? \['overflow'\] : \[\]\)/);
+ assert.match(acceptanceRunner, /rendererLifecycleInvalidStates\.has\(journey\)/);
+ assert.match(acceptanceRunner, /if \(rendererLifecycleInvalidStates\.size > 0\)/);
+ assert.match(acceptanceRunner, /firstInvalidCategory: firstInvalid\?\.firstCategory \?\? 'none'/);
+ assert.doesNotMatch(acceptanceRunner, /rendererLifecycleEvidenceInvalid/);
+
+ const errorSummaryStart = acceptanceRunner.indexOf('const rendererConsoleErrorCategory = record =>');
+ const errorSummaryEnd = acceptanceRunner.indexOf('\nconst rendererLifecycleDiagnosticSummary', errorSummaryStart);
+ assert.notEqual(errorSummaryStart, -1);
+ assert.notEqual(errorSummaryEnd, -1);
+ const errorSummary = acceptanceRunner.slice(errorSummaryStart, errorSummaryEnd);
+ for (const category of ['currentUserSync', 'apiLoad', 'socketContext', 'reactRuntime', 'other']) {
+ assert.ok(errorSummary.includes(category), category);
+ }
+ assert.match(errorSummary, /consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts\(rendererConsoleErrors\)/);
+ assert.match(errorSummary, /counts\[category\] = Math\.min\(counts\[category\] \+ 1, 9\)/);
+ assert.doesNotMatch(errorSummary, /arguments|location|stack|url/);
+ });
+
+ it('keeps the shared clock subject to exact pairing expiry validation', () => {
+ const timing = packagedAcceptancePairingTiming(authorizePackagedAcceptanceTest(input));
+ assert.ok(timing);
+ const response = {
+ pairingId: `dpr_${'P'.repeat(22)}`,
+ deviceSecret: 'D'.repeat(43),
+ approvalUrl: 'https://propr.example.test/approve',
+ interval: 1,
+ };
+
+ assert.equal(parseDesktopPairingStart({
+ ...response,
+ expiresAt: new Date(PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS + 60_000).toISOString(),
+ }, 'https://propr.example.test', timing.now).expiresAt,
+ '2026-01-02T03:05:05.000Z');
+ assert.throws(() => parseDesktopPairingStart({
+ ...response,
+ expiresAt: PACKAGED_ACCEPTANCE_TIME,
+ }, 'https://propr.example.test', timing.now), (error: unknown) =>
+ error instanceof ProprClientError && error.kind === 'invalid_response');
+ });
+
+ it('preserves only fixed secret-free handshake booleans through protected logging', () => {
+ const evidence = {
+ schemaVersion: 1,
+ path: 'socket-io',
+ transport: 'websocket',
+ resource: 'websocket',
+ scopeQueryPresent: true,
+ scopeQueryCount: 1,
+ scopeEqualsActive: true,
+ activeBindingPresent: true,
+ profileGenerationCurrent: true,
+ originEqualsActive: true,
+ rendererBearerPresent: false,
+ rendererCookiePresent: false,
+ outboundBearerPresent: true,
+ bearerMainInjected: true,
+ accepted: true,
+ rejectionCategory: 'none',
+ };
+ assert.deepEqual(sanitizeDesktopLogFields('desktop.acceptance.websocket_handshake', evidence), evidence);
+ assert.equal(JSON.stringify(evidence).includes('http'), false);
+ assert.equal(JSON.stringify(evidence).includes('Bearer '), false);
+ });
+});
diff --git a/apps/desktop/src/acceptance-test-authorization.ts b/apps/desktop/src/acceptance-test-authorization.ts
new file mode 100644
index 000000000..7ee7c34e0
--- /dev/null
+++ b/apps/desktop/src/acceptance-test-authorization.ts
@@ -0,0 +1,71 @@
+import { basename, isAbsolute, resolve } from 'node:path';
+import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from '../scripts/packaged-acceptance-clock.mjs';
+
+export const PACKAGED_ACCEPTANCE_USER_DATA_PREFIX = 'propr-desktop-acceptance-';
+export const PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS = Object.freeze([
+ 'http://127.0.0.2:41731',
+ 'http://127.0.0.3:41732',
+ 'http://127.0.0.4:41733',
+] as const);
+const ACCEPTANCE_USER_DATA_LEAF = /^propr-desktop-acceptance-[A-Za-z0-9]+$/;
+
+interface AcceptanceAuthorizationInput {
+ argv: readonly string[];
+ defaultUserDataDirectory: string;
+ environmentTriggered: boolean;
+ isPackaged: boolean;
+ platform: NodeJS.Platform;
+}
+
+interface PackagedAcceptancePairingTiming {
+ now(): number;
+ sleep(milliseconds: number, signal?: AbortSignal): Promise;
+}
+
+/**
+ * Acceptance-only main-process fakes are deliberately unreachable from an
+ * ordinary application launch. They require a packaged Linux binary, two
+ * independent triggers, and a fresh, explicitly scoped profile directory.
+ */
+export const authorizePackagedAcceptanceTest = ({
+ argv,
+ defaultUserDataDirectory,
+ environmentTriggered,
+ isPackaged,
+ platform,
+}: AcceptanceAuthorizationInput): string | null => {
+ const argumentTriggered = argv.includes('--propr-acceptance-test');
+ if (!argumentTriggered && !environmentTriggered) return null;
+ if (!argumentTriggered || !environmentTriggered) {
+ throw new Error('Packaged desktop acceptance requires both explicit authorization triggers');
+ }
+ if (!isPackaged || platform !== 'linux') {
+ throw new Error('Packaged desktop acceptance is supported only by the packaged Linux application');
+ }
+ const values = argv
+ .filter(argument => argument.startsWith('--user-data-dir='))
+ .map(argument => argument.slice('--user-data-dir='.length));
+ if (values.length !== 1 || !values[0] || !isAbsolute(values[0])) {
+ throw new Error('Packaged desktop acceptance requires one absolute --user-data-dir');
+ }
+ const requested = resolve(values[0]);
+ if (requested === resolve(defaultUserDataDirectory)
+ || !ACCEPTANCE_USER_DATA_LEAF.test(basename(requested))) {
+ throw new Error(`Packaged desktop acceptance userData must use ${PACKAGED_ACCEPTANCE_USER_DATA_PREFIX}`);
+ }
+ return requested;
+};
+
+/**
+ * Keep the deterministic protocol clock behind the same authorization result
+ * as the other packaged-acceptance-only behavior. A null result covers normal
+ * production, packaged smoke, and ordinary development launches.
+ */
+export const packagedAcceptancePairingTiming = (
+ authorizedUserDataDirectory: string | null,
+): PackagedAcceptancePairingTiming | undefined => authorizedUserDataDirectory === null
+ ? undefined
+ : {
+ now: () => PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS,
+ sleep: async (_milliseconds, _signal) => undefined,
+ };
diff --git a/apps/desktop/src/acceptance-zoom.test.ts b/apps/desktop/src/acceptance-zoom.test.ts
new file mode 100644
index 000000000..ccd5933c5
--- /dev/null
+++ b/apps/desktop/src/acceptance-zoom.test.ts
@@ -0,0 +1,167 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ createPackagedAcceptanceZoomBridge,
+ PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL,
+ PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL,
+ registerPackagedAcceptanceZoomIpc,
+ type PackagedAcceptancePreloadIpc,
+} from './acceptance-zoom';
+
+class FakeIpcMain {
+ readonly listeners = new Map void>();
+ readonly handlers = new Map unknown>();
+
+ on(channel: string, listener: (event: any, request: unknown) => void): void { this.listeners.set(channel, listener); }
+ removeListener(channel: string, listener: (event: any, request: unknown) => void): void {
+ if (this.listeners.get(channel) === listener) this.listeners.delete(channel);
+ }
+ handle(channel: string, listener: (event: any, factor: unknown) => unknown): void { this.handlers.set(channel, listener); }
+ removeHandler(channel: string): void { this.handlers.delete(channel); }
+}
+
+const webContents = () => ({ isDestroyed: () => false });
+
+describe('packaged acceptance Electron zoom authorization', () => {
+ it('registers no IPC capability outside the already-authorized acceptance mode', () => {
+ const ipcMain = new FakeIpcMain();
+ const dispose = registerPackagedAcceptanceZoomIpc({
+ authorized: false,
+ ipcMain: ipcMain as never,
+ webContents: webContents() as never,
+ });
+ assert.equal(ipcMain.listeners.size, 0);
+ assert.equal(ipcMain.handlers.size, 0);
+ dispose();
+ });
+
+ it('binds fixed channels, sender, protocol, and the exact finite factor allowlist', async () => {
+ const ipcMain = new FakeIpcMain();
+ const trusted = webContents();
+ const untrusted = webContents();
+ const dispose = registerPackagedAcceptanceZoomIpc({
+ authorized: true,
+ ipcMain: ipcMain as never,
+ webContents: trusted as never,
+ });
+ assert.deepEqual([...ipcMain.listeners.keys()], [PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL]);
+ assert.deepEqual([...ipcMain.handlers.keys()], [PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL]);
+
+ const authorize = ipcMain.listeners.get(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL)!;
+ const rejected: any = { sender: untrusted, returnValue: undefined };
+ authorize(rejected, { protocolVersion: 1 });
+ assert.equal(rejected.returnValue, null);
+ const malformed: any = { sender: trusted, returnValue: undefined };
+ authorize(malformed, { protocolVersion: 1, extra: true });
+ assert.equal(malformed.returnValue, null);
+ const admitted: any = { sender: trusted, returnValue: undefined };
+ authorize(admitted, { protocolVersion: 1 });
+ assert.deepEqual(admitted.returnValue, { authorized: true, protocolVersion: 1 });
+
+ const apply = ipcMain.handlers.get(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL)!;
+ assert.deepEqual(await apply({ sender: trusted }, 1), { authorized: true, requestedFactor: 1 });
+ assert.deepEqual(await apply({ sender: trusted }, 2), { authorized: true, requestedFactor: 2 });
+ for (const rejectedFactor of [0, 1.5, 3, '2', null, { factor: 2 }]) {
+ assert.throws(() => apply({ sender: trusted }, rejectedFactor), /not allowlisted/);
+ }
+ assert.throws(() => apply({ sender: untrusted }, 2), /sender is not authorized/);
+
+ dispose();
+ dispose();
+ assert.equal(ipcMain.listeners.size, 0);
+ assert.equal(ipcMain.handlers.size, 0);
+ });
+});
+
+describe('packaged acceptance Electron zoom preload bridge', () => {
+ it('does not attest or expose a bridge without the exact acceptance environment', () => {
+ let syncCalls = 0;
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => { syncCalls += 1; return { authorized: true, protocolVersion: 1 }; },
+ invoke: async () => ({ authorized: true, requestedFactor: 1 }),
+ };
+ const frame = { setZoomFactor() {}, getZoomFactor: () => 1 };
+ for (const environmentValue of [undefined, '', '0', 'true', '01', '1 ']) {
+ assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, environmentValue), null);
+ }
+ assert.equal(syncCalls, 0);
+ });
+
+ it('fails closed on an absent or malformed main acknowledgement', () => {
+ const frame = { setZoomFactor() {}, getZoomFactor: () => 1 };
+ for (const acknowledgement of [
+ undefined,
+ null,
+ false,
+ { authorized: false, protocolVersion: 1 },
+ { authorized: true, protocolVersion: 2 },
+ { authorized: true, protocolVersion: 1, extra: true },
+ ]) {
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => acknowledgement,
+ invoke: async () => undefined,
+ };
+ assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, '1'), null);
+ }
+ });
+
+ it('accepts exact environment plus main attestation with real preload argv lacking the main switch', async () => {
+ let current = 2;
+ const calls: number[] = [];
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }),
+ };
+ const originalArgv = process.argv;
+ const originalEnvironment = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST;
+ let bridge: ReturnType;
+ try {
+ process.argv = ['/opt/ProPR Desktop/propr-desktop', '--type=renderer'];
+ process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = '1';
+ bridge = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor(factor) { calls.push(factor); current = factor; },
+ getZoomFactor: () => current,
+ });
+ } finally {
+ process.argv = originalArgv;
+ if (originalEnvironment === undefined) delete process.env.PROPR_DESKTOP_ACCEPTANCE_TEST;
+ else process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = originalEnvironment;
+ }
+ assert.ok(bridge);
+ assert.equal(Object.isFrozen(bridge), true);
+ assert.deepEqual(Object.keys(bridge), ['setZoomFactor']);
+ assert.deepEqual(await bridge.setZoomFactor(2), {
+ requestedFactor: 2, resetFactor: 1, appliedFactor: 2, mechanism: 'electron-web-frame',
+ });
+ assert.deepEqual(calls, [1, 2]);
+ await assert.rejects(bridge.setZoomFactor(1.5 as never), /not allowlisted/);
+ assert.deepEqual(calls, [1, 2]);
+
+ const malformedApply = createPackagedAcceptanceZoomBridge({
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async () => ({ authorized: true, requestedFactor: 2, extra: true }),
+ }, { setZoomFactor() {}, getZoomFactor: () => 1 }, '1');
+ assert.ok(malformedApply);
+ await assert.rejects(malformedApply.setZoomFactor(2), /acknowledgement is malformed/);
+ });
+
+ it('fails closed when reset or application reads back the wrong real webFrame factor', async () => {
+ const ipc: PackagedAcceptancePreloadIpc = {
+ sendSync: () => ({ authorized: true, protocolVersion: 1 }),
+ invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }),
+ };
+ const resetFailure = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor() {}, getZoomFactor: () => 2,
+ }, '1');
+ assert.ok(resetFailure);
+ await assert.rejects(resetFailure.setZoomFactor(2), /zoom reset failed/);
+
+ let reads = 0;
+ const applyFailure = createPackagedAcceptanceZoomBridge(ipc, {
+ setZoomFactor() {}, getZoomFactor: () => (++reads === 1 ? 1 : 1),
+ }, '1');
+ assert.ok(applyFailure);
+ await assert.rejects(applyFailure.setZoomFactor(2), /zoom application failed/);
+ });
+});
+
diff --git a/apps/desktop/src/acceptance-zoom.ts b/apps/desktop/src/acceptance-zoom.ts
new file mode 100644
index 000000000..ee0a581ce
--- /dev/null
+++ b/apps/desktop/src/acceptance-zoom.ts
@@ -0,0 +1,145 @@
+import type { IpcMainInvokeEvent, IpcMainEvent, WebContents } from 'electron';
+
+export const PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL = 'desktop:packaged-acceptance-zoom-authorize';
+export const PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL = 'desktop:packaged-acceptance-zoom-apply';
+export const PACKAGED_ACCEPTANCE_ZOOM_MECHANISM = 'electron-web-frame';
+export const PACKAGED_ACCEPTANCE_ZOOM_FACTORS = Object.freeze([1, 2] as const);
+
+export type PackagedAcceptanceZoomFactor = typeof PACKAGED_ACCEPTANCE_ZOOM_FACTORS[number];
+
+type ZoomAuthorizationRequest = { protocolVersion: 1 };
+type ZoomAuthorizationAcknowledgement = { authorized: true; protocolVersion: 1 };
+type ZoomApplyAcknowledgement = { authorized: true; requestedFactor: PackagedAcceptanceZoomFactor };
+
+type IpcMainLike = {
+ on(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void;
+ removeListener(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void;
+ handle(channel: string, listener: (event: IpcMainInvokeEvent, factor: unknown) => unknown): void;
+ removeHandler(channel: string): void;
+};
+
+export type PackagedAcceptancePreloadIpc = {
+ sendSync(channel: string, request: unknown): unknown;
+ invoke(channel: string, factor: unknown): Promise;
+};
+
+export type PackagedAcceptanceWebFrame = {
+ setZoomFactor(factor: number): void;
+ getZoomFactor(): number;
+};
+
+export type PackagedAcceptanceZoomResult = {
+ requestedFactor: PackagedAcceptanceZoomFactor;
+ resetFactor: 1;
+ appliedFactor: PackagedAcceptanceZoomFactor;
+ mechanism: typeof PACKAGED_ACCEPTANCE_ZOOM_MECHANISM;
+};
+
+const exactKeys = (value: object, expected: string[]): boolean => (
+ Object.keys(value).sort().join('\n') === [...expected].sort().join('\n')
+);
+
+const isAuthorizationRequest = (value: unknown): value is ZoomAuthorizationRequest => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['protocolVersion'])
+ && (value as ZoomAuthorizationRequest).protocolVersion === 1
+);
+
+const isAuthorizationAcknowledgement = (value: unknown): value is ZoomAuthorizationAcknowledgement => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['authorized', 'protocolVersion'])
+ && (value as ZoomAuthorizationAcknowledgement).authorized === true
+ && (value as ZoomAuthorizationAcknowledgement).protocolVersion === 1
+);
+
+const isApplyAcknowledgement = (
+ value: unknown,
+ requestedFactor: PackagedAcceptanceZoomFactor,
+): value is ZoomApplyAcknowledgement => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ && exactKeys(value, ['authorized', 'requestedFactor'])
+ && (value as ZoomApplyAcknowledgement).authorized === true
+ && (value as ZoomApplyAcknowledgement).requestedFactor === requestedFactor
+);
+
+export const isPackagedAcceptanceZoomFactor = (value: unknown): value is PackagedAcceptanceZoomFactor => (
+ value === 1 || value === 2
+);
+
+const preloadHasAcceptanceEnvironment = (environmentValue: string | undefined): boolean => environmentValue === '1';
+
+/**
+ * Register the two fixed authorization routes only after the main process has
+ * completed packaged-acceptance authorization, and bind them to one renderer.
+ */
+export const registerPackagedAcceptanceZoomIpc = ({
+ authorized,
+ ipcMain,
+ webContents,
+}: {
+ authorized: boolean;
+ ipcMain: IpcMainLike;
+ webContents: WebContents;
+}): (() => void) => {
+ if (!authorized) return () => undefined;
+
+ const trustedSender = (sender: WebContents): boolean => sender === webContents && !webContents.isDestroyed();
+ const authorize = (event: IpcMainEvent, request: unknown): void => {
+ if (!trustedSender(event.sender) || !isAuthorizationRequest(request)) {
+ event.returnValue = null;
+ return;
+ }
+ event.returnValue = { authorized: true, protocolVersion: 1 } satisfies ZoomAuthorizationAcknowledgement;
+ };
+ ipcMain.on(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize);
+ ipcMain.handle(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, (event, factor) => {
+ if (!trustedSender(event.sender)) throw new Error('Packaged acceptance zoom sender is not authorized');
+ if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted');
+ return { authorized: true, requestedFactor: factor } satisfies ZoomApplyAcknowledgement;
+ });
+
+ let disposed = false;
+ return () => {
+ if (disposed) return;
+ disposed = true;
+ ipcMain.removeListener(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize);
+ ipcMain.removeHandler(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL);
+ };
+};
+
+/** Build the only acceptance-only renderer capability after a synchronous main-process attestation. */
+export const createPackagedAcceptanceZoomBridge = (
+ ipc: PackagedAcceptancePreloadIpc,
+ frame: PackagedAcceptanceWebFrame,
+ environmentValue: string | undefined = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST,
+): Readonly<{ setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise }> | null => {
+ if (!preloadHasAcceptanceEnvironment(environmentValue)) return null;
+ const authorization = ipc.sendSync(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, { protocolVersion: 1 });
+ if (!isAuthorizationAcknowledgement(authorization)) return null;
+
+ return Object.freeze({
+ async setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise {
+ if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted');
+ const acknowledgement = await ipc.invoke(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, factor);
+ if (!isApplyAcknowledgement(acknowledgement, factor)) {
+ throw new Error('Packaged acceptance zoom authorization acknowledgement is malformed');
+ }
+
+ frame.setZoomFactor(1);
+ const resetFactor = frame.getZoomFactor();
+ if (resetFactor !== 1) throw new Error(`Packaged acceptance zoom reset failed: received ${resetFactor}`);
+ frame.setZoomFactor(factor);
+ const appliedFactor = frame.getZoomFactor();
+ if (appliedFactor !== factor) {
+ throw new Error(`Packaged acceptance zoom application failed: expected ${factor}, received ${appliedFactor}`);
+ }
+ return {
+ requestedFactor: factor,
+ resetFactor: 1,
+ appliedFactor: factor,
+ mechanism: PACKAGED_ACCEPTANCE_ZOOM_MECHANISM,
+ };
+ },
+ });
+};
+
diff --git a/apps/desktop/src/application-menu.test.ts b/apps/desktop/src/application-menu.test.ts
new file mode 100644
index 000000000..9bc1e1983
--- /dev/null
+++ b/apps/desktop/src/application-menu.test.ts
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { configureApplicationMenu } from './application-menu';
+
+describe('desktop application menu', () => {
+ it('removes Electron\'s stock menu from packaged Linux builds', () => {
+ const configuredMenus: null[] = [];
+
+ configureApplicationMenu({
+ setApplicationMenu(menu) {
+ configuredMenus.push(menu);
+ },
+ }, true, 'linux');
+
+ assert.deepEqual(configuredMenus, [null]);
+ });
+
+ it('retains the development menu and native menus on other platforms', () => {
+ for (const scenario of [
+ { isPackaged: false, platform: 'linux' as const },
+ { isPackaged: true, platform: 'darwin' as const },
+ { isPackaged: true, platform: 'win32' as const },
+ ]) {
+ let configured = false;
+ configureApplicationMenu({
+ setApplicationMenu() {
+ configured = true;
+ },
+ }, scenario.isPackaged, scenario.platform);
+ assert.equal(configured, false, JSON.stringify(scenario));
+ }
+ });
+});
+
diff --git a/apps/desktop/src/application-menu.ts b/apps/desktop/src/application-menu.ts
new file mode 100644
index 000000000..8ddf90d35
--- /dev/null
+++ b/apps/desktop/src/application-menu.ts
@@ -0,0 +1,14 @@
+type ApplicationMenuController = {
+ setApplicationMenu: (menu: null) => void;
+};
+
+export const configureApplicationMenu = (
+ menu: ApplicationMenuController,
+ isPackaged: boolean,
+ platform: NodeJS.Platform = process.platform,
+): void => {
+ if (isPackaged && platform === 'linux') {
+ menu.setApplicationMenu(null);
+ }
+};
+
diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts
index 9e6aa97d2..03d0b5059 100644
--- a/apps/desktop/src/credential-service.ts
+++ b/apps/desktop/src/credential-service.ts
@@ -53,10 +53,65 @@ export interface CredentialServiceDependencies {
code: 'network' | 'http' | 'local-cleanup';
status?: number;
}): void;
+ /** Fixed, bounded, secret-free evidence for packaged acceptance. */
+ reportWebSocketHandshake?(evidence: DesktopWebSocketHandshakeEvidence): void;
+ /** Fixed, bounded, secret-free evidence for scoped renderer user validation. */
+ reportCurrentUserValidation?(evidence: DesktopCurrentUserProxyEvidence): void;
/** Main-owned Connect evidence; renderer input can never provide this snapshot. */
snapshotConnectIdentityClaim?(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot;
}
+export type DesktopWebSocketRejectionCategory =
+ | 'none' | 'untrusted-http-origin' | 'wrong-path' | 'wrong-transport'
+ | 'wrong-resource-type' | 'scope-missing' | 'scope-duplicate'
+ | 'scope-malformed' | 'no-active-binding' | 'stale-generation'
+ | 'wrong-origin' | 'stale-scope';
+
+export interface DesktopWebSocketHandshakeEvidence {
+ schemaVersion: 1;
+ path: 'socket-io' | 'other';
+ transport: 'websocket' | 'other';
+ resource: 'websocket' | 'other';
+ scopeQueryPresent: boolean;
+ scopeQueryCount: number;
+ scopeEqualsActive: boolean;
+ activeBindingPresent: boolean;
+ profileGenerationCurrent: boolean;
+ originEqualsActive: boolean;
+ rendererBearerPresent: boolean;
+ rendererCookiePresent: boolean;
+ outboundBearerPresent: boolean;
+ bearerMainInjected: boolean;
+ accepted: boolean;
+ rejectionCategory: DesktopWebSocketRejectionCategory;
+}
+
+export type DesktopCurrentUserProxyRejectionCategory =
+ | 'none' | 'scope-missing' | 'scope-duplicate' | 'scope-malformed'
+ | 'no-active-binding' | 'stale-generation' | 'wrong-origin' | 'stale-scope';
+
+export interface DesktopCurrentUserProxyEvidence {
+ schemaVersion: 2;
+ correlation: 'current-scope-user-validation';
+ requestObserved: true;
+ method: 'get';
+ rendererScopeGeneration: number | null;
+ scopeGenerationQueryCount: 0 | 1 | 2;
+ scopeGenerationQueryValid: boolean;
+ scopeHeaderCount: 0 | 1 | 2;
+ activeBindingPresent: boolean;
+ activeScopeGeneration: number;
+ profileGenerationCurrent: boolean;
+ scopeEqualsActive: boolean;
+ originEqualsActive: boolean;
+ rendererBearerPresent: boolean;
+ rendererCookiePresent: boolean;
+ outboundBearerPresent: boolean;
+ bearerMainInjected: boolean;
+ accepted: boolean;
+ rejectionCategory: DesktopCurrentUserProxyRejectionCategory;
+}
+
export interface DesktopPairingBrowserRequest {
apiBaseUrl: string;
pairingId: string;
@@ -179,6 +234,24 @@ const requestOrigin = (value: string): { origin: string; pathname: string; url:
}
};
+const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+const currentUserScopeGeneration = (url: URL): {
+ count: 0 | 1 | 2;
+ generation: number | null;
+ valid: boolean;
+} => {
+ const values = url.searchParams.getAll(CURRENT_USER_SCOPE_GENERATION_QUERY);
+ const count = Math.min(values.length, 2) as 0 | 1 | 2;
+ if (values.length !== 1 || !/^(?:0|[1-9]\d{0,15})$/.test(values[0])) {
+ return { count, generation: null, valid: false };
+ }
+ const generation = Number(values[0]);
+ const exact = url.search === `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${values[0]}`;
+ return Number.isSafeInteger(generation) && exact
+ ? { count, generation, valid: true }
+ : { count, generation: null, valid: false };
+};
+
const parseCode = async (response: Response): Promise => {
try {
const value = await response.clone().json() as { code?: unknown };
@@ -334,6 +407,8 @@ export class DesktopCredentialService {
readonly #pairingTiming: Pick;
readonly #pairingProtocol: PairingProtocolRequestOptions;
readonly #reportRevocationFailure: NonNullable;
+ readonly #reportWebSocketHandshake: NonNullable;
+ readonly #reportCurrentUserValidation: NonNullable;
readonly #revocationDeadlines: RevocationDeadlines;
readonly #snapshotConnectIdentityClaim: NonNullable;
readonly #internalRequestKey = randomBytes(32).toString('base64url');
@@ -363,6 +438,8 @@ export class DesktopCredentialService {
this.#pairingTiming = dependencies.pairingTiming ?? {};
this.#pairingProtocol = dependencies.pairingProtocol ?? {};
this.#reportRevocationFailure = dependencies.reportRevocationFailure ?? (() => undefined);
+ this.#reportWebSocketHandshake = dependencies.reportWebSocketHandshake ?? (() => undefined);
+ this.#reportCurrentUserValidation = dependencies.reportCurrentUserValidation ?? (() => undefined);
this.#revocationDeadlines = boundedRevocationDeadlines(dependencies.revocationDeadlines);
this.#snapshotConnectIdentityClaim = dependencies.snapshotConnectIdentityClaim ?? (() => ({
status: 'unclaimed',
@@ -1068,10 +1145,8 @@ export class DesktopCredentialService {
): DesktopRequestDecision {
if (this.#closed) return { cancel: true };
const headers = { ...originalHeaders };
- if (/^(?:https?|wss?):/i.test(url)) {
- const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:');
- if (!canonicalProprHttpUrlOrigin(httpUrl)) return { cancel: true };
- }
+ const canonicalTarget = !/^(?:https?|wss?):/i.test(url)
+ || canonicalProprHttpUrlOrigin(url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')) !== null;
const internalHeader = headerName(headers, 'x-propr-desktop-main-request');
const trustedMainRequest = internalHeader !== undefined
&& headers[internalHeader] === this.#internalRequestKey;
@@ -1080,44 +1155,130 @@ export class DesktopCredentialService {
const scopeValues = headerValues(headers, DESKTOP_TRANSPORT_SCOPE_HEADER);
removeHeader(headers, DESKTOP_TRANSPORT_SCOPE_HEADER);
+ const target = requestOrigin(url);
+ const rendererAuthorizationPresent = !trustedMainRequest
+ && headerValues(originalHeaders, 'authorization').length > 0;
+ const rendererCookiePresent = !trustedMainRequest
+ && headerValues(originalHeaders, 'cookie').length > 0;
+ const path = target?.pathname === '/socket.io/' ? 'socket-io' : 'other';
+ const transportValues = target?.url.searchParams.getAll('transport') ?? [];
+ const transport = transportValues.length === 1 && transportValues[0] === 'websocket'
+ ? 'websocket' : 'other';
+ const resource = details.resourceType?.toLowerCase() === 'websocket'
+ || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')
+ ? 'websocket' : 'other';
+ const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? [];
+ const active = this.#active;
+ const activeIsCurrent = active !== null
+ && this.#generation(active.profileId) === active.profileGeneration
+ && this.#selectionGeneration === active.selectionGeneration
+ && active.connectClaim.isCurrent();
+ const scopeEqualsActive = socketScopeValues.length === 1
+ && active !== null && socketScopeValues[0] === active.transportScope;
+ const isSocketCandidate = path === 'socket-io' || socketScopeValues.length > 0;
+ const isCurrentUserRequest = !trustedMainRequest
+ && target?.pathname === '/api/auth/user'
+ && (details.method ?? 'GET').toUpperCase() === 'GET';
+ const rendererCurrentUserGeneration = target && isCurrentUserRequest
+ ? currentUserScopeGeneration(target.url)
+ : { count: 0 as const, generation: null, valid: false };
+ const reportHandshake = (
+ accepted: boolean,
+ rejectionCategory: DesktopWebSocketRejectionCategory,
+ bearerMainInjected = false,
+ ): void => {
+ if (!isSocketCandidate || trustedMainRequest) return;
+ try {
+ this.#reportWebSocketHandshake({
+ schemaVersion: 1, path, transport, resource,
+ scopeQueryPresent: socketScopeValues.length > 0,
+ scopeQueryCount: socketScopeValues.length,
+ scopeEqualsActive,
+ activeBindingPresent: active !== null,
+ profileGenerationCurrent: activeIsCurrent,
+ originEqualsActive: target !== null && active !== null && target.origin === active.origin,
+ rendererBearerPresent: rendererAuthorizationPresent,
+ rendererCookiePresent,
+ outboundBearerPresent: headerValues(headers, 'authorization').length > 0,
+ bearerMainInjected,
+ accepted,
+ rejectionCategory,
+ });
+ } catch {
+ // Acceptance diagnostics cannot alter the authorization decision.
+ }
+ };
+ const reportCurrentUser = (
+ accepted: boolean,
+ rejectionCategory: DesktopCurrentUserProxyRejectionCategory,
+ bearerMainInjected = false,
+ ): void => {
+ if (!isCurrentUserRequest) return;
+ try {
+ this.#reportCurrentUserValidation({
+ schemaVersion: 2,
+ correlation: 'current-scope-user-validation',
+ requestObserved: true,
+ method: 'get',
+ rendererScopeGeneration: rendererCurrentUserGeneration.generation,
+ scopeGenerationQueryCount: rendererCurrentUserGeneration.count,
+ scopeGenerationQueryValid: rendererCurrentUserGeneration.valid,
+ scopeHeaderCount: Math.min(scopeValues.length, 2) as 0 | 1 | 2,
+ activeBindingPresent: active !== null,
+ activeScopeGeneration: active?.profileGeneration ?? 0,
+ profileGenerationCurrent: activeIsCurrent,
+ scopeEqualsActive: scopeValues.length === 1 && active !== null
+ && scopeValues[0] === active.transportScope,
+ originEqualsActive: target !== null && active !== null && target.origin === active.origin,
+ rendererBearerPresent: rendererAuthorizationPresent,
+ rendererCookiePresent,
+ outboundBearerPresent: headerValues(headers, 'authorization').length > 0,
+ bearerMainInjected,
+ accepted,
+ rejectionCategory,
+ });
+ } catch {
+ // Acceptance diagnostics cannot alter the authorization decision.
+ }
+ };
+
// The packaged renderer has no cookie identity on any remote HTTP(S) or
// WS(S) origin. It also cannot supply its own bearer. Main-process bearer
// requests are distinguished by the per-process secret marker above.
removeHeader(headers, 'cookie');
if (!trustedMainRequest) removeHeader(headers, 'authorization');
- const target = requestOrigin(url);
+ if (!canonicalTarget) {
+ reportHandshake(false, 'untrusted-http-origin');
+ return { cancel: true };
+ }
if (target && target.url.protocol === 'http:' && !normalizeApiBaseUrl(target.origin)) {
+ reportHandshake(false, 'untrusted-http-origin');
return { cancel: true };
}
if (trustedMainRequest) return { requestHeaders: headers };
const markedRestRequest = scopeValues.length > 0;
if (markedRestRequest && (scopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(scopeValues[0]))) {
+ reportCurrentUser(false, scopeValues.length !== 1 ? 'scope-duplicate' : 'scope-malformed');
return { cancel: true };
}
if (!trustedMainRequest && target
&& (target.pathname.startsWith('/api/desktop/pairings')
|| target.pathname.startsWith('/api/desktop/tokens'))) return { cancel: true };
- const active = this.#active;
- const activeIsCurrent = active !== null
- && this.#generation(active.profileId) === active.profileGeneration
- && this.#selectionGeneration === active.selectionGeneration
- && active.connectClaim.isCurrent();
const isApiRequest = target?.pathname.startsWith('/api/') === true;
- const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? [];
- const isSocketCandidate = target?.pathname === '/socket.io/' || socketScopeValues.length > 0;
- const isSocketUpgrade = target?.pathname === '/socket.io/'
- && target.url.searchParams.get('transport') === 'websocket'
- && (details.resourceType === 'webSocket'
- || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket'));
+ const isSocketUpgrade = path === 'socket-io' && transport === 'websocket' && resource === 'websocket';
// Session security supplies this ownership bit at the actual WebContents
// boundary. A foreign renderer may load only unmarked credentialless
// resources; it can never exercise a REST/Socket scope or receive a bearer.
if (details.rendererOwned === false) {
- if (markedRestRequest || isSocketCandidate) return { cancel: true };
+ if (markedRestRequest || isSocketCandidate) {
+ reportHandshake(false, 'stale-generation');
+ reportCurrentUser(false, 'stale-generation');
+ return { cancel: true };
+ }
return { requestHeaders: headers };
}
@@ -1125,21 +1286,47 @@ export class DesktopCredentialService {
// The live main renderer must therefore remain pinned to the exact current
// origin even for sanitized traffic that does not carry a transport scope.
if (details.rendererOwned === true && target
- && (!activeIsCurrent || target.origin !== active.origin)) return { cancel: true };
+ && (!activeIsCurrent || target.origin !== active.origin)) {
+ reportHandshake(false, !active ? 'no-active-binding' : !activeIsCurrent ? 'stale-generation' : 'wrong-origin');
+ reportCurrentUser(false, !active ? 'no-active-binding' : !activeIsCurrent ? 'stale-generation' : 'wrong-origin');
+ return { cancel: true };
+ }
+
+ if (isSocketCandidate && !isSocketUpgrade) {
+ reportHandshake(false, path !== 'socket-io'
+ ? 'wrong-path' : transport !== 'websocket' ? 'wrong-transport' : 'wrong-resource-type');
+ return { cancel: true };
+ }
if (isSocketUpgrade && target) {
- if (socketScopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0])
- || !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin
- || socketScopeValues[0] !== active.transportScope) return { cancel: true };
+ const rejection = socketScopeValues.length === 0 ? 'scope-missing'
+ : socketScopeValues.length !== 1 ? 'scope-duplicate'
+ : !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0]) ? 'scope-malformed'
+ : !active ? 'no-active-binding'
+ : !activeIsCurrent || active !== verifiedSocketCredential ? 'stale-generation'
+ : target.origin !== active.origin ? 'wrong-origin'
+ : socketScopeValues[0] !== active.transportScope ? 'stale-scope' : null;
+ if (rejection) {
+ reportHandshake(false, rejection);
+ return { cancel: true };
+ }
+ if (!active) return { cancel: true };
headers.Authorization = `Bearer ${active.token}`;
+ reportHandshake(true, 'none', true);
return { requestHeaders: headers };
}
if (!markedRestRequest) return { requestHeaders: headers };
- if (!target || !isApiRequest || !activeIsCurrent || target.origin !== active.origin
- || scopeValues[0] !== active.transportScope) return { cancel: true };
+ if (!target || !isApiRequest || !active || !activeIsCurrent || target.origin !== active.origin
+ || scopeValues[0] !== active.transportScope) {
+ reportCurrentUser(false, !active ? 'no-active-binding'
+ : !activeIsCurrent ? 'stale-generation'
+ : target?.origin !== active.origin ? 'wrong-origin' : 'stale-scope');
+ return { cancel: true };
+ }
if (details.method?.toUpperCase() === 'OPTIONS') return { requestHeaders: headers };
headers.Authorization = `Bearer ${active.token}`;
+ reportCurrentUser(true, 'none', true);
return { requestHeaders: headers };
}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 4af1e56d1..4c9a8e006 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
import { lstatSync, realpathSync } from 'node:fs';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
-import { app, BrowserWindow, crashReporter, dialog, ipcMain, net, protocol, safeStorage, screen, session, shell } from 'electron';
+import { app, BrowserWindow, crashReporter, dialog, ipcMain, Menu, net, protocol, safeStorage, screen, session, shell } from 'electron';
import type { Rectangle } from 'electron';
import {
DESKTOP_RENDERER_ORIGIN,
@@ -18,10 +18,22 @@ import { createDesktopSetupHost } from '@propr/cli/desktop-local-setup';
import type { SetupActions } from '@propr/local-setup';
import { launchDesktopAuthentication } from './authentication-handoff';
import { DesktopConnectDiscoveryService } from './connect-discovery';
+import { configureApplicationMenu } from './application-menu';
+import {
+ authorizePackagedAcceptanceTest,
+ packagedAcceptancePairingTiming,
+ PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS,
+} from './acceptance-test-authorization';
+import { registerPackagedAcceptanceZoomIpc } from './acceptance-zoom';
import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery';
import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy';
import { clearDesktopInstanceCookies } from './desktop-session';
-import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service';
+import {
+ DesktopCredentialService,
+ type DesktopCurrentUserProxyEvidence,
+ type DesktopPairingBrowserRequest,
+ type DesktopWebSocketHandshakeEvidence,
+} from './credential-service';
import {
registerIpcHandlers,
type DesktopAcceptanceOperation,
@@ -130,9 +142,17 @@ let packagedConnectJourneyDiagnosticState: PackagedConnectJourneyDiagnosticState
const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
let packagedSmokeUserDataDirectory: string | null = null;
+let packagedAcceptanceUserDataDirectory: string | null = null;
let packagedSmokeEvidence: ReturnType = null;
let nativeSmokePhase: NativeSmokePhase | undefined;
try {
+ packagedAcceptanceUserDataDirectory = authorizePackagedAcceptanceTest({
+ argv: process.argv,
+ defaultUserDataDirectory: join(app.getPath('appData'), app.name),
+ environmentTriggered: process.env.PROPR_DESKTOP_ACCEPTANCE_TEST === '1',
+ isPackaged: app.isPackaged,
+ platform: process.platform,
+ });
packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({
argv: process.argv,
defaultUserDataDirectory: join(app.getPath('appData'), app.name),
@@ -162,10 +182,20 @@ try {
packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase);
packagedSmokeEvidence?.write('desktop.smoke.authorized');
}
+ if (packagedAcceptanceUserDataDirectory) {
+ if (packagedSmokeUserDataDirectory) throw new Error('Desktop test modes are mutually exclusive');
+ const acceptanceDirectoryStats = lstatSync(packagedAcceptanceUserDataDirectory);
+ if (!acceptanceDirectoryStats.isDirectory() || acceptanceDirectoryStats.isSymbolicLink()) {
+ throw new Error('Packaged desktop acceptance --user-data-dir must be an existing non-link directory');
+ }
+ app.setPath('userData', packagedAcceptanceUserDataDirectory);
+ }
} catch {
process.exit(1);
}
const packagedSmokeTest = packagedSmokeUserDataDirectory !== null;
+const packagedAcceptanceTest = packagedAcceptanceUserDataDirectory !== null;
+const acceptancePairingTiming = packagedAcceptancePairingTiming(packagedAcceptanceUserDataDirectory);
let mainWindow: BrowserWindow | null = null;
let nativeSmokeWindow: BrowserWindow | null = null;
const initialDeepLink = deepLinkFromArguments(process.argv);
@@ -1191,6 +1221,12 @@ const createMainWindow = async (
const window = new BrowserWindow(
createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged, workArea),
);
+ const disposeAcceptanceZoomIpc = registerPackagedAcceptanceZoomIpc({
+ authorized: packagedAcceptanceTest,
+ ipcMain,
+ webContents: window.webContents,
+ });
+ window.once('closed', disposeAcceptanceZoomIpc);
const readyToShow = new Promise(resolveReady => window.once('ready-to-show', resolveReady));
window.webContents.setWindowOpenHandler(({ url }) => {
@@ -1391,6 +1427,7 @@ if (!hasSingleInstanceLock) {
registerProtocolClient();
void app.whenReady().then(async () => {
+ configureApplicationMenu(Menu, app.isPackaged);
logger = createDesktopLogger(
join(app.getPath('logs'), 'desktop.jsonl'),
() => packagedSmokeEvidence?.write('desktop.log.write_failed'),
@@ -1417,7 +1454,9 @@ if (!hasSingleInstanceLock) {
const smokeProfileOrigin = packagedSmokeTest
? normalizeApiBaseUrl(process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL ?? '')
: null;
- let rendererPolicyOrigins: readonly string[] = transportSmoke
+ let rendererPolicyOrigins: readonly string[] = packagedAcceptanceTest
+ ? PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS
+ : transportSmoke
? [transportSmoke.firstOrigin, transportSmoke.secondOrigin]
: connectSmoke?.journeyEndpoint
? [connectSmoke.journeyEndpoint]
@@ -1501,13 +1540,24 @@ if (!hasSingleInstanceLock) {
fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch,
openPairingBrowser: packagedJourneyApprovals
? packagedJourneyApprovals.open
- : request => openApprovedDesktopPairingUrl(request, shell),
+ : packagedAcceptanceTest
+ ? async () => undefined
+ : request => openApprovedDesktopPairingUrl(request, shell),
clientName: `ProPR Desktop (${process.platform})`,
reportRevocationFailure: diagnostic => {
log('warn', 'desktop.credential_revocation.retry_pending', diagnostic);
},
+ ...(packagedAcceptanceTest ? {
+ reportWebSocketHandshake: (evidence: DesktopWebSocketHandshakeEvidence) => {
+ log('info', 'desktop.acceptance.websocket_handshake', { ...evidence });
+ },
+ reportCurrentUserValidation: (evidence: DesktopCurrentUserProxyEvidence) => {
+ log('info', 'desktop.acceptance.current_user_proxy', { ...evidence });
+ },
+ } : {}),
snapshotConnectIdentityClaim: (profileId, origin) =>
connectDiscovery.snapshotIdentityClaim(profileId, origin),
+ ...(acceptancePairingTiming ? { pairingTiming: acceptancePairingTiming } : {}),
});
const sessionSecurity = configureDesktopSessionSecurity({
contentSecurityPolicy,
@@ -1516,7 +1566,14 @@ if (!hasSingleInstanceLock) {
enableRendererNetworkBoundary: process.platform !== 'win32',
getMainRenderer: () => mainWindow?.webContents ?? null,
isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl),
- ...(connectSmoke?.journeyEndpoint ? {
+ ...(packagedAcceptanceTest ? {
+ reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => {
+ log('info', 'desktop.acceptance.network_permission', { ...evidence });
+ },
+ reportRendererOwnershipDecision: (evidence: DesktopRendererOwnershipEvidence) => {
+ log('info', 'desktop.acceptance.renderer_ownership', { ...evidence });
+ },
+ } : connectSmoke?.journeyEndpoint ? {
reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => {
log('info', 'desktop.renderer.connect_network_permission', { ...evidence });
},
@@ -1558,10 +1615,12 @@ if (!hasSingleInstanceLock) {
}
}
if (app.isPackaged && !rendererPolicyPinnedForSmoke) {
- const current = await credentials.listProfiles();
- const activeOrigin = current.profiles
- .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl;
- rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : [];
+ if (!packagedAcceptanceTest) {
+ const current = await credentials.listProfiles();
+ const activeOrigin = current.profiles
+ .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl;
+ rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : [];
+ }
}
const lifecycle = new LocalLifecycleController();
nativeProfiles = profiles;
@@ -1607,6 +1666,7 @@ if (!hasSingleInstanceLock) {
deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement),
...(app.isPackaged && !rendererPolicyPinnedForSmoke ? {
onRendererActiveProfileChanged: (origin: string | null) => {
+ if (packagedAcceptanceTest) return;
const nextOrigins = origin?.startsWith('http://') ? [origin] : [];
if (rendererPolicyOrigins.length === nextOrigins.length
&& rendererPolicyOrigins.every((value, index) => value === nextOrigins[index])) return;
@@ -1692,7 +1752,7 @@ if (!hasSingleInstanceLock) {
windowsSignerPins: __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__,
}
: undefined;
- if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest) {
+ if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest && !packagedAcceptanceTest) {
const runUpdateCheck = () => {
void checkForSignedUpdates({
config: updateConfig,
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 165e7f187..1da3e0363 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -1,4 +1,5 @@
-import { contextBridge, ipcRenderer } from 'electron';
+import { contextBridge, ipcRenderer, webFrame } from 'electron';
+import { createPackagedAcceptanceZoomBridge } from './acceptance-zoom';
import { createDesktopBridge } from './preload-bridge';
const connectJourneyAcceptance = process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST === '1'
@@ -9,3 +10,12 @@ contextBridge.exposeInMainWorld(
'proprDesktop',
createDesktopBridge(ipcRenderer, undefined, connectJourneyAcceptance),
);
+const acceptanceZoom = createPackagedAcceptanceZoomBridge(ipcRenderer, webFrame);
+if (acceptanceZoom) {
+ contextBridge.exposeInMainWorld('__PROPR_PACKAGED_ACCEPTANCE__', acceptanceZoom);
+ const scenario = process.env.PROPR_DESKTOP_ACCEPTANCE_SCENARIO;
+ contextBridge.exposeInMainWorld(
+ '__PROPR_PACKAGED_ACCEPTANCE_SCENARIO__',
+ scenario === 'setup-error' || scenario === 'setup-complete' ? scenario : 'default',
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index 57379a338..48230a264 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -93,7 +93,9 @@
"@electron/windows-sign": "2.0.6",
"@types/node": "^22.10.0",
"@vitejs/plugin-react": "^4.6.0",
+ "axe-core": "4.10.3",
"electron": "^44.0.0",
+ "playwright": "^1.62.1",
"socket.io": "^4.8.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
@@ -4497,6 +4499,16 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/axe-core": {
+ "version": "4.10.3",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
+ "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/bail": {
"version": "2.0.2",
"license": "MIT",
diff --git a/package.json b/package.json
index 8e6a6e4b0..e52c76782 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,8 @@
"desktop:package": "npm run package -w @propr/desktop",
"desktop:smoke": "npm run smoke:package -w @propr/desktop",
"desktop:smoke:inspect": "npm run smoke:inspect -w @propr/desktop",
+ "desktop:acceptance": "npm run acceptance:package -w @propr/desktop",
+ "desktop:acceptance:verify": "npm run acceptance:verify -w @propr/desktop",
"desktop:make": "npm run make -w @propr/desktop",
"audit:runtime": "npm audit --package-lock-only --omit=dev --audit-level=low",
"desktop:audit:packaging": "npm audit --package-lock-only --workspace=@propr/desktop --include=dev --audit-level=high",
diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx
index 5f64329be..fa72ac5e0 100644
--- a/propr-ui/src/App.tsx
+++ b/propr-ui/src/App.tsx
@@ -1,4 +1,4 @@
-import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
+import React, { lazy, Suspense, useEffect, useState } from 'react'
import { BrowserRouter, HashRouter, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
import Layout from './components/Layout'
import { ToastProvider } from './components/ui/Toast'
@@ -7,7 +7,6 @@ import { useDemoMode } from './contexts/DemoModeContext'
import { DemoModeProvider } from './contexts/DemoModeProvider'
import DemoModeBanner from './components/DemoModeBanner'
import './App.css'
-import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from './api/proprApi'
import { checkProprApiCompatibility, ProprCompatibilityCheckError } from './api/compatibility'
import {
hostedUiConnectionIssue,
@@ -17,13 +16,14 @@ import {
pathWithActiveHostedTunnelFlow,
} from './config/runtimeConfig'
import { AuthProvider, useCurrentUser, userHasPermission } from './contexts/AuthContext'
-import type { CurrentUser, InstancePermission } from './api/proprTypes'
+import type { InstancePermission } from './api/proprTypes'
import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary'
import { ConnectAccountProvider } from './contexts/ConnectAccountContext'
import { BrowserPushProvider } from './hooks/useBrowserPush'
import { NotificationCenterProvider } from './contexts/NotificationCenterContext'
-import { currentUiPathname, isDesktopRuntime, publicAssetUrl } from './config/runtimeMode'
+import { isDesktopRuntime, publicAssetUrl } from './config/runtimeMode'
import { DesktopPresentationBoundary } from './desktop/DesktopPresentationBoundary'
+import { useCurrentUserBootstrap } from './hooks/useCurrentUserBootstrap'
const Router = isDesktopRuntime() ? HashRouter : BrowserRouter;
@@ -44,8 +44,6 @@ const TasksPage = lazy(() => import('./pages/TasksPage'))
type CompatibilityState = { status: 'checking' } | { status: 'ready' } | { status: 'blocked'; title: string; message: string };
-const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000;
-
const LoadingSpinner: React.FC = () => (
@@ -143,87 +141,17 @@ export const NotFoundRouteContent: React.FC<{ hostname?: string }> = ({ hostname
const AppContent: React.FC = () => {
const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode();
- // Auth check state - start loading unless already on login page
- const [isLoading, setIsLoading] = useState(currentUiPathname() !== '/login');
- const [currentUser, setCurrentUser] = useState
(null);
- const refreshPromiseRef = useRef | null>(null);
-
- const refreshCurrentUser = useCallback(async () => {
- if (refreshPromiseRef.current) return refreshPromiseRef.current;
- const request = getCurrentUser()
- .then(user => { setCurrentUser(user); })
- .finally(() => {
- if (refreshPromiseRef.current === request) refreshPromiseRef.current = null;
- });
- refreshPromiseRef.current = request;
- return request;
- }, []);
-
- // Perform initial auth check
- useEffect(() => {
- if (isDemoModeLoading) return;
-
- const checkSession = async () => {
- // Don't check if we are already on login page
- if (currentUiPathname() === '/login') {
- setIsLoading(false);
- return;
- }
-
- try {
- await refreshCurrentUser();
- // Session is valid
- setIsLoading(false);
- } catch (error) {
- // If error is NOT 'Authentication required', we let the app render (to show errors).
- // If it IS 'Authentication required', handleApiResponse handles the redirect,
- // so we keep isLoading=true to prevent UI flash.
- if (error instanceof Error && error.message !== 'Authentication required') {
- setIsLoading(false);
- }
- }
- };
-
- checkSession();
- }, [isDemoModeLoading, refreshCurrentUser]);
-
- useEffect(() => {
- const handleAuthorizationChanged = () => {
- void refreshCurrentUser().catch(error => {
- console.error('Failed to refresh instance authorization:', error);
- });
- };
- window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
- return () => window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged);
- }, [refreshCurrentUser]);
+ const {
+ currentUser,
+ currentUserAbsent,
+ currentUserLoading,
+ isInitialLoading,
+ refreshCurrentUser,
+ } = useCurrentUserBootstrap({ isDemoMode, isDemoModeLoading });
- useEffect(() => {
- if (isDemoMode || currentUiPathname() === '/login') return;
- const refreshAuthorization = () => {
- if (document.visibilityState === 'hidden') return;
- void refreshCurrentUser().catch(error => {
- console.error('Failed to synchronize instance authorization:', error);
- });
- };
- const handleVisibilityChange = () => {
- if (document.visibilityState === 'visible') refreshAuthorization();
- };
- const interval = window.setInterval(refreshAuthorization, AUTHORIZATION_REFRESH_INTERVAL_MS);
- window.addEventListener('focus', refreshAuthorization);
- document.addEventListener('visibilitychange', handleVisibilityChange);
- return () => {
- window.clearInterval(interval);
- window.removeEventListener('focus', refreshAuthorization);
- document.removeEventListener('visibilitychange', handleVisibilityChange);
- };
- }, [isDemoMode, refreshCurrentUser]);
-
-
- // Render spinner while checking auth
- if (isDemoModeLoading || isLoading) return ;
-
- return (
-
+ // Keep the provider mounted for lifecycle attribution, but do not construct a
+ // socket until the active desktop scope has an authenticated REST user.
+ const content = isDemoModeLoading || isInitialLoading ? : (
@@ -360,6 +288,20 @@ const AppContent: React.FC = () => {
+ );
+
+ const disableReasons = {
+ demoModeLoading: isDemoModeLoading,
+ demoMode: isDemoMode,
+ currentUserLoading,
+ currentUserAbsent,
+ };
+ return (
+
+ {content}
);
};
diff --git a/propr-ui/src/api/currentUserResponse.test.ts b/propr-ui/src/api/currentUserResponse.test.ts
new file mode 100644
index 000000000..f68b7a292
--- /dev/null
+++ b/propr-ui/src/api/currentUserResponse.test.ts
@@ -0,0 +1,112 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX } from '../desktop/packagedAcceptanceCurrentUserValidation';
+import { getCurrentUser, isCurrentUserResponse } from './proprApi';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+
+vi.mock('./apiClient', () => ({
+ API_BASE_URL: 'https://example.test',
+ apiFetch,
+ handleApiResponse: vi.fn(async (response: Response) => response),
+}));
+
+type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown };
+
+const validUser = {
+ id: 'user-1',
+ login: 'operator',
+ username: 'operator',
+ displayName: 'Operations',
+ email: null,
+ avatarUrl: null,
+ role: 'admin',
+ permissions: ['instance.manage_settings'],
+ authorizationSource: 'local',
+};
+
+describe('current-user response schema', () => {
+ afterEach(() => {
+ apiFetch.mockReset();
+ delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__;
+ vi.restoreAllMocks();
+ });
+
+ it('accepts the authenticated instance-user contract', () => {
+ expect(isCurrentUserResponse(validUser)).toBe(true);
+ });
+
+ it('rejects missing identity, unknown permissions, and unauthenticated bodies', () => {
+ expect(isCurrentUserResponse({ ...validUser, id: undefined })).toBe(false);
+ expect(isCurrentUserResponse({ ...validUser, permissions: ['instance.unknown'] })).toBe(false);
+ expect(isCurrentUserResponse({ code: 'INVALID_INSTANCE_TOKEN' })).toBe(false);
+ });
+
+ it('reports fixed accepted and rejected parse outcomes for an active scope', async () => {
+ (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = {
+ setZoomFactor: vi.fn(),
+ };
+ const info = vi.spyOn(console, 'info').mockImplementation(() => undefined);
+ apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true })).resolves.toEqual(validUser);
+ expect(info.mock.calls.map(call => call[1])).toEqual([
+ expect.objectContaining({ phase: 'request-issued', scopeGeneration: 3, classification: 'pending' }),
+ expect.objectContaining({ phase: 'response-completed', responseStatus: 200, classification: 'success' }),
+ expect.objectContaining({ phase: 'parsed-user-accepted', scopeGeneration: 3, schemaAccepted: true }),
+ ]);
+ expect(info.mock.calls.every(call => call[0] === PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX)).toBe(true);
+
+ info.mockClear();
+ apiFetch.mockResolvedValueOnce(new Response('{"authenticated":true}', {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+ await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true }))
+ .rejects.toThrow('Current-user response schema was invalid.');
+ expect(info.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({
+ phase: 'parsed-user-rejected', classification: 'invalid-schema', schemaAccepted: false,
+ }));
+ });
+
+ it('preserves the exact hosted-web URL and no-store request contract', async () => {
+ apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser()).resolves.toEqual(validUser);
+
+ expect(apiFetch).toHaveBeenCalledOnce();
+ expect(apiFetch).toHaveBeenCalledWith('https://example.test/api/auth/user', {
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ });
+
+ it('uses a bounded desktop generation cache key without fetch cache inputs', async () => {
+ apiFetch.mockImplementation(async () => new Response(JSON.stringify(validUser), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ }));
+
+ await expect(getCurrentUser({ scopeGeneration: 4, activeScopePresent: true })).resolves.toEqual(validUser);
+ await expect(getCurrentUser({ scopeGeneration: 5, activeScopePresent: true })).resolves.toEqual(validUser);
+ await expect(getCurrentUser({
+ scopeGeneration: Number.MAX_SAFE_INTEGER + 1,
+ activeScopePresent: true,
+ })).resolves.toEqual(validUser);
+
+ expect(apiFetch).toHaveBeenCalledTimes(3);
+ expect(apiFetch.mock.calls.map(([url]) => url)).toEqual([
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=4',
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=5',
+ 'https://example.test/api/auth/user?proprDesktopScopeGeneration=0',
+ ]);
+ for (const [, init] of apiFetch.mock.calls) {
+ expect(init).toEqual({ credentials: 'include' });
+ expect(init).not.toHaveProperty('cache');
+ const headers = new Headers(init?.headers);
+ expect(headers.has('Cache-Control')).toBe(false);
+ expect(headers.has('Pragma')).toBe(false);
+ }
+ });
+});
diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts
index 30cb14a53..d2137f5e3 100644
--- a/propr-ui/src/api/proprApi.ts
+++ b/propr-ui/src/api/proprApi.ts
@@ -2,6 +2,10 @@ import type { Task as ApiTask } from './tasks';
import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient';
import { isHostedUiOrigin, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig';
import { isAccountStatusTimestamp, isProprProxyUrl } from '@propr/shared';
+import {
+ reportPackagedAcceptanceCurrentUser,
+ type PackagedAcceptanceCurrentUserClassification,
+} from '../desktop/packagedAcceptanceCurrentUserValidation';
export * from './apiClient';
@@ -241,10 +245,103 @@ export const deleteTask = async (taskId: string, force?: boolean): Promise
await handleApiResponse(response);
};
-export const getCurrentUser = async (): Promise => {
- const response = await apiFetch(`${API_BASE_URL}/api/auth/user`, { credentials: 'include' });
+export interface CurrentUserValidationOptions {
+ scopeGeneration?: number;
+ activeScopePresent?: boolean;
+}
+
+const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration';
+
+const isNullableString = (value: unknown): value is string | null => value === null || typeof value === 'string';
+
+export const isCurrentUserResponse = (value: unknown): value is CurrentUser => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const user = value as Partial;
+ const permissions = new Set(['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings']);
+ return typeof user.id === 'string'
+ && typeof user.login === 'string'
+ && typeof user.username === 'string'
+ && typeof user.displayName === 'string'
+ && isNullableString(user.email)
+ && isNullableString(user.avatarUrl)
+ && (user.role === 'admin' || user.role === 'member')
+ && Array.isArray(user.permissions)
+ && user.permissions.every(permission => permissions.has(permission))
+ && ['bootstrap', 'local', 'managed', 'implicit', 'demo'].includes(user.authorizationSource ?? '');
+};
+
+const currentUserResponseClassification = async (
+ response: Response,
+): Promise => {
+ if (response.ok) return 'success';
+ if (response.status === 403) return 'forbidden';
+ if (response.status >= 500) return 'server-error';
+ if (response.status !== 401) return 'unauthenticated';
+ try {
+ const body = await response.clone().json() as { code?: unknown };
+ return ['INVALID_INSTANCE_TOKEN', 'INSTANCE_TOKEN_EXPIRED', 'INSTANCE_TOKEN_REVOKED'].includes(String(body.code))
+ ? 'revoked'
+ : 'unauthenticated';
+ } catch {
+ return 'unauthenticated';
+ }
+};
+
+export const getCurrentUser = async (options: CurrentUserValidationOptions = {}): Promise => {
+ const requestedScopeGeneration = options.scopeGeneration;
+ const scopeGeneration = typeof requestedScopeGeneration === 'number'
+ && Number.isSafeInteger(requestedScopeGeneration) && requestedScopeGeneration >= 0
+ ? requestedScopeGeneration
+ : 0;
+ const activeScopePresent = options.activeScopePresent === true;
+ const currentUserUrl = activeScopePresent
+ ? `${API_BASE_URL}/api/auth/user?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${scopeGeneration}`
+ : `${API_BASE_URL}/api/auth/user`;
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'request-issued', scopeGeneration, activeScopePresent,
+ responseStatus: 0, classification: 'pending', schemaAccepted: false,
+ });
+ }
+ const response = await apiFetch(currentUserUrl, activeScopePresent
+ ? { credentials: 'include' }
+ : { credentials: 'include', cache: 'no-store' });
+ const classification = await currentUserResponseClassification(response);
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'response-completed', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification, schemaAccepted: false,
+ });
+ }
await handleApiResponse(response);
- return response.json();
+ let body: unknown;
+ try {
+ body = await response.json();
+ } catch {
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false,
+ });
+ }
+ throw new Error('Current-user response schema was invalid.');
+ }
+ if (!isCurrentUserResponse(body)) {
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false,
+ });
+ }
+ throw new Error('Current-user response schema was invalid.');
+ }
+ if (activeScopePresent) {
+ reportPackagedAcceptanceCurrentUser({
+ phase: 'parsed-user-accepted', scopeGeneration, activeScopePresent,
+ responseStatus: response.status, classification, schemaAccepted: true,
+ });
+ }
+ return body;
};
export const HOSTED_LOGOUT_FAILED_MESSAGE =
diff --git a/propr-ui/src/components/GlobalSearch.tsx b/propr-ui/src/components/GlobalSearch.tsx
index 5c1bcce5d..4370dbc4d 100644
--- a/propr-ui/src/components/GlobalSearch.tsx
+++ b/propr-ui/src/components/GlobalSearch.tsx
@@ -157,6 +157,7 @@ const GlobalSearch: React.FC = ({ inputRef: externalInputRef
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
+ aria-label="Search"
placeholder="Search..."
className="w-full pl-9 sm:pl-12 pr-8 sm:pr-10 py-1.5 sm:py-2 bg-gray-50 border border-slate-300 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 focus:bg-white transition-colors"
/>
diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx
index b419c804f..9a4c019ee 100644
--- a/propr-ui/src/components/Layout.tsx
+++ b/propr-ui/src/components/Layout.tsx
@@ -187,7 +187,7 @@ const Layout: React.FC = ({ children }) => {
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
-
+