mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -122,7 +122,7 @@ function digestFiles(root, paths) {
|
||||
)
|
||||
}
|
||||
|
||||
function treeDigest(root) {
|
||||
export function treeDigest(root) {
|
||||
const digest = createHash('sha256')
|
||||
const files = []
|
||||
const visit = (path) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<boolean>
|
||||
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<typeof MobileNativeChatView>[0]['messages']
|
||||
@@ -222,5 +154,4 @@ export async function scrollAwayFromTail(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Vitest rejects exporting vi.hoisted bindings directly, so they are exported by name here.
|
||||
export { scrollToEnd, scrollToOffset, platform, pageScroller }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<boolean>
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) =>
|
||||
React.createElement('Composer', {
|
||||
...props,
|
||||
accessibilityLabel: 'Send message',
|
||||
onPress: () => props.onSend('hi')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { scrollToEnd, scrollToOffset, platform, pageScroller }
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user