From f152101010e4a48f3c99bc369028f029fce2cbe6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:34 -0700 Subject: [PATCH] ci: reuse prepared Windows security addon in PR packages (#29352) * ci: reuse prepared Windows security addon in PR packages * ci: prepare bounded Windows security reuse qualification * ci: ignore owned Windows benchmark receipts during source checks * test: inject Windows security qualification dependencies * test(mobile): register chat view mocks in test roots --- .github/workflows/node-server-tests.yml | 3 + .github/workflows/ssh-windows-hosts.yml | 2 + .../windows-native-preparation-benchmark.yml | 13 + .gitignore | 1 + .../benchmark-windows-native-preparation.mjs | 2 +- .../benchmark-windows-security-reuse.mjs | 439 ++++++++++++++++++ config/scripts/build-orcad.mjs | 5 +- .../scripts/build-windows-security-addon.mjs | 111 ++++- .../build-windows-security-addon.test.mjs | 245 ++++++++++ config/scripts/orcad-windows-security.mjs | 21 +- .../scripts/orcad-windows-security.test.mjs | 84 +++- ...dows-native-preparation-benchmark.test.mjs | 44 +- .../windows-security-reuse-benchmark.test.mjs | 399 ++++++++++++++++ ...MobileNativeChatView.composer-lock.test.ts | 31 ++ ...obileNativeChatView.failure-banner.test.ts | 31 ++ ...bileNativeChatView.harness.test-fixture.ts | 81 +--- ...obileNativeChatView.history-paging.test.ts | 31 ++ ...ativeChatView.render-stubs.test-fixture.ts | 54 +++ .../session/MobileNativeChatView.rows.test.ts | 33 +- .../session/MobileNativeChatView.stop.test.ts | 33 +- .../MobileNativeChatView.tail-follow.test.ts | 31 ++ 21 files changed, 1599 insertions(+), 95 deletions(-) create mode 100644 config/scripts/benchmark-windows-security-reuse.mjs create mode 100644 config/scripts/build-windows-security-addon.test.mjs create mode 100644 config/scripts/windows-security-reuse-benchmark.test.mjs create mode 100644 mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 41a987b92ae..f0956770247 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -151,6 +151,7 @@ jobs: ref: ${{ inputs.ref }} persist-credentials: false - uses: ./.github/actions/install-node-dependencies + id: dependencies with: native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} cache-pnpm-store: ${{ runner.os != 'Windows' }} @@ -191,6 +192,8 @@ jobs: with: arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }} - run: pnpm build:orcad + env: + ORCA_REUSE_PREPARED_WINDOWS_SECURITY: ${{ github.event_name == 'pull_request' && runner.os == 'Windows' && steps.dependencies.outcome == 'success' }} - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} env: diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 4b5bb1ce5f2..7715031fe93 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -161,6 +161,8 @@ jobs: arches: ${{ matrix.arch }} - name: Build the win32 template shell: bash + env: + ORCA_REUSE_PREPARED_WINDOWS_SECURITY: ${{ github.event_name == 'pull_request' && steps.dependencies.outcome == 'success' }} run: | pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" diff --git a/.github/workflows/windows-native-preparation-benchmark.yml b/.github/workflows/windows-native-preparation-benchmark.yml index 477fff42c56..845628d7f7a 100644 --- a/.github/workflows/windows-native-preparation-benchmark.yml +++ b/.github/workflows/windows-native-preparation-benchmark.yml @@ -3,6 +3,11 @@ name: Windows native preparation comparison on: workflow_dispatch: inputs: + comparison: + description: Bounded comparison to run on the existing Windows slots. + type: choice + default: native-preparation + options: [native-preparation, windows-security] source_sha: description: Exact commit containing this benchmark and the Windows compiler candidate. required: true @@ -49,11 +54,19 @@ jobs: cache-pnpm-store: 'false' cache-pnpm-verification: 'false' - name: Compare isolated cold and same-build warm preparation + if: inputs.comparison != 'windows-security' shell: bash env: SOURCE_SHA: ${{ inputs.source_sha }} BENCHMARK_RUNNER: ${{ matrix.os }} run: node config/scripts/benchmark-windows-native-preparation.mjs + - name: Compare warm Windows security builds and qualify fallback + if: inputs.comparison == 'windows-security' + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + BENCHMARK_RUNNER: ${{ matrix.os }} + run: node config/scripts/benchmark-windows-security-reuse.mjs - uses: actions/upload-artifact@v7 if: always() with: diff --git a/.gitignore b/.gitignore index 613bbf6f9bb..c986978b376 100644 --- a/.gitignore +++ b/.gitignore @@ -176,5 +176,6 @@ ci-shards/ # Paired CI timing reports and temporary baseline plugin. .build/preflight-benchmark/ +/.build/windows-native-benchmark/ .local/static-analysis-research/ diff --git a/config/scripts/benchmark-windows-native-preparation.mjs b/config/scripts/benchmark-windows-native-preparation.mjs index 336e6fbbd26..9d0124a0f83 100644 --- a/config/scripts/benchmark-windows-native-preparation.mjs +++ b/config/scripts/benchmark-windows-native-preparation.mjs @@ -122,7 +122,7 @@ function digestFiles(root, paths) { ) } -function treeDigest(root) { +export function treeDigest(root) { const digest = createHash('sha256') const files = [] const visit = (path) => { diff --git a/config/scripts/benchmark-windows-security-reuse.mjs b/config/scripts/benchmark-windows-security-reuse.mjs new file mode 100644 index 00000000000..10e17b9757c --- /dev/null +++ b/config/scripts/benchmark-windows-security-reuse.mjs @@ -0,0 +1,439 @@ +import assert from 'node:assert/strict' +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' +import { runProcess, runProcessSync } from '@orca/process-host' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { + buildWindowsSecurityAddon, + probeWindowsSecurityAddon +} from './build-windows-security-addon.mjs' +import { assertNoHeaderDownloads, treeDigest } from './benchmark-windows-native-preparation.mjs' +import { ensurePinnedNodeExecutable, preparePinnedNodeDir } from './pinned-node-downloads.mjs' +import { sha256Of } from './orcad-prebuild-slot-contents.mjs' +import { hash } from './windows-native-benchmark-observer.mjs' + +const ROOT = resolve(import.meta.dirname, '../..') +const { PE_MACHINE, readPeMachine } = createRequire(import.meta.url)('./windows-pe-machine.cjs') +const invalidCandidate = Buffer.from('orca-windows-security-qualification-invalid') +const preparedPath = (root) => + join(root, 'native/windows-security/build/Release/orca_windows_security.node') + +export function assertSecurityComparisonHost({ + platform = process.platform, + arch = process.arch, + version = process.versions.node, + electron = process.versions.electron, + sourceSha = process.env.SOURCE_SHA, + runner = process.env.BENCHMARK_RUNNER +} = {}) { + assert.equal(platform, 'win32', 'Security comparison requires Windows') + assert(!electron, 'Security comparison requires plain Node') + assert.equal( + { 'windows-2022': 'x64', 'windows-11-arm': 'arm64' }[runner], + arch, + 'Runner architecture mismatch' + ) + assert(['x64', 'arm64'].includes(arch), 'Unsupported architecture') + assert.equal(version, NODE_RUNTIME_PIN.version, 'Build Node differs from pin') + assert(/^[0-9a-f]{40}$/.test(sourceSha ?? ''), 'SOURCE_SHA must be exact') +} + +export function assertSecurityCommand(result) { + assert( + result.code === 0 && !result.signal && !result.timedOut && !result.outputTruncated, + 'Command failed or produced incomplete output' + ) +} + +export function probeSecurityArtifact( + path, + runtimePath, + arch, + run = runProcessSync, + runtimeAssets = NODE_RUNTIME_ASSETS +) { + const machine = readPeMachine(path) + assert.equal(machine, PE_MACHINE[arch], 'Security artifact architecture mismatch') + const sha256 = sha256Of(path) + assert.equal( + sha256Of(runtimePath), + runtimeAssets[`win32-${arch}`]?.executableSha256, + 'Runtime hash mismatch' + ) + const result = probeWindowsSecurityAddon(runtimePath, resolve(path), arch, run) + assertSecurityCommand(result) + assert.equal(result.stdout, 'windows-security-ready', 'Security operation failed') + assert.equal(sha256Of(path), sha256, 'Security artifact changed during probe') + return { sha256, machine, currentUserSid: 'passed' } +} + +export async function withInvalidSecurityCandidate(candidate, backupDir, expectedHash, action) { + const stat = lstatSync(candidate) + assert(stat.isFile() && !stat.isSymbolicLink(), 'Candidate must be an owned regular file') + assert.equal(sha256Of(candidate), expectedHash, 'Prepared candidate changed before backup') + const backup = join(backupDir, 'prepared-security-backup.node') + writeFileSync(backup, readFileSync(candidate), { flag: 'wx', mode: 0o600 }) + assert.equal(sha256Of(backup), expectedHash, 'Candidate backup mismatch') + try { + writeFileSync(candidate, invalidCandidate) + assert.equal(sha256Of(candidate), hash(invalidCandidate), 'Invalidation failed') + return await action() + } finally { + copyFileSync(backup, candidate) + chmodSync(candidate, stat.mode) + assert.equal(sha256Of(candidate), expectedHash, 'Candidate restoration failed') + utimesSync(candidate, stat.atimeMs / 1000, stat.mtimeMs / 1000) + } +} + +export function runSecurityComparisonSample({ + root = ROOT, + variant, + arch = process.arch, + runtimePath, + output, + receiptFile, + run = runProcessSync, + runtimeAssets = NODE_RUNTIME_ASSETS +}) { + assert(['control', 'candidate', 'fallback'].includes(variant), 'Unknown sample') + const receipt = { variant, qualification: 'incomplete', stage: 'initial-validation', calls: [] } + const save = () => writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`) + const observed = (spec) => { + const phase = spec.args?.includes('rebuild') + ? 'compiler' + : spec.args?.includes('--check-only') + ? 'preparation-probe' + : 'security-probe' + const started = performance.now() + const call = { phase, complete: false } + receipt.stage = phase + receipt.calls.push(call) + try { + const result = run({ + ...spec, + stdio: 'pipe', + timeoutMs: spec.timeoutMs ?? 480_000, + maxOutputBytes: 64 * 1024 * 1024 + }) + Object.assign(call, { + code: result.code, + signal: result.signal, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutSha256: hash(result.stdout), + stderrSha256: hash(result.stderr) + }) + assertSecurityCommand(result) + assertNoHeaderDownloads(result) + call.complete = true + return result + } finally { + call.durationMs = performance.now() - started + save() + } + } + try { + assert.equal(arch, process.arch, 'Sample architecture differs from host') + assert.equal( + sha256Of(runtimePath), + runtimeAssets[`win32-${arch}`]?.executableSha256, + 'Runtime hash mismatch' + ) + receipt.stage = 'build' + const start = performance.now() + buildWindowsSecurityAddon(arch, output, { + root, + run: observed, + enabled: variant !== 'control', + runtimePath, + runtimeAssets + }) + receipt.buildIncludingReuseProbesMs = performance.now() - start + receipt.staged = probeSecurityArtifact(output, runtimePath, arch, observed, runtimeAssets) + receipt.compilerInvocationCount = receipt.calls.filter( + (call) => call.phase === 'compiler' + ).length + receipt.stage = 'validate-observations' + receipt.compilerCountMeaning = + 'Actual node-gyp rebuild subprocess calls; each uses the builder fresh work directory.' + assert.equal( + receipt.compilerInvocationCount, + variant === 'candidate' ? 0 : 1, + 'Unexpected compiler invocation count' + ) + assert( + receipt.calls.every((call) => call.complete), + 'A rejected/incomplete command was hidden by fallback' + ) + receipt.qualification = 'passed' + return receipt + } catch { + receipt.qualification = 'failed' + receipt.failureStage = receipt.stage + throw new Error('Windows security sample did not qualify') + } finally { + receipt.compilerInvocationCount = receipt.calls.filter( + (call) => call.phase === 'compiler' + ).length + save() + } +} + +export async function benchmarkWindowsSecurityReuse( + root = ROOT, + { + run = runProcess, + runSync = runProcessSync, + ensureRuntime = ensurePinnedNodeExecutable, + prepareHeaders = preparePinnedNodeDir, + runtimeAssets = NODE_RUNTIME_ASSETS, + hashFile = sha256Of + } = {} +) { + assertSecurityComparisonHost() + const arch = process.arch + const target = `win32-${arch}` + const output = join(root, '.build/windows-native-benchmark/security') + mkdirSync(output, { recursive: true }) + const temporary = mkdtempSync( + join(process.env.RUNNER_TEMP || tmpdir(), 'orca-security-comparison-') + ) + const report = { + schemaVersion: 1, + qualification: 'incomplete', + startedAt: new Date().toISOString(), + phases: [], + samples: [], + scope: + 'One same-run warm control/candidate pair and one invalid-candidate fallback; no CI occupancy or cache-hit-rate claim.', + sourceSha: process.env.SOURCE_SHA, + arch, + runner: process.env.BENCHMARK_RUNNER, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + workflowSha: process.env.GITHUB_WORKFLOW_SHA, + preparation: + 'Source-built in this run; no native cache restore. Headers and runtime preparation precede all measured samples.' + } + const save = () => + writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`) + let environment = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + const command = async (label, program, args) => { + report.stage = label + const start = performance.now() + const result = await run({ + program, + args, + cwd: root, + env: environment, + timeoutMs: 1_200_000, + maxOutputBytes: 64 * 1024 * 1024, + terminationBarrier: true + }) + report.phases.push({ + label, + durationMs: performance.now() - start, + code: result.code, + signal: result.signal, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutSha256: hash(result.stdout), + stderrSha256: hash(result.stderr) + }) + save() + assertSecurityCommand(result) + return result + } + const sourceCheck = async () => { + const head = await command('source-head', 'git', ['rev-parse', 'HEAD']) + assert.equal(head.stdout.trim(), process.env.SOURCE_SHA, 'Source commit mismatch') + await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--']) + const untracked = await command('source-untracked', 'git', [ + 'ls-files', + '--others', + '--exclude-standard' + ]) + assert.equal(untracked.stdout, '', 'Untracked source prevents exact comparison') + } + try { + await sourceCheck() + report.stage = 'verify-build-node' + assert.equal( + hashFile(process.execPath), + runtimeAssets[target].executableSha256, + 'Build Node hash mismatch' + ) + const sourceFiles = ( + await command('security-source-files', 'git', ['ls-files', '--', 'native/windows-security']) + ).stdout + .trim() + .split(/\r?\n/) + const identityFiles = [ + ...sourceFiles, + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'package.json', + '.github/actions/prepare-native-runtime/action.yml', + 'src/shared/node-runtime-pin.ts', + 'config/scripts/build-windows-security-addon.mjs', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/benchmark-windows-security-reuse.mjs' + ] + report.sourceHashes = Object.fromEntries( + identityFiles.map((path) => [path, sha256Of(join(root, path))]) + ) + report.sourceInputsSha256 = hash(JSON.stringify(report.sourceHashes)) + report.tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim() + // This mode owns the candidate because its preparation creates it in a fresh checkout. + const candidate = preparedPath(root) + report.stage = 'verify-fresh-checkout' + assert(!existsSync(candidate), 'Security comparison requires a fresh unprepared checkout') + report.stage = 'prepare-pinned-runtime-and-headers' + const preparationStart = performance.now() + const runtimePath = await ensureRuntime({ target }) + const nodeDir = await prepareHeaders({ target, workDir: join(temporary, 'headers') }) + environment = { + ...environment, + npm_config_nodedir: nodeDir, + npm_package_config_node_gyp_nodedir: nodeDir + } + report.downloadAndHeaderPreparationMs = performance.now() - preparationStart + report.runtime = { + version: NODE_RUNTIME_PIN.version, + sha256: sha256Of(runtimePath), + buildNodeSha256: hashFile(process.execPath) + } + report.headers = treeDigest(nodeDir) + await command('prepare-node-artifacts', process.execPath, [ + join(root, 'config/scripts/initialize-windows-native-toolchain.mjs') + ]) + await command('verify-prepared-node', process.execPath, [ + join(root, 'config/scripts/ensure-native-runtime.mjs'), + '--check-only' + ]) + report.stage = 'verify-prepared-candidate-ownership' + assert.equal( + realpathSync(candidate).toLowerCase(), + preparedPath(realpathSync(root)).toLowerCase(), + 'Candidate escapes owned checkout' + ) + assert( + lstatSync(candidate).isFile() && !lstatSync(candidate).isSymbolicLink(), + 'Prepared candidate must be regular' + ) + report.prepared = probeSecurityArtifact(candidate, runtimePath, arch, runSync, runtimeAssets) + const sample = async (variant) => { + await sourceCheck() + const receiptFile = join(output, `${variant}.json`) + const result = await command(`${variant}-full-build-and-probe`, process.execPath, [ + import.meta.filename, + `--sample=${variant}`, + runtimePath, + join(output, `${variant}.node`), + receiptFile + ]) + assertNoHeaderDownloads(result) + const receipt = JSON.parse(readFileSync(receiptFile, 'utf8')) + assert.equal(receipt.qualification, 'passed', 'Sample did not qualify') + assert.deepEqual( + treeDigest(nodeDir), + report.headers, + 'Measured build changed prepared headers' + ) + if (variant === 'candidate') { + assert.equal( + receipt.staged.sha256, + report.prepared.sha256, + 'Reuse did not copy prepared bytes' + ) + } + report.samples.push({ ...receipt, fullCommandMs: report.phases.at(-1).durationMs }) + save() + await sourceCheck() + } + // Warm compiler discovery and local build inputs before measuring the pair. + await command('warm-compiler-before-measurement', process.execPath, [ + join(root, 'config/scripts/build-windows-security-addon.mjs'), + `--arch=${arch}` + ]) + await sample('control') + await sample('candidate') + assert.equal( + sha256Of(candidate), + report.prepared.sha256, + 'Prepared candidate changed during pair' + ) + report.stage = 'invalidate-owned-candidate' + await withInvalidSecurityCandidate(candidate, output, report.prepared.sha256, async () => { + report.invalidCandidateSha256 = sha256Of(candidate) + await sample('fallback') + }) + report.restoredPreparedSha256 = sha256Of(candidate) + assert.equal( + report.restoredPreparedSha256, + report.prepared.sha256, + 'Prepared bytes were not restored' + ) + await sourceCheck() + const [control, candidateSample] = report.samples + report.comparison = { + controlMs: control.fullCommandMs, + candidateMs: candidateSample.fullCommandMs, + candidateMinusControlMs: candidateSample.fullCommandMs - control.fullCommandMs, + limitation: + 'One fixed-order same-run pair per architecture; descriptive only, not a significance or job-time estimate.' + } + report.qualification = 'passed' + } catch { + report.qualification = 'failed' + report.failureStage = report.stage + throw new Error('Windows security comparison did not qualify') + } finally { + report.finishedAt = new Date().toISOString() + report.restored = report.prepared + ? existsSync(preparedPath(root)) && sha256Of(preparedPath(root)) === report.prepared.sha256 + : null + save() + rmSync(temporary, { recursive: true, force: true }) + } + return report +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const variant = process.argv + .find((arg) => arg.startsWith('--sample=')) + ?.slice('--sample='.length) + if (variant) { + assertSecurityComparisonHost() + runSecurityComparisonSample({ + variant, + runtimePath: process.argv[3], + output: process.argv[4], + receiptFile: process.argv[5] + }) + } else { + await benchmarkWindowsSecurityReuse() + } + } catch { + console.error('[windows-security-comparison] failed; inspect the redacted receipt') + process.exitCode = 1 + } +} diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 93f10ca48b9..76b83c2445b 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -165,7 +165,10 @@ stageNodePty(BUILD_TARGET) await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET) -stageOrcadWindowsSecurity(ROOT, OUT_DIR, BUILD_TARGET) +stageOrcadWindowsSecurity(ROOT, OUT_DIR, BUILD_TARGET, undefined, undefined, { + enabled: process.env.ORCA_REUSE_PREPARED_WINDOWS_SECURITY === 'true', + runtimePath: nodeRuntimePath +}) const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) copyFileSync( diff --git a/config/scripts/build-windows-security-addon.mjs b/config/scripts/build-windows-security-addon.mjs index 8ea2e690f94..9fd3177c5fb 100644 --- a/config/scripts/build-windows-security-addon.mjs +++ b/config/scripts/build-windows-security-addon.mjs @@ -12,9 +12,11 @@ */ import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' -import { join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { runProcessSync } from '@orca/process-host' +import { NODE_RUNTIME_ASSETS } from '../../src/shared/node-runtime-pin.ts' +import { sha256Of } from './orcad-prebuild-slot-contents.mjs' import { ORCAD_WINDOWS_SECURITY_FILENAME } from '../../src/shared/orcad-artifacts.ts' const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( @@ -23,13 +25,96 @@ const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.me const ROOT = resolve(import.meta.dirname, '..', '..') const SUPPORTED_ARCHES = ['x64', 'arm64'] -const PACKAGE_DIR = join(ROOT, 'native', 'windows-security') export function windowsSecurityAddonOutput(root, arch) { return join(root, '.build', 'windows-security', arch, ORCAD_WINDOWS_SECURITY_FILENAME) } -export function buildWindowsSecurityAddon(arch, output = windowsSecurityAddonOutput(ROOT, arch)) { +export function probeWindowsSecurityAddon( + runtimePath, + candidate, + arch, + run = runProcessSync, + cwd = ROOT +) { + return run({ + program: runtimePath, + args: [ + '-e', + `if (process.platform !== 'win32' || process.arch !== ${JSON.stringify(arch)} || process.versions.electron) process.exit(1);` + + `const sid = require(${JSON.stringify(candidate)}).currentUserSid();` + + `if (typeof sid !== 'string' || !/^S-1-\\d+(?:-\\d+)+$/.test(sid)) process.exit(2);` + + `process.stdout.write('windows-security-ready');` + ], + cwd, + timeoutMs: 30_000 + }) +} + +export function reusePreparedWindowsSecurityAddon( + arch, + output, + { + enabled = false, + runtimePath, + root = ROOT, + platform = process.platform, + hostArch = process.arch, + runtimeAssets = NODE_RUNTIME_ASSETS, + run = runProcessSync + } = {} +) { + if (!enabled || platform !== 'win32' || arch !== hostArch || !runtimePath) { + return false + } + const candidate = resolve( + root, + 'native/windows-security/build/Release/orca_windows_security.node' + ) + try { + if ( + readPeMachine(candidate) !== PE_MACHINE[arch] || + !isAbsolute(runtimePath) || + sha256Of(runtimePath) !== runtimeAssets[`win32-${arch}`]?.executableSha256 + ) { + return false + } + const prepared = run({ + program: process.execPath, + args: [ + join(root, 'config/scripts/ensure-native-runtime.mjs'), + '--runtime=node', + '--check-only' + ], + cwd: root, + timeoutMs: 30_000 + }) + if (prepared.code !== 0 || prepared.timedOut || prepared.outputTruncated) { + return false + } + const probe = probeWindowsSecurityAddon(runtimePath, candidate, arch, run, root) + if ( + probe.code !== 0 || + probe.timedOut || + probe.outputTruncated || + probe.stdout !== 'windows-security-ready' + ) { + return false + } + } catch { + return false + } + mkdirSync(resolve(output, '..'), { recursive: true }) + copyFileSync(candidate, output) + console.log(`[windows-security] reused prepared ${arch} -> ${output}`) + return true +} + +export function buildWindowsSecurityAddon( + arch, + output = windowsSecurityAddonOutput(ROOT, arch), + { root = ROOT, run = runProcessSync, ...options } = {} +) { if (!SUPPORTED_ARCHES.includes(arch)) { throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`) } @@ -38,16 +123,19 @@ export function buildWindowsSecurityAddon(arch, output = windowsSecurityAddonOut `The Windows security addon only builds on Windows; running on ${process.platform}.` ) } + if (reusePreparedWindowsSecurityAddon(arch, output, { ...options, root, run })) { + return output + } // Inside the repo so binding.gyp's `require('node-addon-api')` resolves from root node_modules. - const workDir = join(ROOT, '.build', 'windows-security', arch, 'gyp') + const workDir = join(root, '.build', 'windows-security', arch, 'gyp') rmSync(workDir, { recursive: true, force: true }) mkdirSync(workDir, { recursive: true }) - copyFileSync(join(PACKAGE_DIR, 'binding.gyp'), join(workDir, 'binding.gyp')) - cpSync(join(PACKAGE_DIR, 'src'), join(workDir, 'src'), { recursive: true }) - const result = runProcessSync({ + copyFileSync(join(root, 'native/windows-security/binding.gyp'), join(workDir, 'binding.gyp')) + cpSync(join(root, 'native/windows-security/src'), join(workDir, 'src'), { recursive: true }) + const result = run({ program: process.execPath, args: [ - join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), + join(root, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}` ], @@ -77,7 +165,12 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { try { const arch = process.argv.find((arg) => arg.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch - buildWindowsSecurityAddon(arch) + buildWindowsSecurityAddon(arch, undefined, { + enabled: process.argv.includes('--reuse-prepared-runtime'), + runtimePath: process.argv + .find((arg) => arg.startsWith('--node-runtime=')) + ?.slice('--node-runtime='.length) + }) } catch (error) { console.error(`[windows-security] ${error instanceof Error ? error.message : String(error)}`) process.exit(1) diff --git a/config/scripts/build-windows-security-addon.test.mjs b/config/scripts/build-windows-security-addon.test.mjs new file mode 100644 index 00000000000..e03aedc62c0 --- /dev/null +++ b/config/scripts/build-windows-security-addon.test.mjs @@ -0,0 +1,245 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { afterEach, expect, it, vi } from 'vitest' +import { + buildWindowsSecurityAddon, + reusePreparedWindowsSecurityAddon +} from './build-windows-security-addon.mjs' +import { peImage } from './windows-pe-image-fixture.mjs' + +const roots = [] +afterEach(() => { + vi.unstubAllGlobals() + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +const success = { code: 0, timedOut: false, outputTruncated: false, stdout: '' } +function fixture(arch = 'x64') { + const root = mkdtempSync(join(tmpdir(), 'windows-security-build-')) + roots.push(root) + const candidate = join(root, 'native/windows-security/build/Release/orca_windows_security.node') + mkdirSync(dirname(candidate), { recursive: true }) + writeFileSync(candidate, peImage({ arch })) + mkdirSync(join(root, 'native/windows-security/src')) + writeFileSync(join(root, 'native/windows-security/binding.gyp'), '{}') + writeFileSync(join(root, 'native/windows-security/src/addon.cc'), 'source') + const runtimePath = join(root, 'pinned node.exe') + writeFileSync(runtimePath, 'pinned Node fixture') + const executableSha256 = createHash('sha256').update('pinned Node fixture').digest('hex') + const runtimeAssets = { [`win32-${arch}`]: { executableSha256 } } + const output = join(root, 'output/windows-security.node') + const run = vi.fn((invocation) => { + if (invocation.args.includes('rebuild')) { + const built = join(invocation.cwd, 'build/Release/orca_windows_security.node') + mkdirSync(dirname(built), { recursive: true }) + writeFileSync( + built, + peImage({ arch: invocation.args.find((arg) => arg.startsWith('--arch=')).slice(7) }) + ) + return success + } + return { + ...success, + stdout: invocation.program === runtimePath ? 'windows-security-ready' : '' + } + }) + return { + candidate, + output, + options: { + enabled: true, + root, + runtimePath, + platform: 'win32', + hostArch: arch, + runtimeAssets, + run + } + } +} + +it.each(['x64', 'arm64'])('reuses the exact prepared %s candidate under pinned Node', (arch) => { + const { candidate, output, options } = fixture(arch) + expect(reusePreparedWindowsSecurityAddon(arch, output, options)).toBe(true) + expect(readFileSync(output)).toEqual(readFileSync(candidate)) + const [preparation, probe] = options.run.mock.calls.map(([invocation]) => invocation) + expect(preparation).toEqual({ + program: process.execPath, + args: [ + join(options.root, 'config/scripts/ensure-native-runtime.mjs'), + '--runtime=node', + '--check-only' + ], + cwd: options.root, + timeoutMs: 30_000 + }) + expect(probe.program).toBe(options.runtimePath) + expect(isAbsolute(probe.program)).toBe(true) + expect(probe.timeoutMs).toBe(30_000) + const requireAddon = vi.fn(() => ({ currentUserSid: () => 'S-1-5-21-123-456-789-1001' })) + const write = vi.fn() + runInNewContext(probe.args[1], { + process: { platform: 'win32', arch, versions: {}, stdout: { write } }, + require: requireAddon + }) + expect(requireAddon).toHaveBeenCalledWith(candidate) + expect(isAbsolute(candidate)).toBe(true) + expect(write).toHaveBeenCalledWith('windows-security-ready') +}) + +it.each([ + { enabled: false }, + { enabled: undefined }, + { platform: 'darwin' }, + { platform: 'linux' }, + { hostArch: 'arm64' }, + { runtimePath: undefined }, + { runtimePath: 'node.exe' } +])('declines reuse without same-host opt-in and pinned runtime: %j', (overrides) => { + const { output, options } = fixture() + expect(reusePreparedWindowsSecurityAddon('x64', output, { ...options, ...overrides })).toBe(false) + expect(options.run).not.toHaveBeenCalled() +}) + +it('declines missing, corrupt, wrong-architecture candidates and an unverified runtime', () => { + const { candidate, output, options } = fixture() + expect( + reusePreparedWindowsSecurityAddon('x64', output, { + ...options, + runtimeAssets: undefined + }) + ).toBe(false) + expect(options.run).not.toHaveBeenCalled() + for (const bytes of [Buffer.from('corrupt'), peImage({ arch: 'arm64' })]) { + writeFileSync(candidate, bytes) + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) + } + rmSync(candidate) + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) + writeFileSync(candidate, peImage()) + writeFileSync(options.runtimePath, 'different Node') + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) + expect(options.run).not.toHaveBeenCalled() +}) + +it.each([ + { code: 1 }, + { code: null }, + { timedOut: true }, + { outputTruncated: true }, + { stdout: '' }, + { stdout: 'S-1-5-21-123' }, + { stdout: 'windows-security-ready\nextra output' } +])('declines failed loading or invalid probe results: %j', (result) => { + const { output, options } = fixture() + options.run + .mockReturnValueOnce(success) + .mockReturnValueOnce({ ...success, stdout: 'windows-security-ready', ...result }) + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) +}) + +it.each([{ code: 1 }, { timedOut: true }, { outputTruncated: true }])( + 'does not load the candidate after failed Node preparation: %j', + (result) => { + const { output, options } = fixture() + options.run.mockReturnValueOnce({ ...success, ...result }) + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) + expect(options.run).toHaveBeenCalledOnce() + } +) + +it('declines probe spawn exceptions', () => { + const { output, options } = fixture() + options.run.mockImplementation(() => { + throw new Error('spawn failed') + }) + expect(reusePreparedWindowsSecurityAddon('x64', output, options)).toBe(false) +}) + +it.each(['', undefined, 123, 'invalid', 'S-1-no-sid'])( + 'the probe rejects invalid currentUserSid results: %j', + (sid) => { + const { output, options } = fixture() + reusePreparedWindowsSecurityAddon('x64', output, options) + const probe = options.run.mock.calls[1][0] + const write = vi.fn() + expect(() => + runInNewContext(probe.args[1], { + process: { + platform: 'win32', + arch: 'x64', + versions: {}, + stdout: { write }, + exit: (code) => { + throw new Error(`exit ${code}`) + } + }, + require: () => ({ currentUserSid: () => sid }) + }) + ).toThrow('exit 2') + expect(write).not.toHaveBeenCalled() + } +) + +it.each([ + 'non-opt-in', + 'cold', + 'corrupt', + 'wrong architecture', + 'cross compilation', + 'load failure', + 'valid' +])('the builder retains real compilation unless reuse succeeds: %s', (scenario) => { + const { candidate, output, options } = fixture() + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64' }) + if (scenario === 'non-opt-in') { + options.enabled = false + } + if (scenario === 'cold') { + rmSync(candidate) + } + if (scenario === 'corrupt') { + writeFileSync(candidate, 'bad') + } + if (scenario === 'wrong architecture') { + writeFileSync(candidate, peImage({ arch: 'arm64' })) + } + if (scenario === 'load failure') { + options.run.mockReturnValueOnce(success).mockReturnValueOnce({ ...success, code: 1 }) + } + const arch = scenario === 'cross compilation' ? 'arm64' : 'x64' + expect(buildWindowsSecurityAddon(arch, output, options)).toBe(output) + expect( + options.run.mock.calls.filter(([invocation]) => invocation.args.includes('rebuild')) + ).toHaveLength(scenario === 'valid' ? 0 : 1) +}) + +it('standalone compilation rejects unsupported architectures and non-Windows hosts', () => { + const { output, options } = fixture() + vi.stubGlobal('process', { ...process, platform: 'linux' }) + expect(() => buildWindowsSecurityAddon('ia32', output, options)).toThrow('--arch must be') + expect(() => buildWindowsSecurityAddon('x64', output, options)).toThrow('only builds on Windows') + expect(options.run).not.toHaveBeenCalled() +}) + +it.each(['failed compiler', 'missing output', 'wrong machine'])( + 'the cold path fails closed on %s', + (failure) => { + const { output, options } = fixture() + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64' }) + options.enabled = false + options.run.mockImplementation((invocation) => { + if (failure === 'wrong machine') { + const built = join(invocation.cwd, 'build/Release/orca_windows_security.node') + mkdirSync(dirname(built), { recursive: true }) + writeFileSync(built, peImage({ arch: 'arm64' })) + } + return { ...success, code: failure === 'failed compiler' ? 1 : 0 } + }) + expect(() => buildWindowsSecurityAddon('x64', output, options)).toThrow() + expect(() => readFileSync(output)).toThrow() + } +) diff --git a/config/scripts/orcad-windows-security.mjs b/config/scripts/orcad-windows-security.mjs index cdd2b938201..74265302791 100644 --- a/config/scripts/orcad-windows-security.mjs +++ b/config/scripts/orcad-windows-security.mjs @@ -18,7 +18,8 @@ export function stageOrcadWindowsSecurity( outputDir, target, host = { platform: process.platform }, - compile = compileOrcadWindowsSecurity + compile = compileOrcadWindowsSecurity, + options = {} ) { if (!target.startsWith('win32-')) { return @@ -32,7 +33,7 @@ export function stageOrcadWindowsSecurity( `node config/scripts/build-windows-security-addon.mjs --arch=${arch}` ) } - compile(root, arch) + compile(root, arch, options) } const machine = readPeMachine(source) if (machine !== PE_MACHINE[arch]) { @@ -41,10 +42,20 @@ export function stageOrcadWindowsSecurity( copyFileSync(source, join(outputDir, ORCAD_WINDOWS_SECURITY_FILENAME)) } -function compileOrcadWindowsSecurity(root, arch) { - const result = runProcessSync({ +function compileOrcadWindowsSecurity( + root, + arch, + { enabled = false, runtimePath, run = runProcessSync } = {} +) { + const result = run({ program: process.execPath, - args: [join(root, 'config/scripts/build-windows-security-addon.mjs'), `--arch=${arch}`], + args: [ + join(root, 'config/scripts/build-windows-security-addon.mjs'), + `--arch=${arch}`, + ...(enabled && runtimePath + ? ['--reuse-prepared-runtime', `--node-runtime=${runtimePath}`] + : []) + ], cwd: root, stdio: 'inherit', timeoutMs: null diff --git a/config/scripts/orcad-windows-security.test.mjs b/config/scripts/orcad-windows-security.test.mjs index 14f4a9fec04..31e8b0f5128 100644 --- a/config/scripts/orcad-windows-security.test.mjs +++ b/config/scripts/orcad-windows-security.test.mjs @@ -59,7 +59,7 @@ it('fails the build off Windows when the addon was never built, and compiles it const compile = vi.fn((compileRoot, arch) => write(arch, 0x8664)) stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, compile) - expect(compile).toHaveBeenCalledWith(root, 'x64') + expect(compile).toHaveBeenCalledWith(root, 'x64', {}) expect(existsSync(join(output, 'windows-security.node'))).toBe(true) }) @@ -101,3 +101,85 @@ it.each([ expect(step['continue-on-error']).toBeUndefined() } }) + +it('forwards the opt-in and pinned runtime only when compilation is needed', () => { + const { root, output, write } = fixture() + const options = { enabled: true, runtimePath: join(root, 'pinned node.exe') } + const compile = vi.fn((_root, arch) => write(arch, 0x8664)) + stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, compile, options) + expect(compile).toHaveBeenCalledWith(root, 'x64', options) + compile.mockClear() + stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, compile, options) + expect(compile).not.toHaveBeenCalled() +}) + +it.each(['ssh-windows-hosts', 'node-server-tests'])( + 'opts in only in prepared Windows PR packaging in %s', + (name) => { + const workflow = parse(readFileSync(`.github/workflows/${name}.yml`, 'utf8')) + const job = workflow.jobs[name === 'ssh-windows-hosts' ? 'hosts' : 'persistence'] + const preparation = job.steps.find((step) => step.id === 'dependencies') + const build = job.steps.find((step) => step.env?.ORCA_REUSE_PREPARED_WINDOWS_SECURITY) + expect(preparation.uses).toBe('./.github/actions/install-node-dependencies') + expect(preparation.with['native-runtime']).toContain('node') + expect(job.steps.indexOf(preparation)).toBeLessThan(job.steps.indexOf(build)) + expect(preparation['continue-on-error']).toBeUndefined() + expect(build['continue-on-error']).toBeUndefined() + expect(build.run).toContain( + name === 'ssh-windows-hosts' ? 'build-orcad-template.mjs' : 'pnpm build:orcad' + ) + for (const event of ['pull_request', 'push', 'workflow_dispatch', 'schedule']) { + for (const outcome of ['success', 'failure', 'skipped', undefined]) { + for (const os of ['Windows', 'Linux', 'macOS']) { + const enabled = runInNewContext( + build.env.ORCA_REUSE_PREPARED_WINDOWS_SECURITY.slice(3, -2), + { + github: { event_name: event }, + runner: { os }, + steps: { dependencies: { outcome } } + } + ) + expect(enabled).toBe( + event === 'pull_request' && + outcome === 'success' && + (name === 'ssh-windows-hosts' || os === 'Windows') + ) + } + } + } + } +) + +it.each([false, true])('passes an explicit CLI opt-in only when enabled: %s', (enabled) => { + const { root, output, write } = fixture() + const runtimePath = join(root, 'pinned node.exe') + const run = vi.fn(() => { + write('x64', 0x8664) + return { code: 0 } + }) + stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, undefined, { + enabled, + runtimePath, + run + }) + expect(run).toHaveBeenLastCalledWith({ + program: process.execPath, + args: [ + join(root, 'config/scripts/build-windows-security-addon.mjs'), + '--arch=x64', + ...(enabled ? ['--reuse-prepared-runtime', `--node-runtime=${runtimePath}`] : []) + ], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) +}) + +it('does not stage an addon when the child builder fails', () => { + const { root, output } = fixture() + const run = vi.fn(() => ({ code: 1 })) + expect(() => + stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, undefined, { run }) + ).toThrow('failed with exit 1') + expect(existsSync(join(output, 'windows-security.node'))).toBe(false) +}) diff --git a/config/scripts/windows-native-preparation-benchmark.test.mjs b/config/scripts/windows-native-preparation-benchmark.test.mjs index fa5ea5253aa..20c2ac9dd14 100644 --- a/config/scripts/windows-native-preparation-benchmark.test.mjs +++ b/config/scripts/windows-native-preparation-benchmark.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -210,3 +210,45 @@ test('manual workflow freezes source and limits Windows occupancy without changi assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()') assert(!Object.hasOwn(workflow.jobs, 'verify')) }) + +test('source validation allows owned workflow receipts but rejects unrelated untracked source', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-output-ignore-')) + const git = async (args) => { + const result = await runProcess({ program: 'git', args, cwd: directory, timeoutMs: 10_000 }) + assert.equal(result.code, 0) + assert(!result.timedOut && !result.outputTruncated) + return result.stdout + } + try { + await git(['init', '--quiet']) + writeFileSync( + join(directory, '.gitignore'), + readFileSync(join(import.meta.dirname, '../../.gitignore')) + ) + await git(['add', '.gitignore']) + const output = join(directory, '.build/windows-native-benchmark') + mkdirSync(join(output, 'security'), { recursive: true }) + writeFileSync( + join(output, 'job-start.json'), + JSON.stringify({ startedAt: new Date().toISOString() }) + ) + writeFileSync( + join(output, 'security/receipt.json'), + JSON.stringify({ qualification: 'incomplete' }) + ) + const untracked = ['ls-files', '--others', '--exclude-standard'] + assert.equal(await git(untracked), '') + mkdirSync(join(directory, 'config/scripts'), { recursive: true }) + writeFileSync( + join(directory, 'config/scripts/untracked-source.mjs'), + 'export const source = true' + ) + writeFileSync(join(directory, '.build/unrelated-source.mjs'), 'export const source = true') + assert.deepEqual((await git(untracked)).trim().split('\n').sort(), [ + '.build/unrelated-source.mjs', + 'config/scripts/untracked-source.mjs' + ]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/windows-security-reuse-benchmark.test.mjs b/config/scripts/windows-security-reuse-benchmark.test.mjs new file mode 100644 index 00000000000..add6179a274 --- /dev/null +++ b/config/scripts/windows-security-reuse-benchmark.test.mjs @@ -0,0 +1,399 @@ +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + statSync, + symlinkSync, + writeFileSync, + rmSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { afterEach, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { sha256Of } from './orcad-prebuild-slot-contents.mjs' +import { peImage } from './windows-pe-image-fixture.mjs' +import { + assertSecurityComparisonHost, + assertSecurityCommand, + benchmarkWindowsSecurityReuse, + probeSecurityArtifact, + runSecurityComparisonSample, + withInvalidSecurityCandidate +} from './benchmark-windows-security-reuse.mjs' + +const directories = [] +const sourceSha = 'a'.repeat(40) +const sidFixture = 'S-1-5-21-123456-234567-345678-1001' +const success = { + code: 0, + signal: null, + timedOut: false, + outputTruncated: false, + stdout: '', + stderr: '' +} +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.resetAllMocks() + directories.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })) +}) +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') +function fixture(arch = 'x64') { + const root = mkdtempSync(join(tmpdir(), 'orca-security-qualification-')) + directories.push(root) + const candidate = join(root, 'native/windows-security/build/Release/orca_windows_security.node') + mkdirSync(dirname(candidate), { recursive: true }) + writeFileSync(candidate, peImage({ arch })) + mkdirSync(join(root, 'native/windows-security/src')) + writeFileSync(join(root, 'native/windows-security/binding.gyp'), '{}') + const runtimePath = join(root, 'pinned node.exe') + writeFileSync(runtimePath, 'pinned fixture') + const output = join(root, 'staged.node') + const receiptFile = join(root, 'sample.json') + const run = vi.fn((spec) => { + if (spec.args.includes('rebuild')) { + const built = join(spec.cwd, 'build/Release/orca_windows_security.node') + mkdirSync(dirname(built), { recursive: true }) + writeFileSync(built, peImage({ arch })) + } + let stdout = '' + if (spec.args[0] === '-e') { + runInNewContext(spec.args[1], { + process: { + platform: 'win32', + arch, + versions: {}, + stdout: { + write: (value) => { + stdout += value + } + }, + exit: () => { + throw new Error('invalid probe') + } + }, + require: (path) => { + expect(existsSync(path)).toBe(true) + return { currentUserSid: () => sidFixture } + } + }) + } + return { ...success, stdout } + }) + const runtimeAssets = { [`win32-${arch}`]: { executableSha256: digest('pinned fixture') } } + return { root, arch, candidate, runtimePath, output, receiptFile, run, runtimeAssets } +} +function windows(arch = 'x64') { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + arch, + versions: { ...process.versions, node: '24.21.0' } + }) + vi.stubEnv('SOURCE_SHA', sourceSha) + vi.stubEnv('BENCHMARK_RUNNER', arch === 'x64' ? 'windows-2022' : 'windows-11-arm') +} + +it.each(['x64', 'arm64'])('accepts only matching Windows %s source and Node identity', (arch) => { + windows(arch) + expect(() => assertSecurityComparisonHost()).not.toThrow() + for (const mismatch of [ + { platform: 'linux' }, + { platform: 'darwin' }, + { arch: 'ia32' }, + { runner: 'unknown' }, + { version: '24.20.0' }, + { sourceSha: 'main' }, + { electron: '43.7.8' } + ]) { + expect(() => assertSecurityComparisonHost(mismatch)).toThrow() + } +}) +it.each([ + { code: 1 }, + { code: null }, + { signal: 'SIGTERM' }, + { timedOut: true }, + { outputTruncated: true } +])('refuses incomplete commands: %j', (failure) => { + expect(() => assertSecurityCommand({ ...success, ...failure })).toThrow() +}) + +it.each(['control', 'candidate', 'fallback'])( + 'records observed compiler calls and private operation proof for %s', + async (variant) => { + const f = fixture() + windows() + const act = () => runSecurityComparisonSample({ ...f, variant }) + const receipt = + variant === 'fallback' + ? await withInvalidSecurityCandidate(f.candidate, f.root, sha256Of(f.candidate), act) + : act() + expect(receipt.qualification).toBe('passed') + expect(receipt.compilerInvocationCount).toBe(variant === 'candidate' ? 0 : 1) + expect(receipt.calls.filter((call) => call.phase === 'preparation-probe')).toHaveLength( + variant === 'candidate' ? 1 : 0 + ) + expect(receipt.staged.currentUserSid).toBe('passed') + expect(receipt.staged.sha256).toBe(sha256Of(f.output)) + expect(readFileSync(f.receiptFile, 'utf8')).not.toContain(sidFixture) + for (const [spec] of f.run.mock.calls) { + expect(spec.stdio).toBe('pipe') + expect(spec.timeoutMs).toBeGreaterThan(0) + } + } +) + +it.each(['timeout', 'truncated', 'download', 'spawn'])( + 'cannot hide a %s in the reuse probe behind successful fallback', + (failure) => { + const f = fixture() + windows() + const original = f.run.getMockImplementation() + f.run.mockImplementationOnce((spec) => { + if (failure === 'spawn') { + throw new Error(sidFixture) + } + if (failure === 'download') { + return { ...success, stderr: 'gyp http GET https://nodejs.org/headers' } + } + return { + ...original(spec), + timedOut: failure === 'timeout', + outputTruncated: failure === 'truncated' + } + }) + expect(() => runSecurityComparisonSample({ ...f, variant: 'candidate' })).toThrow() + const receipt = JSON.parse(readFileSync(f.receiptFile, 'utf8')) + expect(receipt.qualification).not.toBe('passed') + expect(receipt.calls.some((call) => !call.complete)).toBe(true) + expect(JSON.stringify(receipt)).not.toContain(sidFixture) + } +) + +it('rejects wrong PE/runtime identity and a changed artifact during probing', () => { + const f = fixture() + expect(() => probeSecurityArtifact(f.candidate, f.runtimePath, 'x64', f.run)).toThrow( + 'Runtime hash' + ) + expect(f.run).not.toHaveBeenCalled() + expect(() => + probeSecurityArtifact(f.candidate, f.runtimePath, 'arm64', f.run, f.runtimeAssets) + ).toThrow('architecture') + writeFileSync(f.runtimePath, 'wrong runtime') + expect(() => + probeSecurityArtifact(f.candidate, f.runtimePath, 'x64', f.run, f.runtimeAssets) + ).toThrow('Runtime hash') + expect(f.run).not.toHaveBeenCalled() + writeFileSync(f.runtimePath, 'pinned fixture') + f.run.mockImplementationOnce(() => { + writeFileSync(f.candidate, 'changed') + return { ...success, stdout: 'windows-security-ready' } + }) + expect(() => + probeSecurityArtifact(f.candidate, f.runtimePath, 'x64', f.run, f.runtimeAssets) + ).toThrow('changed during probe') +}) + +it.each(['success', 'rejection'])( + 'restores exact candidate bytes and permissions after %s', + async (outcome) => { + const f = fixture() + const original = readFileSync(f.candidate) + const before = statSync(f.candidate) + const action = async () => { + expect(readFileSync(f.candidate)).not.toEqual(original) + if (outcome === 'rejection') { + throw new Error('failed sample') + } + return 'proof' + } + const operation = withInvalidSecurityCandidate(f.candidate, f.root, digest(original), action) + if (outcome === 'rejection') { + await expect(operation).rejects.toThrow('failed sample') + } else { + expect(await operation).toBe('proof') + } + expect(readFileSync(f.candidate)).toEqual(original) + expect(statSync(f.candidate).mode).toBe(before.mode) + expect(statSync(f.candidate).mtimeMs).toBeCloseTo(before.mtimeMs, 0) + } +) + +it('refuses a changed candidate, symlink and preexisting backup before invalidating', async () => { + const f = fixture() + const bytes = readFileSync(f.candidate) + const action = vi.fn() + await expect( + withInvalidSecurityCandidate(f.candidate, f.root, 'wrong hash', action) + ).rejects.toThrow() + writeFileSync(join(f.root, 'prepared-security-backup.node'), 'occupied') + await expect( + withInvalidSecurityCandidate(f.candidate, f.root, digest(bytes), action) + ).rejects.toThrow() + const link = join(f.root, 'link.node') + symlinkSync(dirname(f.candidate), link, 'junction') + await expect(withInvalidSecurityCandidate(link, f.root, digest(bytes), action)).rejects.toThrow( + 'regular file' + ) + expect(readFileSync(f.candidate)).toEqual(bytes) + expect(action).not.toHaveBeenCalled() +}) + +it('keeps the existing manual default and runs the security mode on only its two existing slots', () => { + const workflow = parse( + readFileSync('.github/workflows/windows-native-preparation-benchmark.yml', 'utf8') + ) + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + expect(workflow.on.workflow_dispatch.inputs.comparison).toEqual({ + description: expect.any(String), + type: 'choice', + default: 'native-preparation', + options: ['native-preparation', 'windows-security'] + }) + expect(Object.keys(workflow.jobs)).toEqual(['compare']) + expect(workflow.jobs.compare.strategy.matrix.os).toEqual(['windows-2022', 'windows-11-arm']) + expect(workflow.jobs.compare.strategy['max-parallel']).toBe(2) + const steps = workflow.jobs.compare.steps + const native = steps.find( + (step) => step.run === 'node config/scripts/benchmark-windows-native-preparation.mjs' + ) + const security = steps.find( + (step) => step.run === 'node config/scripts/benchmark-windows-security-reuse.mjs' + ) + for (const choice of [undefined, '', 'native-preparation', 'windows-security']) { + expect(runInNewContext(native.if, { inputs: { comparison: choice } })).toBe( + choice !== 'windows-security' + ) + expect(runInNewContext(security.if, { inputs: { comparison: choice } })).toBe( + choice === 'windows-security' + ) + } + expect(steps.at(-1).if).toBe('always()') + expect(workflow.env.ORCA_BACKGROUND_LAUNCH).toBe('1') +}) + +function hostedFixture(arch = 'x64', failure) { + const f = fixture(arch) + windows(arch) + rmSync(f.candidate) + for (const path of [ + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'package.json', + '.github/actions/prepare-native-runtime/action.yml', + 'src/shared/node-runtime-pin.ts', + 'config/scripts/build-windows-security-addon.mjs', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/benchmark-windows-security-reuse.mjs' + ]) { + mkdirSync(dirname(join(f.root, path)), { recursive: true }) + writeFileSync(join(f.root, path), 'source fixture') + } + const ensureRuntime = vi.fn(async () => f.runtimePath) + const prepareHeaders = vi.fn(async ({ workDir }) => { + mkdirSync(workDir, { recursive: true }) + writeFileSync(join(workDir, 'header'), 'verified header fixture') + return workDir + }) + const run = vi.fn(async (spec) => { + if (spec.args.includes('HEAD') && spec.args.includes('rev-parse')) { + return { ...success, stdout: sourceSha } + } + if (spec.args.includes('HEAD^{tree}')) { + return { ...success, stdout: 'b'.repeat(40) } + } + if (spec.args.includes('native/windows-security')) { + return { ...success, stdout: 'native/windows-security/binding.gyp\n' } + } + if (spec.args.some((arg) => arg.endsWith('initialize-windows-native-toolchain.mjs'))) { + writeFileSync(f.candidate, peImage({ arch })) + } + const variant = spec.args.find((arg) => arg.startsWith('--sample='))?.slice(9) + if (variant) { + if (failure === variant) { + return { ...success, code: 1, timedOut: true, stderr: sidFixture } + } + runSecurityComparisonSample({ + ...f, + variant, + output: spec.args[3], + receiptFile: spec.args[4] + }) + } + return success + }) + f.ports = { + run, + runSync: f.run, + ensureRuntime, + prepareHeaders, + runtimeAssets: f.runtimeAssets, + hashFile: (path) => (path === process.execPath ? digest('pinned fixture') : sha256Of(path)) + } + return f +} + +it.each(['x64', 'arm64'])( + 'qualifies exactly one same-source %s pair and fallback after separate preparation', + async (arch) => { + const f = hostedFixture(arch) + const report = await benchmarkWindowsSecurityReuse(f.root, f.ports) + expect(report.qualification).toBe('passed') + expect( + report.samples.map((sample) => [sample.variant, sample.compilerInvocationCount]) + ).toEqual([ + ['control', 1], + ['candidate', 0], + ['fallback', 1] + ]) + expect(report.restored).toBe(true) + expect(report.restoredPreparedSha256).toBe(report.prepared.sha256) + expect(sha256Of(f.candidate)).toBe(report.prepared.sha256) + expect(report.runtime.sha256).toBe(digest('pinned fixture')) + expect( + report.samples.every((sample) => sample.fullCommandMs >= sample.buildIncludingReuseProbesMs) + ).toBe(true) + const phases = report.phases.map((phase) => phase.label) + expect(phases.indexOf('verify-prepared-node')).toBeLessThan( + phases.indexOf('control-full-build-and-probe') + ) + expect(phases.indexOf('warm-compiler-before-measurement')).toBeLessThan( + phases.indexOf('control-full-build-and-probe') + ) + expect(report.sourceSha).toBe(sourceSha) + expect(report.sourceHashes['native/windows-security/binding.gyp']).toBe( + sha256Of(join(f.root, 'native/windows-security/binding.gyp')) + ) + expect(JSON.stringify(report)).not.toContain(sidFixture) + } +) + +it('restores the owned candidate and leaves a failed redacted receipt when fallback times out', async () => { + const f = hostedFixture('x64', 'fallback') + await expect(benchmarkWindowsSecurityReuse(f.root, f.ports)).rejects.toThrow('did not qualify') + const report = JSON.parse( + readFileSync(join(f.root, '.build/windows-native-benchmark/security/receipt.json'), 'utf8') + ) + expect(report.qualification).toBe('failed') + expect(report.failureStage).toBe('fallback-full-build-and-probe') + expect(report.restored).toBe(true) + expect(sha256Of(f.candidate)).toBe(report.prepared.sha256) + expect( + report.phases.find((phase) => phase.label === 'fallback-full-build-and-probe').timedOut + ).toBe(true) + expect(JSON.stringify(report)).not.toContain(sidFixture) +}) + +it('refuses another checkout commit before preparing artifacts', async () => { + const f = hostedFixture() + f.ports.run.mockResolvedValueOnce({ ...success, stdout: 'c'.repeat(40) }) + await expect(benchmarkWindowsSecurityReuse(f.root, f.ports)).rejects.toThrow('did not qualify') + expect(f.ports.prepareHeaders).not.toHaveBeenCalled() + expect(existsSync(f.candidate)).toBe(false) +}) diff --git a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts index c4a10df2f3a..f99e1fbe19c 100644 --- a/mobile/src/session/MobileNativeChatView.composer-lock.test.ts +++ b/mobile/src/session/MobileNativeChatView.composer-lock.test.ts @@ -7,6 +7,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: composer lock and prompt cards', () => { installChatViewHooks() diff --git a/mobile/src/session/MobileNativeChatView.failure-banner.test.ts b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts index 2bcebade50a..9c056b83934 100644 --- a/mobile/src/session/MobileNativeChatView.failure-banner.test.ts +++ b/mobile/src/session/MobileNativeChatView.failure-banner.test.ts @@ -12,6 +12,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: send and read failure banners', () => { installChatViewHooks() diff --git a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts index ba3f764c698..617e9147405 100644 --- a/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts +++ b/mobile/src/session/MobileNativeChatView.harness.test-fixture.ts @@ -3,80 +3,12 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, beforeEach, expect, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileNativeChatView } from './MobileNativeChatView' - -const scrollToEnd = vi.hoisted(() => vi.fn()) -const scrollToOffset = vi.hoisted(() => vi.fn()) -const platform = vi.hoisted(() => ({ OS: 'ios' })) -const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 })) - -vi.mock('react-native', async () => { - const React = await import('react') - return { - ActivityIndicator: 'ActivityIndicator', - FlatList: React.forwardRef((props, ref) => { - const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller } - React.useImperativeHandle(ref, () => handle, []) - return React.createElement('FlatList', props) - }), - Platform: platform, - Pressable: 'Pressable', - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, - Text: 'Text', - View: 'View' - } -}) - -vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) -})) - -vi.mock('react-native-gesture-handler', () => { - const chain = { - runOnJS: () => chain, - onStart: () => chain, - onUpdate: () => chain - } - return { - Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, - GestureDetector: 'GestureDetector', - GestureHandlerRootView: 'GestureHandlerRootView' - } -}) - -vi.mock('lucide-react-native', () => ({ - ArrowDown: 'ArrowDown', - ChevronsDownUp: 'ChevronsDownUp', - ChevronsUpDown: 'ChevronsUpDown', - Square: 'Square' -})) - -vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) -vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) -vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) -vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) -vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) -vi.mock('./MobileAgentWorkingIndicator', () => ({ - MobileAgentWorkingIndicator: 'WorkingIndicator' -})) - -// Stand-in composer: exposes the view's `handleSend` through a pressable, which is -// the only composer behaviour these banner tests exercise. -vi.mock('./MobileNativeChatComposer', async () => { - const React = await import('react') - return { - MobileNativeChatComposer: (props: { - onSend: (text: string) => Promise - disabled?: boolean - placeholder?: string - }) => - React.createElement('Composer', { - ...props, - accessibilityLabel: 'Send message', - onPress: () => props.onSend('hi') - }) - } -}) +import { + scrollToEnd, + scrollToOffset, + platform, + pageScroller +} from './MobileNativeChatView.render-stubs.test-fixture' export type Overrides = { messages?: Parameters[0]['messages'] @@ -222,5 +154,4 @@ export async function scrollAwayFromTail(): Promise { }) } -// Vitest rejects exporting vi.hoisted bindings directly, so they are exported by name here. export { scrollToEnd, scrollToOffset, platform, pageScroller } diff --git a/mobile/src/session/MobileNativeChatView.history-paging.test.ts b/mobile/src/session/MobileNativeChatView.history-paging.test.ts index f8533544664..116464c23d6 100644 --- a/mobile/src/session/MobileNativeChatView.history-paging.test.ts +++ b/mobile/src/session/MobileNativeChatView.history-paging.test.ts @@ -13,6 +13,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: history paging and anchoring', () => { installChatViewHooks() diff --git a/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts new file mode 100644 index 00000000000..19cb4ee778c --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.render-stubs.test-fixture.ts @@ -0,0 +1,54 @@ +import { vi } from 'vitest' + +const scrollToEnd = vi.hoisted(() => vi.fn()) +const scrollToOffset = vi.hoisted(() => vi.fn()) +const platform = vi.hoisted(() => ({ OS: 'ios' })) +const pageScroller = vi.hoisted(() => ({ scrollHeight: 0 })) + +export async function reactNativeStub() { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + const handle = { scrollToEnd, scrollToOffset, getScrollableNode: () => pageScroller } + React.useImperativeHandle(ref, () => handle, []) + return React.createElement('FlatList', props) + }), + Platform: platform, + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +} + +export function gestureHandlerStub() { + const chain = { + runOnJS: () => chain, + onStart: () => chain, + onUpdate: () => chain + } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +} + +export async function composerStub() { + const React = await import('react') + return { + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => + React.createElement('Composer', { + ...props, + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }) + } +} + +export { scrollToEnd, scrollToOffset, platform, pageScroller } diff --git a/mobile/src/session/MobileNativeChatView.rows.test.ts b/mobile/src/session/MobileNativeChatView.rows.test.ts index db2dc5bf15d..789df91ca68 100644 --- a/mobile/src/session/MobileNativeChatView.rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.rows.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { installChatViewHooks, assistantTurn, @@ -8,6 +8,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: rows and the streaming bubble', () => { installChatViewHooks() diff --git a/mobile/src/session/MobileNativeChatView.stop.test.ts b/mobile/src/session/MobileNativeChatView.stop.test.ts index d5c919dd91b..fc24ceb812b 100644 --- a/mobile/src/session/MobileNativeChatView.stop.test.ts +++ b/mobile/src/session/MobileNativeChatView.stop.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { installChatViewHooks, hostType, @@ -7,6 +7,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: Stop control', () => { installChatViewHooks() diff --git a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts index b5ce104c7c2..1de85ec29fa 100644 --- a/mobile/src/session/MobileNativeChatView.tail-follow.test.ts +++ b/mobile/src/session/MobileNativeChatView.tail-follow.test.ts @@ -13,6 +13,37 @@ import { update } from './MobileNativeChatView.harness.test-fixture' +vi.mock('react-native', async () => { + const { reactNativeStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return reactNativeStub() +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', async () => { + const { gestureHandlerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return gestureHandlerStub() +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatComposer', async () => { + const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture') + return composerStub() +}) + describe('MobileNativeChatView: tail-follow', () => { installChatViewHooks()