@ornexus/neocortex 4.60.67 → 4.60.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/release-source.json +1 -1
- package/dist/sbom.cdx.json +1194 -1194
- package/install.sh +1 -1
- package/package.json +2 -2
- package/packages/client/dist/adapters/adapter-registry.js +108 -1
- package/packages/client/dist/adapters/antigravity-adapter.js +77 -2
- package/packages/client/dist/adapters/claude-code-adapter.js +79 -3
- package/packages/client/dist/adapters/codex-adapter.js +80 -2
- package/packages/client/dist/adapters/cursor-adapter.js +115 -4
- package/packages/client/dist/adapters/gemini-adapter.js +71 -2
- package/packages/client/dist/adapters/grok-adapter.js +42 -2
- package/packages/client/dist/adapters/index.js +22 -1
- package/packages/client/dist/adapters/platform-detector.js +212 -1
- package/packages/client/dist/adapters/target-adapter.js +12 -0
- package/packages/client/dist/adapters/vscode-adapter.js +72 -2
- package/packages/client/dist/agent/refresh-stubs.js +417 -2
- package/packages/client/dist/agent/update-agent-yaml.js +102 -1
- package/packages/client/dist/agent/update-description.js +251 -1
- package/packages/client/dist/cache/crypto-utils.js +78 -1
- package/packages/client/dist/cache/encrypted-cache.js +108 -1
- package/packages/client/dist/cache/in-memory-asset-cache.js +73 -1
- package/packages/client/dist/cache/index.js +13 -1
- package/packages/client/dist/cache/protected-pi-boundary.js +44 -1
- package/packages/client/dist/checkpoint/checkpoint-client-reader.js +127 -2
- package/packages/client/dist/checkpoint/index.js +11 -1
- package/packages/client/dist/checkpoint/shared-checkpoint-types.js +32 -1
- package/packages/client/dist/cli.js +290 -3
- package/packages/client/dist/commands/activate.js +394 -8
- package/packages/client/dist/commands/architecture-answers.js +274 -7
- package/packages/client/dist/commands/architecture-memory-terminal.js +253 -4
- package/packages/client/dist/commands/architecture-non-ui-continuation.js +100 -1
- package/packages/client/dist/commands/architecture-root-binding.js +51 -1
- package/packages/client/dist/commands/cache-status.js +112 -2
- package/packages/client/dist/commands/config.js +88 -1
- package/packages/client/dist/commands/doctor.js +635 -2
- package/packages/client/dist/commands/invoke.js +7140 -142
- package/packages/client/dist/commands/legacy-epic-terminal.js +71 -1
- package/packages/client/dist/commands/legacy-story-branch.js +96 -1
- package/packages/client/dist/commands/legacy-story-pr.js +276 -1
- package/packages/client/dist/commands/mutation-qa-checkpoint.js +285 -5
- package/packages/client/dist/commands/mutation-qa-gate.js +54 -1
- package/packages/client/dist/commands/mutation-qa-git.js +41 -1
- package/packages/client/dist/commands/mutation-qa-policy.js +102 -1
- package/packages/client/dist/commands/mutation-qa-run.js +219 -3
- package/packages/client/dist/commands/mutation-qa-verdict.js +149 -1
- package/packages/client/dist/commands/mutation-qa.js +220 -1
- package/packages/client/dist/commands/native-agent.js +136 -2
- package/packages/client/dist/commands/planning-diagnosis.js +43 -1
- package/packages/client/dist/commands/planning-generation.js +53 -1
- package/packages/client/dist/commands/planning-memory.js +40 -1
- package/packages/client/dist/commands/planning-research.js +42 -1
- package/packages/client/dist/commands/private-session.js +75 -1
- package/packages/client/dist/commands/property-qa.js +139 -1
- package/packages/client/dist/commands/qa-control.js +16 -1
- package/packages/client/dist/commands/qa-scan-browser-launch.js +55 -1
- package/packages/client/dist/commands/qa-scan-browser-route.js +28 -1
- package/packages/client/dist/commands/qa-scan-capture.js +663 -5
- package/packages/client/dist/commands/qa-scan-discovery.js +376 -3
- package/packages/client/dist/commands/qa-scan-review.js +826 -5
- package/packages/client/dist/commands/qa-synthetic-backend-capture.js +266 -1
- package/packages/client/dist/commands/qa-synthetic-backend-review.js +297 -1
- package/packages/client/dist/commands/qa-synthetic-backend.js +340 -2
- package/packages/client/dist/commands/qa-synthetic-broker-port.js +85 -1
- package/packages/client/dist/commands/qa-synthetic-browser.js +39 -1
- package/packages/client/dist/commands/refactor-characterization.js +671 -21
- package/packages/client/dist/commands/refactor-commit.js +399 -13
- package/packages/client/dist/commands/refactor-control.js +142 -3
- package/packages/client/dist/commands/refactor-cpp-assertions.js +93 -3
- package/packages/client/dist/commands/refactor-review.js +283 -5
- package/packages/client/dist/commands/refactor-root.js +588 -7
- package/packages/client/dist/commands/refresh-memory.js +156 -3
- package/packages/client/dist/commands/shared-architecture-non-ui.js +24 -1
- package/packages/client/dist/commands/story-checkpoint.js +171 -1
- package/packages/client/dist/config/profile-directory.js +13 -1
- package/packages/client/dist/config/resolver-selection.js +278 -1
- package/packages/client/dist/config/secure-config.js +281 -12
- package/packages/client/dist/config.js +223 -2
- package/packages/client/dist/constants.js +25 -1
- package/packages/client/dist/context/context-collector.js +222 -2
- package/packages/client/dist/context/context-sanitizer.js +145 -1
- package/packages/client/dist/continuity/autonomous-governance.js +285 -1
- package/packages/client/dist/continuity/continuity-client-state-store.js +1587 -1
- package/packages/client/dist/continuity/invoke-hooks.js +129 -1
- package/packages/client/dist/continuity/migrations/001-initial-schema.js +12 -2
- package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +12 -2
- package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +12 -2
- package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +12 -2
- package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +12 -2
- package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +10 -2
- package/packages/client/dist/continuity/native-agent-store.js +69 -1
- package/packages/client/dist/continuity/native-agent-types.js +90 -1
- package/packages/client/dist/continuity/outbox.js +117 -1
- package/packages/client/dist/continuity/runner-bootstrap-policy.js +172 -1
- package/packages/client/dist/continuity/sqlite-store.js +2536 -81
- package/packages/client/dist/effects/invoke-effect-gateways.js +62 -1
- package/packages/client/dist/errors/error-messages.js +228 -2
- package/packages/client/dist/errors/frontmatter-diagnostics.js +125 -1
- package/packages/client/dist/evidence/collector.js +347 -1
- package/packages/client/dist/evidence/index.js +1 -1
- package/packages/client/dist/evidence/types.js +2 -0
- package/packages/client/dist/fs/atomic-state-replace.js +152 -1
- package/packages/client/dist/generated/command-registry.generated.js +4076 -1
- package/packages/client/dist/generated/public-projections.generated.js +149 -1
- package/packages/client/dist/git/direct-yolo-c01-retry.js +138 -4
- package/packages/client/dist/git/direct-yolo-checkpoint.js +1234 -4
- package/packages/client/dist/git/git-lifecycle-step-ids.js +14 -1
- package/packages/client/dist/git/git-lifecycle-store.js +1938 -9
- package/packages/client/dist/git/legacy-story-branch-checkpoint.js +259 -9
- package/packages/client/dist/git/legacy-story-branch-setup.js +348 -4
- package/packages/client/dist/git/legacy-story-pr.js +3538 -70
- package/packages/client/dist/git/secure-filesystem-boundary.js +182 -5
- package/packages/client/dist/graph-retrieval/pre-command-hook.js +284 -1
- package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +237 -1
- package/packages/client/dist/i18n/first-run.js +60 -2
- package/packages/client/dist/index.js +47 -1
- package/packages/client/dist/invocation/invoke-input.js +24 -1
- package/packages/client/dist/license/index.js +5 -1
- package/packages/client/dist/license/license-client.js +304 -1
- package/packages/client/dist/machine/fingerprint.js +163 -2
- package/packages/client/dist/machine/index.js +5 -1
- package/packages/client/dist/memory/project-memory-path-scope.js +62 -1
- package/packages/client/dist/memory/project-memory-writer.js +1007 -40
- package/packages/client/dist/memory/shared-project-memory-types.js +253 -2
- package/packages/client/dist/migrations/project-identity-capability.js +50 -1
- package/packages/client/dist/migrations/project-identity.js +493 -7
- package/packages/client/dist/migrations/state-json.js +344 -4
- package/packages/client/dist/policy/architecture-policy.js +184 -2
- package/packages/client/dist/policy/index.js +7 -1
- package/packages/client/dist/policy/shared-policy-types.js +38 -0
- package/packages/client/dist/policy/subagent-approval-policy.js +55 -1
- package/packages/client/dist/private-execution/clean-baseline.js +279 -2
- package/packages/client/dist/private-execution/codex-executor.js +574 -4
- package/packages/client/dist/private-execution/codex-host-runtime.js +137 -1
- package/packages/client/dist/private-execution/codex-lifecycle-guard.js +97 -1
- package/packages/client/dist/private-execution/codex-scoped-permissions.js +128 -1
- package/packages/client/dist/private-execution/delivery.js +410 -13
- package/packages/client/dist/private-execution/diagnosis-root-review.js +294 -2
- package/packages/client/dist/private-execution/frozen-handoff.js +19 -6
- package/packages/client/dist/private-execution/implementation-validation.js +106 -2
- package/packages/client/dist/private-execution/isolated-admission.js +1432 -23
- package/packages/client/dist/private-execution/isolated-session.js +725 -5
- package/packages/client/dist/private-execution/isolated-workspace.js +122 -1
- package/packages/client/dist/private-execution/pact-consumer-runtime.js +409 -3
- package/packages/client/dist/private-execution/pact-test-source.js +258 -1
- package/packages/client/dist/private-execution/pact-toolchain.js +290 -3
- package/packages/client/dist/private-execution/pact-write-set.js +172 -1
- package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +406 -5
- package/packages/client/dist/private-execution/planning-generation-checkpoint.js +462 -5
- package/packages/client/dist/private-execution/planning-memory-checkpoint.js +139 -3
- package/packages/client/dist/private-execution/planning-research-checkpoint.js +325 -4
- package/packages/client/dist/private-execution/private-report-store.js +42 -1
- package/packages/client/dist/private-execution/property-test-candidate.js +103 -1
- package/packages/client/dist/private-execution/property-test-choices.js +147 -3
- package/packages/client/dist/private-execution/property-test-plan.js +271 -7
- package/packages/client/dist/private-execution/property-test-review.js +258 -4
- package/packages/client/dist/private-execution/property-test-runtime.js +372 -9
- package/packages/client/dist/private-execution/property-test-source.js +139 -1
- package/packages/client/dist/private-execution/root-checkpoint.js +80 -1
- package/packages/client/dist/private-execution/root-memory-git-evidence.js +135 -3
- package/packages/client/dist/private-execution/root-memory-recovery.js +93 -1
- package/packages/client/dist/private-execution/root-memory-request.js +29 -1
- package/packages/client/dist/private-execution/root-memory-scope.js +178 -1
- package/packages/client/dist/private-execution/root-memory-writer.js +102 -1
- package/packages/client/dist/private-execution/session-lease.js +63 -1
- package/packages/client/dist/private-execution/shared-private-admission-types.js +50 -1
- package/packages/client/dist/private-execution/shared-project-validation.js +66 -1
- package/packages/client/dist/private-execution/shared-root-memory-types.js +61 -1
- package/packages/client/dist/private-execution/ux-source-validation.js +372 -1
- package/packages/client/dist/private-execution/ux-write-set.js +191 -2
- package/packages/client/dist/resilience/circuit-breaker.js +170 -1
- package/packages/client/dist/resilience/degradation-manager.js +164 -1
- package/packages/client/dist/resilience/freshness-indicator.js +100 -1
- package/packages/client/dist/resilience/index.js +8 -1
- package/packages/client/dist/resilience/recovery-detector.js +74 -1
- package/packages/client/dist/resolvers/asset-resolver.js +13 -0
- package/packages/client/dist/resolvers/local-resolver.js +255 -8
- package/packages/client/dist/resolvers/remote-resolver.js +233 -1
- package/packages/client/dist/runner/binary.js +404 -1
- package/packages/client/dist/runner/cli.js +1571 -17
- package/packages/client/dist/runner/headless/adapters.js +49 -1
- package/packages/client/dist/runner/headless/claude-code-adapter.js +79 -1
- package/packages/client/dist/runner/headless/continuous-story-workspace.js +1628 -11
- package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +69 -1
- package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +110 -2
- package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +1236 -8
- package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +318 -2
- package/packages/client/dist/runner/headless/fake-adapter.js +62 -1
- package/packages/client/dist/runner/headless/managed-profile.js +132 -1
- package/packages/client/dist/runner/headless/opencode-adapter.js +145 -2
- package/packages/client/dist/runner/headless/prepared-story-bundle.js +43 -1
- package/packages/client/dist/runner/headless/resolver.js +231 -1
- package/packages/client/dist/runner/headless/run-yolo.js +885 -1
- package/packages/client/dist/runner/headless/run-yoloop.js +2574 -2
- package/packages/client/dist/runner/headless/types.js +574 -2
- package/packages/client/dist/runner/local-console.js +396 -13
- package/packages/client/dist/runner/portal-sync-worker.js +1487 -1
- package/packages/client/dist/runner/project-identity.js +106 -1
- package/packages/client/dist/runner/scheduler.js +995 -7
- package/packages/client/dist/runner-cli.js +18 -2
- package/packages/client/dist/state/invocation-shape.js +13 -1
- package/packages/client/dist/state/project-state-snapshot.js +1677 -1
- package/packages/client/dist/state/state-json-repair.js +785 -3
- package/packages/client/dist/state/structural-dag-projection.js +118 -1
- package/packages/client/dist/telemetry/index.js +5 -1
- package/packages/client/dist/telemetry/offline-queue.js +131 -1
- package/packages/client/dist/tier/index.js +5 -1
- package/packages/client/dist/tier/tier-aware-client.js +405 -1
- package/packages/client/dist/types/index.js +38 -1
- package/packages/client/dist/version.js +27 -1
- package/packages/client/dist/yolo/change-evidence-collector.js +126 -1
- package/packages/client/dist/yolo/invoke-envelope-capability.js +62 -1
- package/packages/client/dist/yolo/spec-envelope-collector.js +185 -5
- package/packages/client/dist/yoloop/discovery-hook.js +300 -2
- package/packages/client/dist/yoloop/index.js +8 -1
- package/packages/client/dist/yoloop/invoke-hooks.js +669 -12
- package/packages/client/dist/yoloop/shared-discover-epics.js +1007 -1
- package/packages/client/dist/yoloop/shared-yoloop-types.js +33 -1
- package/packages/client/dist/yoloop/target-normalization.js +180 -1
- package/packages/client/dist/yoloop/yoloop-client-state-store.js +1723 -1
- package/targets-stubs/antigravity/gemini.md +1 -1
- package/targets-stubs/antigravity/skill/SKILL.md +1 -1
- package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex-root.md +2 -2
- package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex.md +2 -2
- package/targets-stubs/codex/AGENTS.md +1 -1
- package/targets-stubs/cursor/agent.md +2 -2
- package/targets-stubs/gemini-cli/agent.md +2 -2
- package/targets-stubs/opencode/neocortex-root.md +1 -1
- package/targets-stubs/vscode/neocortex.agent.md +2 -2
|
@@ -1,11 +1,669 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Opt-in, root-owned physical browser capture. This produces observations,
|
|
2
|
+
* never a QA verdict or a story checkpoint. */
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import axe from 'axe-core';
|
|
7
|
+
import { planRootQaScan, rootQaScanEvidenceDirectory } from './qa-scan-review.js';
|
|
8
|
+
import { routeRootQaScanRequest } from './qa-scan-browser-route.js';
|
|
9
|
+
import { launchRootQaBrowser } from './qa-scan-browser-launch.js';
|
|
10
|
+
const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const LOAD_BUDGET_MS = 10_000;
|
|
12
|
+
const SAFE_ACTION = /^[a-z][a-z0-9-]{0,50}$/;
|
|
13
|
+
const SCENARIO_RATIONALE = 'Root executed the bounded SDD-linked synthetic UI scenario and checked local DOM results with all action-time network denied.';
|
|
14
|
+
function exactScenarioObject(value, keys) {
|
|
15
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
17
|
+
}
|
|
18
|
+
const scenarioText = (value, maximum) => typeof value === 'string'
|
|
19
|
+
&& value.length <= maximum && !/[\x00-\x1f\x7f]/.test(value);
|
|
20
|
+
/** Closed declarative plan. Selectors and values are data, never executable code. */
|
|
21
|
+
function readScenario(value) {
|
|
22
|
+
if (!exactScenarioObject(value, ['id', 'requirementSource', 'requirementIds', 'actions', 'checks'])
|
|
23
|
+
|| typeof value.id !== 'string' || !SAFE_ACTION.test(value.id)
|
|
24
|
+
|| typeof value.requirementSource !== 'string' || value.requirementSource.length > 256
|
|
25
|
+
|| !/^docs\/[A-Za-z0-9_./-]+\.md$/.test(value.requirementSource)
|
|
26
|
+
|| value.requirementSource.split('/').some(part => !part || part === '.' || part === '..')
|
|
27
|
+
|| !Array.isArray(value.requirementIds) || value.requirementIds.length < 1 || value.requirementIds.length > 32
|
|
28
|
+
|| value.requirementIds.some(id => typeof id !== 'string' || !/^[A-Z][A-Z0-9-]{1,63}$/.test(id))
|
|
29
|
+
|| new Set(value.requirementIds).size !== value.requirementIds.length
|
|
30
|
+
|| !Array.isArray(value.actions) || value.actions.length < 1 || value.actions.length > 32
|
|
31
|
+
|| !Array.isArray(value.checks) || value.checks.length < 1 || value.checks.length > 32
|
|
32
|
+
|| Buffer.byteLength(JSON.stringify(value)) > 16384)
|
|
33
|
+
throw new Error('qa-scan-review-scenario-invalid');
|
|
34
|
+
for (const item of value.actions) {
|
|
35
|
+
const fields = item?.kind === 'click' ? ['kind', 'selector'] : item?.kind === 'press'
|
|
36
|
+
? ['kind', 'selector', 'key'] : ['kind', 'selector', 'value'];
|
|
37
|
+
if (!exactScenarioObject(item, fields) || !scenarioText(item.selector, 256) || !item.selector
|
|
38
|
+
|| !['click', 'fill', 'select', 'press'].includes(String(item.kind))
|
|
39
|
+
|| ['fill', 'select'].includes(String(item.kind)) && !scenarioText(item.value, 512)
|
|
40
|
+
|| item.kind === 'press' && (typeof item.key !== 'string'
|
|
41
|
+
|| !['Tab', 'Enter', 'Space', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(item.key))) {
|
|
42
|
+
throw new Error('qa-scan-review-scenario-invalid');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const ids = new Set();
|
|
46
|
+
for (const item of value.checks) {
|
|
47
|
+
if (!exactScenarioObject(item, ['id', 'selector', 'kind', 'expected'])
|
|
48
|
+
|| typeof item.id !== 'string' || !SAFE_ACTION.test(item.id) || ids.has(item.id)
|
|
49
|
+
|| !scenarioText(item.selector, 256) || !item.selector
|
|
50
|
+
|| !['text', 'value', 'visible', 'enabled', 'checked'].includes(String(item.kind))
|
|
51
|
+
|| (item.kind === 'text' || item.kind === 'value' ? !scenarioText(item.expected, 512) : typeof item.expected !== 'boolean')) {
|
|
52
|
+
throw new Error('qa-scan-review-scenario-invalid');
|
|
53
|
+
}
|
|
54
|
+
ids.add(item.id);
|
|
55
|
+
}
|
|
56
|
+
// Freeze a detached plan so a trusted caller cannot mutate it while the browser waits.
|
|
57
|
+
const copy = structuredClone(value);
|
|
58
|
+
return Object.freeze({ ...copy, requirementIds: Object.freeze([...copy.requirementIds]),
|
|
59
|
+
actions: Object.freeze(copy.actions.map(action => Object.freeze(action))),
|
|
60
|
+
checks: Object.freeze(copy.checks.map(check => Object.freeze(check))) });
|
|
61
|
+
}
|
|
62
|
+
export function validateRootQaScanScenario(value) {
|
|
63
|
+
try {
|
|
64
|
+
return readScenario(value);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error('qa-scan-review-scenario-invalid');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function sha(bytes) {
|
|
71
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
72
|
+
}
|
|
73
|
+
function boundedJson(value) {
|
|
74
|
+
return Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
function observation(kind, captureMethod, suffix, bytes, capturedAt, rationale) {
|
|
77
|
+
if (bytes.length < 8 || bytes.length > MAX_ARTIFACT_BYTES) {
|
|
78
|
+
throw new Error('qa-scan-review-capture-artifact-invalid');
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({ kind, artifactId: `${kind}-${randomUUID()}.${suffix}`,
|
|
81
|
+
captureMethod, sha256: sha(bytes), capturedAt, rationale });
|
|
82
|
+
}
|
|
83
|
+
async function uniqueScenarioLocator(page, selector) {
|
|
84
|
+
// Use CSS only; custom selector engines and script evaluation are not admitted.
|
|
85
|
+
const locator = page.locator(`css=${selector}`);
|
|
86
|
+
if (await locator.count() !== 1)
|
|
87
|
+
throw new Error('qa-scan-review-scenario-target-ambiguous');
|
|
88
|
+
return locator;
|
|
89
|
+
}
|
|
90
|
+
export function scenarioRequirementDigest(root, scenario) {
|
|
91
|
+
const path = join(root, scenario.requirementSource);
|
|
92
|
+
let fd;
|
|
93
|
+
try {
|
|
94
|
+
if (realpathSync(path) !== path)
|
|
95
|
+
throw new Error();
|
|
96
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
97
|
+
const stat = fstatSync(fd);
|
|
98
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size < 1 || stat.size > 65536)
|
|
99
|
+
throw new Error();
|
|
100
|
+
const bytes = Buffer.alloc(65537);
|
|
101
|
+
let length = 0;
|
|
102
|
+
while (length < bytes.length) {
|
|
103
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
104
|
+
if (!count)
|
|
105
|
+
break;
|
|
106
|
+
length += count;
|
|
107
|
+
}
|
|
108
|
+
if (length !== stat.size || length > 65536)
|
|
109
|
+
throw new Error();
|
|
110
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length));
|
|
111
|
+
if (scenario.requirementIds.some(id => !new RegExp(`(^|[^A-Z0-9-])${id}([^A-Z0-9-]|$)`).test(source)))
|
|
112
|
+
throw new Error();
|
|
113
|
+
return sha(bytes.subarray(0, length));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error('qa-scan-review-scenario-requirements-unbound');
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (fd !== undefined)
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Private persisted linkage, reopened by root/worker triage after restart. */
|
|
124
|
+
export function readRootQaScanScenarioReceipt(projectRoot, surface, targetUrl, scenarioRecordSha256) {
|
|
125
|
+
try {
|
|
126
|
+
if (!/^[a-f0-9]{64}$/.test(scenarioRecordSha256))
|
|
127
|
+
throw new Error();
|
|
128
|
+
const root = resolve(projectRoot);
|
|
129
|
+
const plan = planRootQaScan(root, surface, targetUrl);
|
|
130
|
+
const directory = rootQaScanEvidenceDirectory(root);
|
|
131
|
+
const read = (path, limit) => {
|
|
132
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
133
|
+
try {
|
|
134
|
+
const stat = fstatSync(fd);
|
|
135
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.()
|
|
136
|
+
|| (stat.mode & 0o777) !== 0o600 || stat.size < 1 || stat.size > limit)
|
|
137
|
+
throw new Error();
|
|
138
|
+
const bytes = Buffer.alloc(limit + 1);
|
|
139
|
+
let length = 0;
|
|
140
|
+
while (length < bytes.length) {
|
|
141
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
142
|
+
if (!count)
|
|
143
|
+
break;
|
|
144
|
+
length += count;
|
|
145
|
+
}
|
|
146
|
+
const after = lstatSync(path);
|
|
147
|
+
if (length !== stat.size || length > limit || after.dev !== stat.dev || after.ino !== stat.ino
|
|
148
|
+
|| after.ctimeMs !== stat.ctimeMs || after.mtimeMs !== stat.mtimeMs || after.nlink !== 1)
|
|
149
|
+
throw new Error();
|
|
150
|
+
return bytes.subarray(0, length);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const raw = read(join(directory, `scenario-${scenarioRecordSha256}.json`), 32768);
|
|
157
|
+
if (sha(Buffer.concat([Buffer.from('root-qa-scenario-record-v1\0'), raw])) !== scenarioRecordSha256)
|
|
158
|
+
throw new Error();
|
|
159
|
+
const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
|
|
160
|
+
if (!exactScenarioObject(value, ['scenario', 'receipt']))
|
|
161
|
+
throw new Error();
|
|
162
|
+
const scenario = validateRootQaScanScenario(value.scenario);
|
|
163
|
+
const receipt = value.receipt;
|
|
164
|
+
if (!exactScenarioObject(receipt, ['schemaVersion', 'evidenceContractVersion', 'kind', 'sourceHead',
|
|
165
|
+
'planSha256', 'surface', 'targetSha256', 'observation', 'scenarioSha256', 'requirementSourceSha256',
|
|
166
|
+
'requirementIds', 'qaAccepted', 'checkpointAccepted', 'workflowContinuationAuthorized', 'localOnly', 'publicSafe'])
|
|
167
|
+
|| receipt.schemaVersion !== 1 || receipt.evidenceContractVersion !== 2 || receipt.kind !== 'root-qa-scan-interaction'
|
|
168
|
+
|| receipt.sourceHead !== plan.sourceHead || receipt.planSha256 !== plan.planSha256
|
|
169
|
+
|| receipt.surface !== surface || receipt.targetSha256 !== plan.targetSha256
|
|
170
|
+
|| receipt.scenarioSha256 !== sha(Buffer.from(JSON.stringify(scenario)))
|
|
171
|
+
|| receipt.requirementSourceSha256 !== scenarioRequirementDigest(root, scenario)
|
|
172
|
+
|| JSON.stringify(receipt.requirementIds) !== JSON.stringify(scenario.requirementIds)
|
|
173
|
+
|| receipt.qaAccepted !== false || receipt.checkpointAccepted !== false || receipt.workflowContinuationAuthorized !== false
|
|
174
|
+
|| receipt.localOnly !== true || receipt.publicSafe !== true
|
|
175
|
+
|| !exactScenarioObject(receipt.observation, ['kind', 'artifactId', 'captureMethod', 'sha256', 'capturedAt', 'rationale'])
|
|
176
|
+
|| receipt.observation.kind !== 'interaction' || receipt.observation.captureMethod !== 'browser-interaction'
|
|
177
|
+
|| receipt.observation.rationale !== SCENARIO_RATIONALE
|
|
178
|
+
|| !/^interaction-[a-f0-9-]{36}\.json$/.test(receipt.observation.artifactId))
|
|
179
|
+
throw new Error();
|
|
180
|
+
const capturedMs = Date.parse(receipt.observation.capturedAt);
|
|
181
|
+
if (!Number.isFinite(capturedMs) || new Date(capturedMs).toISOString() !== receipt.observation.capturedAt
|
|
182
|
+
|| capturedMs > Date.now() + 5 * 60000 || Date.now() - capturedMs > 60 * 60000)
|
|
183
|
+
throw new Error();
|
|
184
|
+
const artifact = read(join(directory, receipt.observation.artifactId), 16384);
|
|
185
|
+
if (sha(artifact) !== receipt.observation.sha256)
|
|
186
|
+
throw new Error();
|
|
187
|
+
const actual = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(artifact));
|
|
188
|
+
const expected = { schemaVersion: 2, syntheticTarget: true, networkAttemptsAfterAction: 0,
|
|
189
|
+
checks: scenario.checks.map(check => ({ id: `functional-${check.id}`, passed: true })) };
|
|
190
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)
|
|
191
|
+
|| planRootQaScan(root, surface, targetUrl).planSha256 !== plan.planSha256)
|
|
192
|
+
throw new Error();
|
|
193
|
+
return Object.freeze({ ...receipt, scenarioRecordSha256 });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error('qa-scan-review-scenario-record-unverified');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Generic synthetic UI scenarios. An operator grants the target, not a DOM marker.
|
|
200
|
+
* Backend mutations, navigation, downloads and sockets remain denied. */
|
|
201
|
+
export async function captureRootQaScanScenario(options) {
|
|
202
|
+
if (!options.authorizeScenario || options.dataScope !== 'synthetic-public') {
|
|
203
|
+
throw new Error('qa-scan-review-scenario-authorization-required');
|
|
204
|
+
}
|
|
205
|
+
const scenario = validateRootQaScanScenario(options.scenario);
|
|
206
|
+
const scenarioSha256 = sha(Buffer.from(JSON.stringify(scenario)));
|
|
207
|
+
const root = resolve(options.projectRoot);
|
|
208
|
+
const plan = planRootQaScan(root, options.surface, options.targetUrl);
|
|
209
|
+
const requirementSourceSha256 = scenarioRequirementDigest(root, scenario);
|
|
210
|
+
const target = new URL(options.targetUrl);
|
|
211
|
+
let browser;
|
|
212
|
+
try {
|
|
213
|
+
browser = await launchRootQaBrowser();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new Error('qa-scan-review-browser-unavailable');
|
|
217
|
+
}
|
|
218
|
+
let budgetTimer;
|
|
219
|
+
let budgetExceeded = false;
|
|
220
|
+
try {
|
|
221
|
+
const context = await browser.newContext({ serviceWorkers: 'block', acceptDownloads: false,
|
|
222
|
+
permissions: [], ignoreHTTPSErrors: false, viewport: { width: 1280, height: 900 } });
|
|
223
|
+
let started = false;
|
|
224
|
+
let denied = 0;
|
|
225
|
+
let disruptions = 0;
|
|
226
|
+
await context.route('**/*', async (route) => {
|
|
227
|
+
let allowed = false;
|
|
228
|
+
try {
|
|
229
|
+
const request = route.request();
|
|
230
|
+
const url = new URL(request.url());
|
|
231
|
+
allowed = !started && url.protocol === 'http:' && url.origin === target.origin
|
|
232
|
+
&& ['GET', 'HEAD'].includes(request.method());
|
|
233
|
+
}
|
|
234
|
+
catch { /* deny */ }
|
|
235
|
+
await routeRootQaScanRequest(route, allowed, () => { denied++; });
|
|
236
|
+
});
|
|
237
|
+
await context.routeWebSocket(/.*/, async (socket) => { denied++; await socket.close({ code: 1008, reason: 'blocked' }); });
|
|
238
|
+
const page = await context.newPage();
|
|
239
|
+
context.on('page', opened => { if (opened !== page) {
|
|
240
|
+
disruptions++;
|
|
241
|
+
void opened.close();
|
|
242
|
+
} });
|
|
243
|
+
page.on('framenavigated', () => { if (started)
|
|
244
|
+
disruptions++; });
|
|
245
|
+
page.on('download', () => { disruptions++; });
|
|
246
|
+
page.on('pageerror', () => { disruptions++; });
|
|
247
|
+
page.on('console', message => { if (message.type() === 'error')
|
|
248
|
+
disruptions++; });
|
|
249
|
+
const response = await page.goto(target.href, { waitUntil: 'load', timeout: 15000 });
|
|
250
|
+
if (!response || response.status() >= 400 || page.url() !== target.href || denied || disruptions) {
|
|
251
|
+
throw new Error('qa-scan-review-scenario-target-unavailable');
|
|
252
|
+
}
|
|
253
|
+
started = true;
|
|
254
|
+
budgetTimer = setTimeout(() => { budgetExceeded = true; void context.close().catch(() => undefined); }, 15000);
|
|
255
|
+
// One absolute action/check budget; individual calls cannot extend it.
|
|
256
|
+
const deadline = performance.now() + 15000;
|
|
257
|
+
const remaining = () => {
|
|
258
|
+
const value = Math.floor(deadline - performance.now());
|
|
259
|
+
if (value < 1)
|
|
260
|
+
throw new Error('qa-scan-review-scenario-budget-exhausted');
|
|
261
|
+
return Math.min(value, 2000);
|
|
262
|
+
};
|
|
263
|
+
for (const action of scenario.actions) {
|
|
264
|
+
const locator = await uniqueScenarioLocator(page, action.selector);
|
|
265
|
+
if (!(await locator.isVisible()) || !(await locator.isEnabled()))
|
|
266
|
+
throw new Error('qa-scan-review-scenario-action-unavailable');
|
|
267
|
+
const timeout = remaining();
|
|
268
|
+
if (action.kind === 'click')
|
|
269
|
+
await locator.click({ timeout });
|
|
270
|
+
else if (action.kind === 'fill')
|
|
271
|
+
await locator.fill(action.value, { timeout });
|
|
272
|
+
else if (action.kind === 'select')
|
|
273
|
+
await locator.selectOption(action.value, { timeout });
|
|
274
|
+
else if (action.kind === 'press')
|
|
275
|
+
await locator.press(action.key, { timeout });
|
|
276
|
+
if (denied || disruptions || page.url() !== target.href)
|
|
277
|
+
throw new Error('qa-scan-review-scenario-effects-denied');
|
|
278
|
+
}
|
|
279
|
+
const checks = [];
|
|
280
|
+
for (const check of scenario.checks) {
|
|
281
|
+
const locator = await uniqueScenarioLocator(page, check.selector);
|
|
282
|
+
const timeout = remaining();
|
|
283
|
+
const observed = check.kind === 'text' ? (await locator.textContent({ timeout }))?.trim()
|
|
284
|
+
: check.kind === 'value' ? await locator.inputValue({ timeout })
|
|
285
|
+
: check.kind === 'visible' ? await locator.isVisible()
|
|
286
|
+
: check.kind === 'enabled' ? await locator.isEnabled() : await locator.isChecked({ timeout });
|
|
287
|
+
if (observed !== check.expected)
|
|
288
|
+
throw new Error('qa-scan-review-scenario-check-failed');
|
|
289
|
+
checks.push({ id: `functional-${check.id}`, passed: true });
|
|
290
|
+
}
|
|
291
|
+
await page.waitForTimeout(250);
|
|
292
|
+
if (denied || disruptions || page.url() !== target.href)
|
|
293
|
+
throw new Error('qa-scan-review-scenario-effects-denied');
|
|
294
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256) {
|
|
295
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
296
|
+
}
|
|
297
|
+
if (scenarioRequirementDigest(root, scenario) !== requirementSourceSha256)
|
|
298
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
299
|
+
const bytes = boundedJson({ schemaVersion: 2, syntheticTarget: true, networkAttemptsAfterAction: 0, checks });
|
|
300
|
+
const row = observation('interaction', 'browser-interaction', 'json', bytes, new Date().toISOString(), SCENARIO_RATIONALE);
|
|
301
|
+
const directory = rootQaScanEvidenceDirectory(root);
|
|
302
|
+
const handle = openSync(join(directory, row.artifactId), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
303
|
+
try {
|
|
304
|
+
writeFileSync(handle, bytes);
|
|
305
|
+
fsyncSync(handle);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
closeSync(handle);
|
|
309
|
+
}
|
|
310
|
+
const directoryHandle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
311
|
+
try {
|
|
312
|
+
fsyncSync(directoryHandle);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
closeSync(directoryHandle);
|
|
316
|
+
}
|
|
317
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256)
|
|
318
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
319
|
+
const receipt = Object.freeze({ schemaVersion: 1, evidenceContractVersion: 2, kind: 'root-qa-scan-interaction',
|
|
320
|
+
sourceHead: plan.sourceHead, planSha256: plan.planSha256, surface: plan.surface, targetSha256: plan.targetSha256,
|
|
321
|
+
observation: row, scenarioSha256, requirementSourceSha256, requirementIds: scenario.requirementIds,
|
|
322
|
+
qaAccepted: false, checkpointAccepted: false, workflowContinuationAuthorized: false, localOnly: true, publicSafe: true });
|
|
323
|
+
const record = Buffer.from(JSON.stringify({ scenario, receipt }));
|
|
324
|
+
const scenarioRecordSha256 = sha(Buffer.concat([Buffer.from('root-qa-scenario-record-v1\0'), record]));
|
|
325
|
+
const recordHandle = openSync(join(directory, `scenario-${scenarioRecordSha256}.json`), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(recordHandle, record);
|
|
328
|
+
fsyncSync(recordHandle);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
closeSync(recordHandle);
|
|
332
|
+
}
|
|
333
|
+
const parentHandle = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
334
|
+
try {
|
|
335
|
+
fsyncSync(parentHandle);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
closeSync(parentHandle);
|
|
339
|
+
}
|
|
340
|
+
return readRootQaScanScenarioReceipt(root, options.surface, options.targetUrl, scenarioRecordSha256);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (budgetExceeded)
|
|
344
|
+
throw new Error('qa-scan-review-scenario-budget-exhausted');
|
|
345
|
+
if (error instanceof Error && /^qa-scan-review-[a-z-]+$/.test(error.message))
|
|
346
|
+
throw error;
|
|
347
|
+
throw new Error('qa-scan-review-scenario-unavailable');
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
if (budgetTimer)
|
|
351
|
+
clearTimeout(budgetTimer);
|
|
352
|
+
await browser.close().catch(() => undefined);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Observe only loopback GET/HEAD loads, including popups. A page-initiated
|
|
356
|
+
* mutation request or WebSocket is blocked even at the named origin; a fresh
|
|
357
|
+
* browser profile/cookie jar is never borrowed from the user's host. */
|
|
358
|
+
export async function captureRootQaScan(options) {
|
|
359
|
+
if (!options.authorizeCapture)
|
|
360
|
+
throw new Error('qa-scan-review-authorization-or-target-required');
|
|
361
|
+
const root = resolve(options.projectRoot);
|
|
362
|
+
const plan = planRootQaScan(root, options.surface, options.targetUrl);
|
|
363
|
+
const target = new URL(options.targetUrl);
|
|
364
|
+
let browser;
|
|
365
|
+
try {
|
|
366
|
+
browser = await launchRootQaBrowser();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
throw new Error('qa-scan-review-browser-unavailable');
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const context = await browser.newContext({
|
|
373
|
+
viewport: { width: 1440, height: 900 },
|
|
374
|
+
serviceWorkers: 'block', acceptDownloads: false,
|
|
375
|
+
permissions: [], ignoreHTTPSErrors: false,
|
|
376
|
+
});
|
|
377
|
+
let blockedRequests = 0;
|
|
378
|
+
let blockedWebSockets = 0;
|
|
379
|
+
await context.route('**/*', async (route) => {
|
|
380
|
+
let allowed = false;
|
|
381
|
+
try {
|
|
382
|
+
const requested = new URL(route.request().url());
|
|
383
|
+
allowed = requested.protocol === 'http:' && requested.origin === target.origin
|
|
384
|
+
&& (route.request().method() === 'GET' || route.request().method() === 'HEAD');
|
|
385
|
+
}
|
|
386
|
+
catch { /* Opaque or malformed browser requests are denied. */ }
|
|
387
|
+
await routeRootQaScanRequest(route, allowed, () => { blockedRequests++; });
|
|
388
|
+
});
|
|
389
|
+
// HTTP routing does not cover WebSocket handshakes. Even a same-origin
|
|
390
|
+
// socket can mutate server state during an allegedly passive scan.
|
|
391
|
+
await context.routeWebSocket(/.*/, async (socket) => {
|
|
392
|
+
blockedWebSockets++;
|
|
393
|
+
await socket.close({ code: 1008, reason: 'blocked' });
|
|
394
|
+
});
|
|
395
|
+
const page = await context.newPage();
|
|
396
|
+
context.on('page', (opened) => { if (opened !== page)
|
|
397
|
+
void opened.close(); });
|
|
398
|
+
let mainFrameNavigations = 0;
|
|
399
|
+
page.on('framenavigated', (frame) => {
|
|
400
|
+
if (frame === page.mainFrame() && frame.url() !== 'about:blank')
|
|
401
|
+
mainFrameNavigations++;
|
|
402
|
+
});
|
|
403
|
+
page.setDefaultTimeout(15_000);
|
|
404
|
+
let consoleErrors = 0;
|
|
405
|
+
let pageErrors = 0;
|
|
406
|
+
let failedRequests = 0;
|
|
407
|
+
let requestsObserved = 0;
|
|
408
|
+
// These listeners precede the first navigation. HTTP 4xx/5xx are failures
|
|
409
|
+
// even though Playwright does not emit requestfailed for an HTTP response.
|
|
410
|
+
page.on('console', (message) => { if (message.type() === 'error')
|
|
411
|
+
consoleErrors++; });
|
|
412
|
+
page.on('pageerror', () => { pageErrors++; });
|
|
413
|
+
page.on('request', () => { requestsObserved++; });
|
|
414
|
+
page.on('requestfailed', () => { failedRequests++; });
|
|
415
|
+
page.on('response', (response) => { if (response.status() >= 400)
|
|
416
|
+
failedRequests++; });
|
|
417
|
+
const response = await page.goto(target.href, { waitUntil: 'load', timeout: 15_000 });
|
|
418
|
+
if (!response || response.status() >= 400 || page.url() !== target.href
|
|
419
|
+
|| mainFrameNavigations !== 1) {
|
|
420
|
+
throw new Error('qa-scan-review-capture-target-unavailable');
|
|
421
|
+
}
|
|
422
|
+
const stableTarget = () => {
|
|
423
|
+
if (page.url() !== target.href || mainFrameNavigations !== 1) {
|
|
424
|
+
throw new Error('qa-scan-review-capture-target-drift');
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
stableTarget();
|
|
428
|
+
const visual = await page.screenshot({ type: 'png', animations: 'disabled', timeout: 15_000 });
|
|
429
|
+
stableTarget();
|
|
430
|
+
await page.addScriptTag({ content: axe.source });
|
|
431
|
+
const axeResult = await page.evaluate('window.axe.run(document)');
|
|
432
|
+
stableTarget();
|
|
433
|
+
if (!axeResult || typeof axeResult !== 'object') {
|
|
434
|
+
throw new Error('qa-scan-review-capture-accessibility-unavailable');
|
|
435
|
+
}
|
|
436
|
+
const checkedAxe = axeResult;
|
|
437
|
+
if (!Array.isArray(checkedAxe.violations) || !Array.isArray(checkedAxe.incomplete)
|
|
438
|
+
|| !Array.isArray(checkedAxe.passes)) {
|
|
439
|
+
throw new Error('qa-scan-review-capture-accessibility-unavailable');
|
|
440
|
+
}
|
|
441
|
+
// A keyboard focus probe is non-activating: it never clicks an arbitrary
|
|
442
|
+
// application control or performs a business mutation.
|
|
443
|
+
await page.keyboard.press('Tab');
|
|
444
|
+
const keyboardFocus = await page.evaluate(`(() => {
|
|
3
445
|
const active = document.activeElement;
|
|
4
446
|
return !!active && active !== document.body && active !== document.documentElement
|
|
5
447
|
&& active.matches(':focus-visible');
|
|
6
|
-
})()`)
|
|
448
|
+
})()`);
|
|
449
|
+
const navigationDuration = await page.evaluate(`(() => {
|
|
7
450
|
const entry = performance.getEntriesByType('navigation')[0];
|
|
8
451
|
return entry ? entry.duration : null;
|
|
9
|
-
})()`);
|
|
452
|
+
})()`);
|
|
453
|
+
stableTarget();
|
|
454
|
+
await page.setViewportSize({ width: 375, height: 812 });
|
|
455
|
+
const responsive = await page.screenshot({ type: 'png', animations: 'disabled', timeout: 15_000 });
|
|
456
|
+
stableTarget();
|
|
457
|
+
await page.setViewportSize({ width: 768, height: 1024 });
|
|
458
|
+
const responsiveTablet = await page.screenshot({ type: 'png', animations: 'disabled', timeout: 15_000 });
|
|
459
|
+
stableTarget();
|
|
460
|
+
await page.setViewportSize({ width: 1024, height: 768 });
|
|
461
|
+
const responsiveDesktop = await page.screenshot({ type: 'png', animations: 'disabled', timeout: 15_000 });
|
|
462
|
+
stableTarget();
|
|
463
|
+
const observedMs = typeof navigationDuration === 'number' && Number.isFinite(navigationDuration)
|
|
464
|
+
? Math.max(0, Math.round(navigationDuration)) : null;
|
|
465
|
+
const capturedAt = new Date().toISOString();
|
|
466
|
+
const rows = [
|
|
467
|
+
{ kind: 'visual', method: 'browser-screenshot', suffix: 'png',
|
|
468
|
+
bytes: Buffer.from(visual), rationale: 'Chrome headless desktop viewport 1440x900 at the exact local target before QA review.' },
|
|
469
|
+
{ kind: 'responsive', method: 'browser-screenshot', suffix: 'png',
|
|
470
|
+
bytes: Buffer.from(responsive), rationale: 'Chrome headless mobile-width viewport 375x812 at the same local target.' },
|
|
471
|
+
{ kind: 'responsive-tablet', method: 'browser-screenshot', suffix: 'png',
|
|
472
|
+
bytes: Buffer.from(responsiveTablet), rationale: 'Chrome headless tablet-width viewport 768x1024 at the same local target.' },
|
|
473
|
+
{ kind: 'responsive-desktop', method: 'browser-screenshot', suffix: 'png',
|
|
474
|
+
bytes: Buffer.from(responsiveDesktop), rationale: 'Chrome headless compact-desktop viewport 1024x768 at the same local target.' },
|
|
475
|
+
{ kind: 'accessibility', method: 'axe-scan', suffix: 'json',
|
|
476
|
+
bytes: boundedJson({ schemaVersion: 1, engine: 'axe-core',
|
|
477
|
+
violationCount: checkedAxe.violations.length, incompleteCount: checkedAxe.incomplete.length,
|
|
478
|
+
passCount: checkedAxe.passes.length }),
|
|
479
|
+
rationale: 'Axe-core ran against the rendered local document; counts preserve incomplete and violated rules.' },
|
|
480
|
+
{ kind: 'interaction', method: 'browser-interaction', suffix: 'json',
|
|
481
|
+
bytes: boundedJson({ schemaVersion: 1, checks: [{ id: 'keyboard-focus', passed: keyboardFocus === true }] }),
|
|
482
|
+
rationale: 'Non-activating Tab key probe observed a visible focus target without clicking application controls.' },
|
|
483
|
+
{ kind: 'console-network', method: 'console-capture', suffix: 'json',
|
|
484
|
+
bytes: boundedJson({ schemaVersion: 1, initialCapture: true, consoleErrors, pageErrors,
|
|
485
|
+
failedRequests: failedRequests + blockedRequests + blockedWebSockets, requestsObserved }),
|
|
486
|
+
rationale: 'Console, page-error, HTTP and WebSocket routing listeners were installed before navigation.' },
|
|
487
|
+
{ kind: 'performance', method: 'performance-trace', suffix: 'json',
|
|
488
|
+
bytes: boundedJson({ schemaVersion: 1, metric: 'load', observedMs, budgetMs: LOAD_BUDGET_MS }),
|
|
489
|
+
rationale: 'Browser Navigation Timing load duration was compared with the fixed 10000 ms local budget.' },
|
|
490
|
+
];
|
|
491
|
+
const observations = rows.map((row) => observation(row.kind, row.method, row.suffix, row.bytes, capturedAt, row.rationale));
|
|
492
|
+
stableTarget();
|
|
493
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256) {
|
|
494
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
495
|
+
}
|
|
496
|
+
const directory = rootQaScanEvidenceDirectory(root);
|
|
497
|
+
for (let index = 0; index < rows.length; index++) {
|
|
498
|
+
const path = join(directory, observations[index].artifactId);
|
|
499
|
+
const handle = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL
|
|
500
|
+
| (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
501
|
+
try {
|
|
502
|
+
writeFileSync(handle, rows[index].bytes);
|
|
503
|
+
fsyncSync(handle);
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
closeSync(handle);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const directoryHandle = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
510
|
+
try {
|
|
511
|
+
fsyncSync(directoryHandle);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
closeSync(directoryHandle);
|
|
515
|
+
}
|
|
516
|
+
stableTarget();
|
|
517
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256) {
|
|
518
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
519
|
+
}
|
|
520
|
+
return Object.freeze({ schemaVersion: 1, evidenceContractVersion: 2,
|
|
521
|
+
kind: 'root-qa-scan-capture',
|
|
522
|
+
sourceHead: plan.sourceHead, planSha256: plan.planSha256,
|
|
523
|
+
surface: plan.surface, targetSha256: plan.targetSha256,
|
|
524
|
+
evidenceCount: 8, observations: Object.freeze(observations),
|
|
525
|
+
qaAccepted: false, checkpointAccepted: false,
|
|
526
|
+
workflowContinuationAuthorized: false, localOnly: true, publicSafe: true });
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (error instanceof Error && error.message.startsWith('qa-scan-review-'))
|
|
530
|
+
throw error;
|
|
531
|
+
throw new Error('qa-scan-review-capture-unavailable');
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
await browser.close().catch(() => undefined);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** Explicit synthetic-only local-state interaction. The click runs with every
|
|
538
|
+
* subsequent browser network request denied, so it cannot authorize an
|
|
539
|
+
* arbitrary live-app workflow or a server-side business mutation. */
|
|
540
|
+
export async function captureRootQaScanInteraction(options) {
|
|
541
|
+
if (!options.authorizeInteraction || !SAFE_ACTION.test(options.actionId)
|
|
542
|
+
|| !SAFE_ACTION.test(options.expectedStatus)) {
|
|
543
|
+
throw new Error('qa-scan-review-interaction-authorization-required');
|
|
544
|
+
}
|
|
545
|
+
const root = resolve(options.projectRoot);
|
|
546
|
+
const plan = planRootQaScan(root, options.surface, options.targetUrl);
|
|
547
|
+
const target = new URL(options.targetUrl);
|
|
548
|
+
let browser;
|
|
549
|
+
try {
|
|
550
|
+
browser = await launchRootQaBrowser();
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
throw new Error('qa-scan-review-browser-unavailable');
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 },
|
|
557
|
+
serviceWorkers: 'block', acceptDownloads: false, permissions: [],
|
|
558
|
+
ignoreHTTPSErrors: false });
|
|
559
|
+
let actionStarted = false;
|
|
560
|
+
let networkAttempts = 0;
|
|
561
|
+
let initialBlocked = 0;
|
|
562
|
+
await context.route('**/*', async (route) => {
|
|
563
|
+
let allowed = false;
|
|
564
|
+
try {
|
|
565
|
+
const request = route.request();
|
|
566
|
+
const requested = new URL(request.url());
|
|
567
|
+
allowed = !actionStarted && requested.protocol === 'http:'
|
|
568
|
+
&& requested.origin === target.origin
|
|
569
|
+
&& (request.method() === 'GET' || request.method() === 'HEAD');
|
|
570
|
+
}
|
|
571
|
+
catch { /* Unparseable requests are denied. */ }
|
|
572
|
+
await routeRootQaScanRequest(route, allowed, () => {
|
|
573
|
+
if (actionStarted)
|
|
574
|
+
networkAttempts++;
|
|
575
|
+
else
|
|
576
|
+
initialBlocked++;
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
await context.routeWebSocket(/.*/, async (socket) => {
|
|
580
|
+
if (actionStarted)
|
|
581
|
+
networkAttempts++;
|
|
582
|
+
else
|
|
583
|
+
initialBlocked++;
|
|
584
|
+
await socket.close({ code: 1008, reason: 'blocked' });
|
|
585
|
+
});
|
|
586
|
+
const page = await context.newPage();
|
|
587
|
+
let popups = 0;
|
|
588
|
+
let navigationsAfterAction = 0;
|
|
589
|
+
let downloadsAfterAction = 0;
|
|
590
|
+
context.on('page', (opened) => { if (opened !== page) {
|
|
591
|
+
popups++;
|
|
592
|
+
void opened.close();
|
|
593
|
+
} });
|
|
594
|
+
page.on('framenavigated', () => { if (actionStarted)
|
|
595
|
+
navigationsAfterAction++; });
|
|
596
|
+
page.on('download', () => { if (actionStarted)
|
|
597
|
+
downloadsAfterAction++; });
|
|
598
|
+
let pageErrors = 0;
|
|
599
|
+
let consoleErrors = 0;
|
|
600
|
+
page.on('pageerror', () => { pageErrors++; });
|
|
601
|
+
page.on('console', (message) => { if (message.type() === 'error')
|
|
602
|
+
consoleErrors++; });
|
|
603
|
+
const response = await page.goto(target.href, { waitUntil: 'load', timeout: 15_000 });
|
|
604
|
+
if (!response || response.status() >= 400 || page.url() !== target.href
|
|
605
|
+
|| initialBlocked !== 0 || pageErrors !== 0 || consoleErrors !== 0) {
|
|
606
|
+
throw new Error('qa-scan-review-interaction-target-unavailable');
|
|
607
|
+
}
|
|
608
|
+
const action = page.locator(`button[type="button"][data-neocortex-qa-safe="true"][data-neocortex-qa-action="${options.actionId}"]`);
|
|
609
|
+
const status = page.locator(`[data-neocortex-qa-status="${options.actionId}"]`);
|
|
610
|
+
if (await page.locator('body[data-neocortex-qa-synthetic="1"]').count() !== 1
|
|
611
|
+
|| await action.count() !== 1 || await status.count() !== 1
|
|
612
|
+
|| !(await action.isVisible()) || !(await status.isVisible())
|
|
613
|
+
|| (await status.textContent())?.trim() === options.expectedStatus) {
|
|
614
|
+
throw new Error('qa-scan-review-interaction-contract-unavailable');
|
|
615
|
+
}
|
|
616
|
+
actionStarted = true;
|
|
617
|
+
await action.click({ timeout: 5_000 });
|
|
618
|
+
await page.waitForFunction(`({ actionId, expectedStatus }) =>
|
|
10
619
|
document.querySelector('[data-neocortex-qa-status="' + actionId + '"]')
|
|
11
|
-
?.textContent?.trim() === expectedStatus`,{actionId:
|
|
620
|
+
?.textContent?.trim() === expectedStatus`, { actionId: options.actionId, expectedStatus: options.expectedStatus }, { timeout: 5_000 });
|
|
621
|
+
await page.waitForTimeout(250);
|
|
622
|
+
if (networkAttempts !== 0 || popups !== 0 || navigationsAfterAction !== 0
|
|
623
|
+
|| downloadsAfterAction !== 0 || pageErrors !== 0 || consoleErrors !== 0
|
|
624
|
+
|| page.url() !== target.href) {
|
|
625
|
+
throw new Error('qa-scan-review-interaction-effects-denied');
|
|
626
|
+
}
|
|
627
|
+
const bytes = boundedJson({ schemaVersion: 2, syntheticTarget: true,
|
|
628
|
+
networkAttemptsAfterAction: networkAttempts,
|
|
629
|
+
checks: [{ id: `functional-${options.actionId}`, passed: true }] });
|
|
630
|
+
const row = observation('interaction', 'browser-interaction', 'json', bytes, new Date().toISOString(), 'Root synthetic action changed one visible local status without browser network effects.');
|
|
631
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256) {
|
|
632
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
633
|
+
}
|
|
634
|
+
const directory = rootQaScanEvidenceDirectory(root);
|
|
635
|
+
const handle = openSync(join(directory, row.artifactId), constants.O_WRONLY | constants.O_CREAT
|
|
636
|
+
| constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
637
|
+
try {
|
|
638
|
+
writeFileSync(handle, bytes);
|
|
639
|
+
fsyncSync(handle);
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
closeSync(handle);
|
|
643
|
+
}
|
|
644
|
+
const directoryHandle = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
645
|
+
try {
|
|
646
|
+
fsyncSync(directoryHandle);
|
|
647
|
+
}
|
|
648
|
+
finally {
|
|
649
|
+
closeSync(directoryHandle);
|
|
650
|
+
}
|
|
651
|
+
if (planRootQaScan(root, options.surface, options.targetUrl).planSha256 !== plan.planSha256) {
|
|
652
|
+
throw new Error('qa-scan-review-capture-source-drift');
|
|
653
|
+
}
|
|
654
|
+
return Object.freeze({ schemaVersion: 1, evidenceContractVersion: 2,
|
|
655
|
+
kind: 'root-qa-scan-interaction',
|
|
656
|
+
sourceHead: plan.sourceHead, planSha256: plan.planSha256,
|
|
657
|
+
surface: plan.surface, targetSha256: plan.targetSha256,
|
|
658
|
+
observation: row, qaAccepted: false, checkpointAccepted: false,
|
|
659
|
+
workflowContinuationAuthorized: false, localOnly: true, publicSafe: true });
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (error instanceof Error && error.message.startsWith('qa-scan-review-'))
|
|
663
|
+
throw error;
|
|
664
|
+
throw new Error('qa-scan-review-interaction-unavailable');
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
await browser.close().catch(() => undefined);
|
|
668
|
+
}
|
|
669
|
+
}
|