@yemi33/minions 0.1.2448 → 0.1.2449
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/bin/cli-api-client.js +1 -1
- package/bin/install-internal-minions.js +1382 -44
- package/bin/install-layout.js +150 -0
- package/bin/minions.js +460 -167
- package/dashboard/docs/typography.md +65 -12
- package/dashboard/js/command-center.js +58 -4
- package/dashboard/js/detail-panel.js +36 -0
- package/dashboard/js/memory-panel.js +59 -12
- package/dashboard/js/qa.js +179 -20
- package/dashboard/js/refresh.js +148 -12
- package/dashboard/js/render-dispatch.js +3 -4
- package/dashboard/js/render-inbox.js +2 -2
- package/dashboard/js/render-other.js +3 -3
- package/dashboard/js/render-pipelines.js +14 -0
- package/dashboard/js/render-plans.js +57 -9
- package/dashboard/js/render-prd.js +132 -23
- package/dashboard/js/render-prs.js +195 -166
- package/dashboard/js/render-schedules.js +63 -3
- package/dashboard/js/render-utils.js +3 -3
- package/dashboard/js/render-watches.js +19 -3
- package/dashboard/js/render-work-items.js +238 -30
- package/dashboard/js/settings.js +205 -54
- package/dashboard/js/utils.js +51 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/qa.html +1 -16
- package/dashboard/pages/work.html +40 -0
- package/dashboard/shared/cc-limits.js +79 -0
- package/dashboard/shared/pr-filters.js +21 -38
- package/dashboard/shared/project-git-summary.js +1 -1
- package/dashboard/shared/record-filters.js +169 -0
- package/dashboard/shared/watches-source.js +1 -1
- package/dashboard/shared/welcome-popup.js +1 -1
- package/dashboard/shared/wi-filters.js +302 -0
- package/dashboard/slim/body.html +1 -0
- package/dashboard/slim/js/command-send.js +26 -0
- package/dashboard/slim/js/modals-tiles.js +380 -39
- package/dashboard/slim/js/status.js +13 -21
- package/dashboard/slim/layout.html +1 -0
- package/dashboard/slim/panel-bootstrap.js +6 -2
- package/dashboard/slim/styles.css +38 -0
- package/dashboard/styles.css +159 -55
- package/dashboard-build.js +13 -2
- package/dashboard.js +956 -423
- package/docs/README.md +11 -6
- package/docs/api-errors.md +2 -2
- package/docs/architecture-review-2026-07-09.md +1 -1
- package/docs/architecture.excalidraw +2 -2
- package/docs/auto-discovery.md +18 -9
- package/docs/branch-derivation.md +4 -4
- package/docs/capture-demos.js +39 -2
- package/docs/ci-runner-canary.md +123 -0
- package/docs/claude-md-propagation.md +2 -2
- package/docs/cloud-agent-dispatch.md +204 -0
- package/docs/command-center.md +7 -7
- package/docs/completion-reports.md +43 -20
- package/docs/constants.md +10 -3
- package/docs/constellation-bridge.md +134 -6
- package/docs/constellation-style-telemetry.md +4 -4
- package/docs/contracts/capability-protocol.v1.json +165 -0
- package/docs/cooldown-merge-semantics.md +12 -12
- package/docs/copilot-cli-schema.md +7 -7
- package/docs/cross-repo-plans.md +10 -10
- package/docs/dead-code-audit-retractions.md +5 -5
- package/docs/default-branch-ci.md +173 -0
- package/docs/deprecated.json +31 -31
- package/docs/design-inbox-entries-schema.md +3 -3
- package/docs/design-language.md +1051 -0
- package/docs/design-state-storage.md +11 -11
- package/docs/diagnostics-crash-reports.md +9 -9
- package/docs/diagnostics-memory.md +5 -5
- package/docs/documentation-audit-2026-07-09.md +7 -7
- package/docs/engine-restart.md +90 -5
- package/docs/harness-mode.md +1 -1
- package/docs/internal-install.md +338 -39
- package/docs/kb-dedup-duplicate-pair-investigation.md +5 -5
- package/docs/kb-pr3223-cascade-archiving.md +1 -1
- package/docs/kb-pr696-merge-conflict-docs.md +6 -6
- package/docs/kb-sweep.md +35 -35
- package/docs/keep-processes.md +1 -1
- package/docs/live-checkout-mode.md +30 -30
- package/docs/managed-spawn.md +18 -14
- package/docs/named-agents.md +7 -7
- package/docs/plan-lifecycle.md +69 -2
- package/docs/pr-author-identity.md +114 -0
- package/docs/pr-auto-fix-dispatch.md +19 -4
- package/docs/pr-comment-followup.md +6 -6
- package/docs/pr-review-fix-loop.md +59 -10
- package/docs/process-termination.md +40 -0
- package/docs/proposals/repo-pool-for-live-checkout.md +13 -13
- package/docs/qa-runbook-lifecycle.md +367 -17
- package/docs/qa-runbooks.md +3 -3
- package/docs/rfc-completion-json.md +18 -18
- package/docs/runtime-adapters.md +26 -21
- package/docs/security.md +6 -6
- package/docs/self-improvement.md +4 -4
- package/docs/shared-lifecycle-module-map.md +473 -472
- package/docs/skills.md +52 -3
- package/docs/slim-ux/concepts.md +121 -116
- package/docs/specs/agent-configurability.md +18 -18
- package/docs/specs/agent-rename.md +18 -18
- package/docs/team-memory.md +38 -21
- package/docs/timeouts-and-liveness.md +118 -10
- package/docs/tutorials/01-install-and-connect.md +1 -1
- package/docs/watches.md +40 -39
- package/docs/workspace-manifests.md +4 -4
- package/docs/worktree-lifecycle.md +293 -14
- package/engine/README.md +46 -0
- package/engine/{ado-comment.js → ado/comment.js} +8 -8
- package/engine/{ado-git-auth.js → ado/git-auth.js} +4 -4
- package/engine/{ado.js → ado/index.js} +417 -63
- package/engine/{ado-status.js → ado/status.js} +6 -8
- package/engine/{ado-token.js → ado/token.js} +1 -1
- package/engine/{acp-transport.js → agents/acp-transport.js} +62 -22
- package/engine/{agent-worker-pool.js → agents/agent-worker-pool.js} +17 -8
- package/engine/{cc-worker-pool.js → agents/cc-worker-pool.js} +16 -6
- package/engine/{claude-md-context.js → agents/claude-md-context.js} +5 -5
- package/engine/{harness-context.js → agents/harness-context.js} +5 -5
- package/engine/{harness.js → agents/harness.js} +3 -3
- package/engine/{llm.js → agents/llm.js} +18 -14
- package/engine/{model-discovery.js → agents/model-discovery.js} +2 -2
- package/engine/{playbook.js → agents/playbook.js} +155 -22
- package/engine/{pooled-agent-process.js → agents/pooled-agent-process.js} +14 -12
- package/engine/{preflight.js → agents/preflight.js} +29 -10
- package/engine/{spawn-agent.js → agents/spawn-agent.js} +25 -14
- package/engine/{spawn-phase-watchdog.js → agents/spawn-phase-watchdog.js} +16 -7
- package/engine/{steering.js → agents/steering.js} +5 -5
- package/engine/{tools-inventory.js → agents/tools-inventory.js} +2 -2
- package/engine/{agent-api-validation.js → api/agent-api-validation.js} +2 -2
- package/engine/{api-validation.js → api/api-validation.js} +1 -1
- package/engine/api/bridge.js +787 -0
- package/engine/{cc-api-validation.js → api/cc-api-validation.js} +1 -1
- package/engine/api/companion.js +560 -0
- package/engine/{content-api-validation.js → api/content-api-validation.js} +2 -2
- package/engine/{pr-issue-validation.js → api/pr-issue-validation.js} +33 -6
- package/engine/{settings-validation.js → api/settings-validation.js} +32 -4
- package/engine/api-contracts/agent-content.js +4 -4
- package/engine/api-contracts/capability-manifest.js +236 -0
- package/engine/api-contracts/capability-protocol.js +333 -0
- package/engine/api-contracts/cc-ops.js +1 -1
- package/engine/api-contracts/config-runtime.js +5 -0
- package/engine/api-contracts/core.js +28 -1
- package/engine/api-contracts/index.js +100 -0
- package/engine/api-contracts/orchestration.js +18 -5
- package/engine/api-contracts/pull-requests.js +37 -6
- package/engine/api-contracts/qa-process.js +29 -6
- package/engine/api-contracts/work-plan-prd.js +21 -1
- package/engine/cloud/contract.js +212 -0
- package/engine/cloud/index.js +159 -0
- package/engine/{execution-model.js → core/execution-model.js} +1 -1
- package/engine/{features.js → core/features.js} +4 -4
- package/engine/{operator-identity.js → core/operator-identity.js} +1 -1
- package/engine/{queries.js → core/queries.js} +201 -36
- package/engine/{safe-expr.js → core/safe-expr.js} +1 -1
- package/engine/{shared.js → core/shared.js} +1637 -175
- package/engine/{stdio-timestamps.js → core/stdio-timestamps.js} +1 -1
- package/engine/{untrusted-fence.js → core/untrusted-fence.js} +3 -3
- package/engine/db/index.js +11 -2
- package/engine/db/migrations/002-dispatches.js +3 -3
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/db/migrations/006-metrics.js +1 -1
- package/engine/db/migrations/007-watches.js +2 -2
- package/engine/db/migrations/008-small-state.js +1 -1
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/010-pr-links.js +1 -1
- package/engine/db/migrations/011-remaining-state.js +1 -1
- package/engine/db/migrations/012-steering-deliveries.js +2 -2
- package/engine/db/migrations/013-backfill-broken-note-links.js +1 -1
- package/engine/db/migrations/014-pr-fix-target-prefs.js +2 -2
- package/engine/db/migrations/015-plans-prds.js +0 -0
- package/engine/db/migrations/018-sql-only-cutover.js +2 -2
- package/engine/db/migrations/021-archived-work-items.js +1 -1
- package/engine/db/migrations/022-global-cc-session.js +1 -1
- package/engine/db/migrations/023-engine-state.js +1 -1
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +1 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +1 -1
- package/engine/db/migrations/029-repair-reused-versions.js +20 -0
- package/engine/db/migrations/031-pr-author-identity.js +137 -0
- package/engine/{consolidation.js → memory/consolidation.js} +6 -6
- package/engine/{kb-sweep-runner.js → memory/kb-sweep-runner.js} +2 -2
- package/engine/{kb-sweep.js → memory/kb-sweep.js} +9 -7
- package/engine/{memory-retrieval.js → memory/memory-retrieval.js} +46 -4
- package/engine/{memory-store.js → memory/memory-store.js} +3 -3
- package/engine/{promotion.js → memory/promotion.js} +3 -3
- package/engine/{review-learning-backfill.js → memory/review-learning-backfill.js} +6 -6
- package/engine/{review-learning.js → memory/review-learning.js} +10 -5
- package/engine/{diagnostics-memory.js → observability/diagnostics-memory.js} +1 -1
- package/engine/{logs-store.js → observability/logs-store.js} +5 -5
- package/engine/{metrics-store.js → observability/metrics-store.js} +4 -4
- package/engine/{check-status.js → operations/check-status.js} +3 -3
- package/engine/{cli.js → operations/cli.js} +271 -113
- package/engine/{distribution.js → operations/distribution.js} +5 -6
- package/engine/{cleanup.js → orchestration/cleanup.js} +72 -45
- package/engine/{cooldown.js → orchestration/cooldown.js} +5 -5
- package/engine/{dispatch-events.js → orchestration/dispatch-events.js} +2 -2
- package/engine/{dispatch.js → orchestration/dispatch.js} +129 -36
- package/engine/orchestration/failed-scheduled-cleanup.js +274 -0
- package/engine/{lifecycle.js → orchestration/lifecycle.js} +198 -90
- package/engine/{meeting.js → orchestration/meeting.js} +6 -16
- package/engine/{pipeline.js → orchestration/pipeline.js} +12 -12
- package/engine/{pre-dispatch-eval.js → orchestration/pre-dispatch-eval.js} +10 -9
- package/engine/{routing.js → orchestration/routing.js} +3 -3
- package/engine/{schedule-bootstrap.js → orchestration/schedule-bootstrap.js} +4 -4
- package/engine/{scheduler.js → orchestration/scheduler.js} +38 -8
- package/engine/{timeout.js → orchestration/timeout.js} +158 -109
- package/engine/{db-events.js → persistence/db-events.js} +2 -2
- package/engine/{dispatch-store.js → persistence/dispatch-store.js} +7 -7
- package/engine/{inbox-store.js → persistence/inbox-store.js} +2 -2
- package/engine/{note-link-backfill.js → persistence/note-link-backfill.js} +4 -4
- package/engine/{pr-fix-target-store.js → persistence/pr-fix-target-store.js} +8 -8
- package/engine/{pull-requests-store.js → persistence/pull-requests-store.js} +21 -7
- package/engine/{small-state-store.js → persistence/small-state-store.js} +31 -31
- package/engine/persistence/state-operations.js +350 -0
- package/engine/{steering-store.js → persistence/steering-store.js} +6 -6
- package/engine/{issues.js → planning/issues.js} +2 -2
- package/engine/{plan-prd-validation.js → planning/plan-prd-validation.js} +8 -2
- package/engine/planning/prd-result-sidecar.js +190 -0
- package/engine/{prd-store.js → planning/prd-store.js} +17 -17
- package/engine/{project-discovery.js → planning/project-discovery.js} +5 -5
- package/engine/{projects.js → planning/projects.js} +10 -10
- package/engine/{resolve-area.js → planning/resolve-area.js} +1 -1
- package/engine/{work-item-validation.js → planning/work-item-validation.js} +39 -3
- package/engine/{work-items-store.js → planning/work-items-store.js} +29 -21
- package/engine/{keep-process-sweep.js → processes/keep-process-sweep.js} +57 -17
- package/engine/{managed-spawn-launcher.js → processes/managed-spawn-launcher.js} +3 -3
- package/engine/{managed-spawn.js → processes/managed-spawn.js} +97 -46
- package/engine/{process-utils.js → processes/process-utils.js} +599 -55
- package/engine/{abandoned-pr-reconciliation.js → providers/abandoned-pr-reconciliation.js} +17 -7
- package/engine/{comment-classifier.js → providers/comment-classifier.js} +85 -17
- package/engine/{comment-format.js → providers/comment-format.js} +5 -5
- package/engine/{gh-comment.js → providers/gh-comment.js} +15 -15
- package/engine/{gh-token.js → providers/gh-token.js} +4 -4
- package/engine/{github.js → providers/github.js} +131 -54
- package/engine/{pr-action.js → providers/pr-action.js} +13 -12
- package/engine/{pr-clone-keep.js → providers/pr-clone-keep.js} +7 -7
- package/engine/{pr-devbox.js → providers/pr-devbox.js} +6 -6
- package/engine/{pr-fix-target.js → providers/pr-fix-target.js} +13 -13
- package/engine/{pr-remote-patch.js → providers/pr-remote-patch.js} +4 -4
- package/engine/{pr-resolve.js → providers/pr-resolve.js} +7 -7
- package/engine/{pr-temp-clone.js → providers/pr-temp-clone.js} +5 -5
- package/engine/{pr-track.js → providers/pr-track.js} +11 -13
- package/engine/{shared-branch-pr-reconcile.js → providers/shared-branch-pr-reconcile.js} +4 -4
- package/engine/qa/auto-prd-qa.js +313 -0
- package/engine/{qa-from-prd.js → qa/from-prd.js} +42 -12
- package/engine/qa/prd-session.js +240 -0
- package/engine/{qa-process-validation.js → qa/process-validation.js} +14 -9
- package/engine/{qa-runbooks.js → qa/runbooks.js} +1 -1
- package/engine/{qa-runs.js → qa/runs.js} +286 -15
- package/engine/{qa-sessions.js → qa/sessions.js} +595 -49
- package/engine/qa/visual-journey.js +654 -0
- package/engine/{qa-runners.js → qa-runners/index.js} +7 -7
- package/engine/qa-runners/maestro.js +3 -3
- package/engine/qa-runners/playwright.js +2 -2
- package/engine/{restart-health.js → recovery/restart-health.js} +48 -4
- package/engine/recovery/stop-stack.js +607 -0
- package/engine/{supervisor.js → recovery/supervisor.js} +105 -175
- package/engine/{watchdog.js → recovery/watchdog.js} +136 -13
- package/engine/runtimes/claude.js +14 -12
- package/engine/runtimes/codex.js +8 -6
- package/engine/runtimes/copilot.js +17 -16
- package/engine/{watch-actions.js → watches/actions.js} +13 -13
- package/engine/{watches.js → watches/index.js} +43 -32
- package/engine/{watches-store.js → watches/store.js} +4 -4
- package/engine/{create-pr-worktree.js → worktrees/create-pr.js} +1 -1
- package/engine/{worktree-gc.js → worktrees/gc.js} +70 -22
- package/engine/worktrees/inventory.js +671 -0
- package/engine/{live-checkout.js → worktrees/live-checkout.js} +4 -4
- package/engine/{worktree-pool.js → worktrees/pool.js} +2 -2
- package/engine/{worktree-preflight.js → worktrees/preflight.js} +1 -0
- package/engine/worktrees/quarantine-refs.js +173 -0
- package/engine.js +1137 -208
- package/minions.js +147 -77
- package/package.json +10 -6
- package/playbooks/_pr-description-audit.md +110 -78
- package/playbooks/build-fix-complex.md +2 -0
- package/playbooks/fix.md +16 -12
- package/playbooks/implement-shared.md +2 -0
- package/playbooks/implement.md +19 -20
- package/playbooks/plan-to-prd.md +18 -3
- package/playbooks/qa-session-draft.md +136 -1
- package/playbooks/qa-session-execute.md +80 -2
- package/playbooks/qa-session-setup.md +17 -1
- package/playbooks/qa-validate.md +1 -1
- package/playbooks/setup.md +2 -0
- package/playbooks/shared-rules.md +25 -32
- package/playbooks/templates/followup-dispatch.md +4 -3
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +19 -27
- package/watch-plugins/README.md +92 -0
- package/watch-plugins/ado-author-prs.js +336 -0
- package/watch-plugins/gh-author-prs.js +375 -0
- package/watch-plugins/http.js +474 -0
- package/watch-plugins/teams-channel.js +869 -0
- package/docs/dev-composite-workflow.md +0 -101
- package/docs/pr-screenshots/pr-886/after-single-header.png +0 -0
- package/docs/pr-screenshots/pr-886/before-duplicate-header.png +0 -0
- package/docs/pr-screenshots/pr-895/01-cancellation-reason-detail.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-default.png +0 -0
- package/docs/pr-screenshots/pr-901/projects-tab-fmf-selected.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE-crop.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-916/model-picker-dropdown-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/visual-evidence-ci.md +0 -103
- package/engine/bridge.js +0 -379
- package/engine/quarantine-refs.js +0 -103
- package/engine/state-operations.js +0 -178
- /package/engine/{steering-constraints.js → agents/steering-constraints.js} +0 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/qa/visual-journey.js — the DRAFT-phase visual-journey manifest
|
|
3
|
+
* (W-msb9kgs402813a97-b).
|
|
4
|
+
*
|
|
5
|
+
* Before this module, DRAFT's only structured output was
|
|
6
|
+
* `structuredCompletion.testFile`, so an API-only test file passed straight
|
|
7
|
+
* through `engine/qa/sessions.js#handleDraftComplete` into EXECUTE. A session
|
|
8
|
+
* that asked for screenshots or video could therefore reach a green qa-run
|
|
9
|
+
* having never opened a browser, and on a multi-project session it could
|
|
10
|
+
* silently ignore every co-service origin.
|
|
11
|
+
*
|
|
12
|
+
* The manifest is owned by the SESSION layer, NOT inferred from the drafted
|
|
13
|
+
* test file: a source-string scan of a runner-native test is a guess, and a
|
|
14
|
+
* guess cannot be a gate. The DRAFT agent DECLARES what it drafted in a
|
|
15
|
+
* single-use sidecar; the engine validates that declaration against the
|
|
16
|
+
* session's own spec and fails closed before EXECUTE is ever queued.
|
|
17
|
+
*
|
|
18
|
+
* Sidecar contract (modelled on engine/qa/runs.js#prepareResultSidecar /
|
|
19
|
+
* #consumeResultSidecar):
|
|
20
|
+
*
|
|
21
|
+
* agents/<agentId>/qa-session-draft-result.json
|
|
22
|
+
*
|
|
23
|
+
* `prepareDraftResultSidecar` clears stale content at spawn time;
|
|
24
|
+
* `consumeDraftResultSidecar` does an atomic read-then-unlink and returns
|
|
25
|
+
* `{ ok: false, reason }` for missing / unreadable / malformed /
|
|
26
|
+
* session-id-mismatched files. Exactly-once, like every other Minions sidecar.
|
|
27
|
+
*
|
|
28
|
+
* Manifest shape:
|
|
29
|
+
*
|
|
30
|
+
* {
|
|
31
|
+
* "sessionId": "qas-...",
|
|
32
|
+
* "journeys": [{
|
|
33
|
+
* "id": "login",
|
|
34
|
+
* "name": "Operator signs in and lands on the dashboard",
|
|
35
|
+
* "kind": "browser" | "api",
|
|
36
|
+
* "projects": ["web"],
|
|
37
|
+
* "services": ["qa-session-qas-...", "qa-session-qas-...-api"],
|
|
38
|
+
* "origins": ["http://localhost:7346"],
|
|
39
|
+
* "steps": ["open /login", "submit credentials"],
|
|
40
|
+
* "assertions": ["dashboard header is visible"],
|
|
41
|
+
* "evidence": { "screenshots": ["login.png"], "video": ["login.webm"] }
|
|
42
|
+
* }]
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* Every list is capped in length AND per-entry byte size, and the encoded
|
|
46
|
+
* manifest itself is byte-capped, so a runaway agent cannot inflate the
|
|
47
|
+
* session record.
|
|
48
|
+
*
|
|
49
|
+
* ── EXECUTE side (W-msb9kgs402813a97-c) ────────────────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* The manifest is a PLAN; `validateEvidenceCoverage` at the bottom of this
|
|
52
|
+
* file is the PROOF. Before `engine/qa/sessions.js#handleExecuteComplete` is
|
|
53
|
+
* allowed to choose DONE, it checks the accepted manifest against what the
|
|
54
|
+
* qa-run actually produced: registered artifacts per requested capture type,
|
|
55
|
+
* those artifacts existing on disk (non-zero, inside the run's own artifacts
|
|
56
|
+
* dir), and per-journey `journeyCoverage` covering every project and
|
|
57
|
+
* managed-spawn the session owns. A green run that cannot show its evidence
|
|
58
|
+
* fails with `qa-session-evidence-incomplete` instead of closing the session.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const fs = require('fs');
|
|
62
|
+
const path = require('path');
|
|
63
|
+
const shared = require('../core/shared');
|
|
64
|
+
|
|
65
|
+
const DRAFT_RESULT_SIDECAR_FILENAME = 'qa-session-draft-result.json';
|
|
66
|
+
|
|
67
|
+
const JOURNEY_KINDS = Object.freeze({ BROWSER: 'browser', API: 'api' });
|
|
68
|
+
const VALID_JOURNEY_KINDS = Object.freeze(new Set(Object.values(JOURNEY_KINDS)));
|
|
69
|
+
|
|
70
|
+
// Which capture toggle demands which planned artifact extension, and which
|
|
71
|
+
// registered artifact `type` values count as that kind of evidence at EXECUTE
|
|
72
|
+
// time. Kept as data so a new capture type is one row, not a new branch in
|
|
73
|
+
// either gate.
|
|
74
|
+
const EVIDENCE_CONTRACT = Object.freeze({
|
|
75
|
+
screenshots: {
|
|
76
|
+
field: 'screenshots',
|
|
77
|
+
ext: '.png',
|
|
78
|
+
artifactTypes: Object.freeze(['screenshot', 'screenshots', 'image']),
|
|
79
|
+
},
|
|
80
|
+
video: {
|
|
81
|
+
field: 'video',
|
|
82
|
+
ext: '.webm',
|
|
83
|
+
artifactTypes: Object.freeze(['video', 'recording']),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Failure class stamped on the session when the gate rejects. Operators grep
|
|
88
|
+
// this out of engine.log, so it is a constant rather than an inline literal.
|
|
89
|
+
const VISUAL_COVERAGE_FAILURE_CLASS = 'qa-session-draft-visual-coverage';
|
|
90
|
+
|
|
91
|
+
// W-msb9kgs402813a97-c — the EXECUTE-side sibling: a green qa-run whose
|
|
92
|
+
// evidence does not actually exist (or only covers one project) fails the
|
|
93
|
+
// session with this class instead of transitioning it to done.
|
|
94
|
+
const EVIDENCE_COVERAGE_FAILURE_CLASS = 'qa-session-evidence-incomplete';
|
|
95
|
+
|
|
96
|
+
const LIMITS = Object.freeze({
|
|
97
|
+
journeysMax: 20,
|
|
98
|
+
idMaxBytes: 64,
|
|
99
|
+
nameMaxBytes: 200,
|
|
100
|
+
// projects / services / origins entries per journey
|
|
101
|
+
listMax: 20,
|
|
102
|
+
stepsMax: 50,
|
|
103
|
+
assertionsMax: 50,
|
|
104
|
+
// planned evidence entries per capture type per journey
|
|
105
|
+
evidenceMax: 20,
|
|
106
|
+
// per-entry cap for every string inside a list
|
|
107
|
+
entryMaxBytes: 500,
|
|
108
|
+
// whole-sidecar cap, checked before anything is walked
|
|
109
|
+
manifestMaxBytes: 64 * 1024,
|
|
110
|
+
agentIdMax: 128,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const _AGENT_ID_RE = /^[a-zA-Z0-9._-]+$/;
|
|
114
|
+
|
|
115
|
+
function _isNonEmptyString(v) {
|
|
116
|
+
return typeof v === 'string' && v.length > 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _bytes(str) {
|
|
120
|
+
return Buffer.byteLength(String(str), 'utf8');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Sidecar ────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function draftResultSidecarPath(agentId) {
|
|
126
|
+
const segment = typeof agentId === 'string' ? agentId : '';
|
|
127
|
+
if (!segment || segment.length > LIMITS.agentIdMax || !_AGENT_ID_RE.test(segment)
|
|
128
|
+
|| segment === '.' || segment === '..') {
|
|
129
|
+
throw new Error(`qa-visual-journey: invalid agent id for draft result sidecar: ${agentId}`);
|
|
130
|
+
}
|
|
131
|
+
return path.join(shared.MINIONS_DIR, 'agents', segment, DRAFT_RESULT_SIDECAR_FILENAME);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Clear any stale draft-result sidecar before a DRAFT dispatch starts.
|
|
136
|
+
*
|
|
137
|
+
* Load-bearing on the re-draft path: `editDraft` re-queues DRAFT for the SAME
|
|
138
|
+
* session id, so a leftover manifest from a previous attempt would pass the
|
|
139
|
+
* session-id check and be consumed as if it described the new draft.
|
|
140
|
+
*/
|
|
141
|
+
function prepareDraftResultSidecar(agentId, sessionId) {
|
|
142
|
+
if (!_isNonEmptyString(sessionId)) {
|
|
143
|
+
throw new Error(`qa-visual-journey: invalid session id for draft result sidecar: ${sessionId}`);
|
|
144
|
+
}
|
|
145
|
+
const filePath = draftResultSidecarPath(agentId);
|
|
146
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(filePath);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code !== 'ENOENT') {
|
|
151
|
+
throw new Error(`qa-visual-journey: could not clear draft result sidecar for ${sessionId}: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return filePath;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read-then-unlink the draft-result sidecar exactly once.
|
|
159
|
+
*
|
|
160
|
+
* @returns {{ok:true, manifest:object, filePath:string, cleanupError?:string}
|
|
161
|
+
* |{ok:false, reason:string, filePath:string, detail?:string,
|
|
162
|
+
* actualSessionId?:string, expectedSessionId?:string,
|
|
163
|
+
* cleanupError?:string}}
|
|
164
|
+
*/
|
|
165
|
+
function consumeDraftResultSidecar(agentId, expectedSessionId) {
|
|
166
|
+
if (!_isNonEmptyString(expectedSessionId)) {
|
|
167
|
+
throw new Error(`qa-visual-journey: invalid expected session id for draft result sidecar: ${expectedSessionId}`);
|
|
168
|
+
}
|
|
169
|
+
const filePath = draftResultSidecarPath(agentId);
|
|
170
|
+
let raw;
|
|
171
|
+
try {
|
|
172
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: err.code === 'ENOENT' ? 'missing' : 'read-error',
|
|
177
|
+
detail: err.code === 'ENOENT' ? '' : err.message,
|
|
178
|
+
filePath,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let outcome;
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(raw);
|
|
185
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
186
|
+
outcome = { ok: false, reason: 'malformed', detail: 'expected a JSON object', filePath };
|
|
187
|
+
} else if (parsed.sessionId !== expectedSessionId) {
|
|
188
|
+
outcome = {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: 'session-id-mismatch',
|
|
191
|
+
actualSessionId: typeof parsed.sessionId === 'string' ? parsed.sessionId : '',
|
|
192
|
+
expectedSessionId,
|
|
193
|
+
filePath,
|
|
194
|
+
};
|
|
195
|
+
} else {
|
|
196
|
+
outcome = { ok: true, manifest: parsed, filePath };
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
outcome = { ok: false, reason: 'malformed', detail: err.message, filePath };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(filePath);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err.code !== 'ENOENT') outcome.cleanupError = err.message;
|
|
206
|
+
}
|
|
207
|
+
return outcome;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Manifest validation ────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function _requestedCaptureTypes(session) {
|
|
213
|
+
const capture = (session && session.spec && session.spec.capture) || {};
|
|
214
|
+
return Object.keys(EVIDENCE_CONTRACT).filter(key => !!capture[key]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* True when the session asked for screenshots or video — the ONLY condition
|
|
219
|
+
* under which a visual journey is demanded. A logs-only session (or a session
|
|
220
|
+
* with no capture at all) keeps its historical pass-through behaviour.
|
|
221
|
+
*/
|
|
222
|
+
function isVisualCaptureRequested(session) {
|
|
223
|
+
return _requestedCaptureTypes(session).length > 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _sessionProjects(session) {
|
|
227
|
+
const projects = session && session.spec && session.spec.projects;
|
|
228
|
+
return Array.isArray(projects) ? projects.filter(_isNonEmptyString) : [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The managed-spawn names the manifest must cover.
|
|
233
|
+
*
|
|
234
|
+
* Callers may inject an inventory (`opts.services`, either bare names or the
|
|
235
|
+
* `{name, project, ...}` rows `engine.js#buildQaSessionServices` produces);
|
|
236
|
+
* otherwise it is derived from the session itself. A lookup that throws is
|
|
237
|
+
* FAIL-CLOSED — an unknown inventory rejects rather than silently waiving the
|
|
238
|
+
* coverage requirement.
|
|
239
|
+
*/
|
|
240
|
+
function _requiredServiceNames(session, opts) {
|
|
241
|
+
if (Array.isArray(opts && opts.services)) {
|
|
242
|
+
const names = [];
|
|
243
|
+
for (const entry of opts.services) {
|
|
244
|
+
if (_isNonEmptyString(entry)) names.push(entry);
|
|
245
|
+
else if (entry && typeof entry === 'object' && _isNonEmptyString(entry.name)) names.push(entry.name);
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, names };
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const names = require('./sessions').managedSpawnNamesForSession(session);
|
|
251
|
+
return { ok: true, names: Array.isArray(names) ? names.filter(_isNonEmptyString) : [] };
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return { ok: false, names: [], error: err.message };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _normalizeStringList(value, label, max, errors) {
|
|
258
|
+
if (value === undefined || value === null) return [];
|
|
259
|
+
if (!Array.isArray(value)) {
|
|
260
|
+
errors.push(`${label} must be an array of strings`);
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
if (value.length > max) {
|
|
264
|
+
errors.push(`${label} has ${value.length} entries (cap ${max})`);
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const out = [];
|
|
268
|
+
for (const entry of value) {
|
|
269
|
+
if (!_isNonEmptyString(entry)) {
|
|
270
|
+
errors.push(`${label} entries must be non-empty strings`);
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
if (_bytes(entry) > LIMITS.entryMaxBytes) {
|
|
274
|
+
errors.push(`${label} entry exceeds ${LIMITS.entryMaxBytes} bytes`);
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
out.push(entry);
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _normalizeJourney(raw, index, errors) {
|
|
283
|
+
const label = `journeys[${index}]`;
|
|
284
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
285
|
+
errors.push(`${label} must be a plain object`);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
if (!_isNonEmptyString(raw.id) || _bytes(raw.id) > LIMITS.idMaxBytes) {
|
|
289
|
+
errors.push(`${label}.id must be a non-empty string of at most ${LIMITS.idMaxBytes} bytes`);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (!_isNonEmptyString(raw.name) || _bytes(raw.name) > LIMITS.nameMaxBytes) {
|
|
293
|
+
errors.push(`${label}.name must be a non-empty string of at most ${LIMITS.nameMaxBytes} bytes`);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (!VALID_JOURNEY_KINDS.has(raw.kind)) {
|
|
297
|
+
errors.push(`${label}.kind must be one of: ${[...VALID_JOURNEY_KINDS].join(', ')}`);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const before = errors.length;
|
|
302
|
+
const journey = {
|
|
303
|
+
id: raw.id,
|
|
304
|
+
name: raw.name,
|
|
305
|
+
kind: raw.kind,
|
|
306
|
+
projects: _normalizeStringList(raw.projects, `${label}.projects`, LIMITS.listMax, errors),
|
|
307
|
+
services: _normalizeStringList(raw.services, `${label}.services`, LIMITS.listMax, errors),
|
|
308
|
+
origins: _normalizeStringList(raw.origins, `${label}.origins`, LIMITS.listMax, errors),
|
|
309
|
+
steps: _normalizeStringList(raw.steps, `${label}.steps`, LIMITS.stepsMax, errors),
|
|
310
|
+
assertions: _normalizeStringList(raw.assertions, `${label}.assertions`, LIMITS.assertionsMax, errors),
|
|
311
|
+
evidence: { screenshots: [], video: [] },
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const evidence = raw.evidence;
|
|
315
|
+
if (evidence !== undefined && evidence !== null) {
|
|
316
|
+
if (typeof evidence !== 'object' || Array.isArray(evidence)) {
|
|
317
|
+
errors.push(`${label}.evidence must be a plain object`);
|
|
318
|
+
} else {
|
|
319
|
+
for (const key of Object.keys(EVIDENCE_CONTRACT)) {
|
|
320
|
+
journey.evidence[key] = _normalizeStringList(
|
|
321
|
+
evidence[key], `${label}.evidence.${key}`, LIMITS.evidenceMax, errors,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return errors.length === before ? journey : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Validate (and normalize) a DRAFT visual-journey manifest against its session.
|
|
331
|
+
*
|
|
332
|
+
* Shape errors short-circuit — coverage cannot be judged on a manifest whose
|
|
333
|
+
* journeys did not parse. Coverage errors are collected together so one
|
|
334
|
+
* re-draft can fix everything the gate objects to.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} session - the qa-sessions record
|
|
337
|
+
* @param {object|null} manifest - parsed sidecar contents (null when absent)
|
|
338
|
+
* @param {object} [opts]
|
|
339
|
+
* @param {Array} [opts.services] - injected managed-spawn inventory
|
|
340
|
+
* @returns {{ok:true, required:boolean, manifest:object|null}
|
|
341
|
+
* |{ok:false, required:boolean, errors:string[], error:string}}
|
|
342
|
+
*/
|
|
343
|
+
function validateVisualJourneyManifest(session, manifest, opts = {}) {
|
|
344
|
+
const sessionId = (session && session.id) || '(unknown session)';
|
|
345
|
+
const requestedCapture = _requestedCaptureTypes(session);
|
|
346
|
+
const required = requestedCapture.length > 0;
|
|
347
|
+
const reject = (errors) => ({ ok: false, required, errors, error: errors.join(' ') });
|
|
348
|
+
|
|
349
|
+
if (manifest === undefined || manifest === null) {
|
|
350
|
+
if (!required) return { ok: true, required, manifest: null };
|
|
351
|
+
return reject([
|
|
352
|
+
`QA Session ${sessionId} requested visual capture (${requestedCapture.join(', ')}) but DRAFT ` +
|
|
353
|
+
`declared no visual-journey manifest — write the ${DRAFT_RESULT_SIDECAR_FILENAME} sidecar with a ` +
|
|
354
|
+
'`journeys` array describing what the drafted test actually exercises.',
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Shape ────────────────────────────────────────────────────────────────
|
|
359
|
+
const shapeErrors = [];
|
|
360
|
+
if (typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
361
|
+
return reject([`visual-journey manifest for ${sessionId} must be a plain object.`]);
|
|
362
|
+
}
|
|
363
|
+
let encodedBytes = 0;
|
|
364
|
+
try {
|
|
365
|
+
encodedBytes = _bytes(JSON.stringify(manifest));
|
|
366
|
+
} catch (err) {
|
|
367
|
+
return reject([`visual-journey manifest for ${sessionId} is not JSON-encodable: ${err.message}`]);
|
|
368
|
+
}
|
|
369
|
+
if (encodedBytes > LIMITS.manifestMaxBytes) {
|
|
370
|
+
return reject([
|
|
371
|
+
`visual-journey manifest for ${sessionId} is ${encodedBytes} bytes ` +
|
|
372
|
+
`(cap ${LIMITS.manifestMaxBytes}) — trim steps/assertions and keep the journey list to what the test covers.`,
|
|
373
|
+
]);
|
|
374
|
+
}
|
|
375
|
+
if (manifest.sessionId !== sessionId) {
|
|
376
|
+
return reject([
|
|
377
|
+
`visual-journey manifest declares sessionId "${manifest.sessionId || ''}" but this DRAFT belongs to ` +
|
|
378
|
+
`${sessionId} — the sidecar must name its own session.`,
|
|
379
|
+
]);
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(manifest.journeys)) {
|
|
382
|
+
return reject([`visual-journey manifest for ${sessionId} must carry a \`journeys\` array.`]);
|
|
383
|
+
}
|
|
384
|
+
if (manifest.journeys.length > LIMITS.journeysMax) {
|
|
385
|
+
return reject([
|
|
386
|
+
`visual-journey manifest for ${sessionId} declares ${manifest.journeys.length} journeys ` +
|
|
387
|
+
`(cap ${LIMITS.journeysMax}).`,
|
|
388
|
+
]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const journeys = [];
|
|
392
|
+
const seenIds = new Set();
|
|
393
|
+
for (let i = 0; i < manifest.journeys.length; i++) {
|
|
394
|
+
const journey = _normalizeJourney(manifest.journeys[i], i, shapeErrors);
|
|
395
|
+
if (!journey) continue;
|
|
396
|
+
if (seenIds.has(journey.id)) {
|
|
397
|
+
shapeErrors.push(`journeys[${i}].id "${journey.id}" is a duplicate — journey ids must be unique.`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
seenIds.add(journey.id);
|
|
401
|
+
journeys.push(journey);
|
|
402
|
+
}
|
|
403
|
+
if (shapeErrors.length) {
|
|
404
|
+
return reject([`visual-journey manifest for ${sessionId} is malformed: ${shapeErrors.join('; ')}.`]);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const normalized = { sessionId, journeys };
|
|
408
|
+
if (!required) return { ok: true, required, manifest: normalized };
|
|
409
|
+
|
|
410
|
+
// ── Coverage gate (visual capture requested) ─────────────────────────────
|
|
411
|
+
const errors = [];
|
|
412
|
+
|
|
413
|
+
const browserJourneys = journeys.filter(j => j.kind === JOURNEY_KINDS.BROWSER);
|
|
414
|
+
if (browserJourneys.length === 0) {
|
|
415
|
+
const kinds = journeys.length ? [...new Set(journeys.map(j => j.kind))].join(', ') : 'none';
|
|
416
|
+
errors.push(
|
|
417
|
+
`QA Session ${sessionId} requested visual capture (${requestedCapture.join(', ')}) but the manifest ` +
|
|
418
|
+
`declares no kind:"browser" journey (declared kinds: ${kinds}) — an API-only draft cannot produce ` +
|
|
419
|
+
'screenshots or video.',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const declaredProjects = new Set(journeys.flatMap(j => j.projects));
|
|
424
|
+
const missingProjects = _sessionProjects(session).filter(p => !declaredProjects.has(p));
|
|
425
|
+
if (missingProjects.length) {
|
|
426
|
+
errors.push(
|
|
427
|
+
`visual-journey manifest does not cover project(s): ${missingProjects.join(', ')} — every project in ` +
|
|
428
|
+
`the session (${_sessionProjects(session).join(', ')}) must appear in some journey's \`projects\`.`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const serviceLookup = _requiredServiceNames(session, opts);
|
|
433
|
+
if (!serviceLookup.ok) {
|
|
434
|
+
errors.push(
|
|
435
|
+
`visual-journey manifest could not be checked against session ${sessionId}'s managed-spawn inventory ` +
|
|
436
|
+
`(${serviceLookup.error}) — refusing to advance to EXECUTE without service coverage.`,
|
|
437
|
+
);
|
|
438
|
+
} else {
|
|
439
|
+
const declaredServices = new Set(journeys.flatMap(j => j.services));
|
|
440
|
+
const missingServices = serviceLookup.names.filter(n => !declaredServices.has(n));
|
|
441
|
+
if (missingServices.length) {
|
|
442
|
+
errors.push(
|
|
443
|
+
`visual-journey manifest does not cover service(s): ${missingServices.join(', ')} — every ` +
|
|
444
|
+
`managed-spawn the session owns (${serviceLookup.names.join(', ')}) must appear in some journey's ` +
|
|
445
|
+
'`services`, addressed at its OWN origin.',
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const type of requestedCapture) {
|
|
451
|
+
const { field, ext } = EVIDENCE_CONTRACT[type];
|
|
452
|
+
const planned = journeys.some(j => j.evidence[field].some(
|
|
453
|
+
entry => entry.toLowerCase().endsWith(ext),
|
|
454
|
+
));
|
|
455
|
+
if (!planned) {
|
|
456
|
+
errors.push(
|
|
457
|
+
`visual-journey manifest plans no ${type} evidence — capture.${type} was requested, so at least one ` +
|
|
458
|
+
`journey must plan a \`${ext}\` file under \`evidence.${field}\`.`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (errors.length) return reject(errors);
|
|
464
|
+
return { ok: true, required, manifest: normalized };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── EXECUTE-side evidence coverage (W-msb9kgs402813a97-c) ──────────────────
|
|
468
|
+
|
|
469
|
+
function _emptyMissing() {
|
|
470
|
+
return { files: [], journeys: [], projects: [], services: [], evidenceTypes: [] };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Does a registered run artifact count as evidence for `type`?
|
|
475
|
+
*
|
|
476
|
+
* Accepts either the declared artifact `type` (the sidecar contract) or the
|
|
477
|
+
* file extension, so a run that labelled its capture `image` instead of
|
|
478
|
+
* `screenshot` is not rejected on a vocabulary technicality. The file-existence
|
|
479
|
+
* check below is what actually makes this load-bearing.
|
|
480
|
+
*/
|
|
481
|
+
function _artifactIsEvidenceFor(artifact, contract) {
|
|
482
|
+
const declared = typeof artifact.type === 'string' ? artifact.type.trim().toLowerCase() : '';
|
|
483
|
+
if (declared && contract.artifactTypes.includes(declared)) return true;
|
|
484
|
+
const p = typeof artifact.path === 'string' ? artifact.path.toLowerCase() : '';
|
|
485
|
+
return !!p && p.endsWith(contract.ext);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Prove one registered artifact is a real file inside the run's artifacts dir.
|
|
490
|
+
*
|
|
491
|
+
* Registration is a claim; this is the check. Paths are resolved relative to
|
|
492
|
+
* the linked run's directory (the sidecar contract), must be strictly INSIDE
|
|
493
|
+
* it (`shared.isPathInside`), and must be a non-empty regular file.
|
|
494
|
+
*/
|
|
495
|
+
function _checkArtifactFile(artifact, artifactsDir) {
|
|
496
|
+
const rel = typeof artifact.path === 'string' ? artifact.path : '';
|
|
497
|
+
if (!rel) return { ok: false, reason: 'has no path' };
|
|
498
|
+
if (!_isNonEmptyString(artifactsDir)) {
|
|
499
|
+
return { ok: false, reason: 'has no resolvable run artifacts directory' };
|
|
500
|
+
}
|
|
501
|
+
const resolved = path.resolve(artifactsDir, rel);
|
|
502
|
+
if (!shared.isPathInside(resolved, artifactsDir)) {
|
|
503
|
+
return { ok: false, reason: 'resolves outside the run artifacts directory' };
|
|
504
|
+
}
|
|
505
|
+
let stat;
|
|
506
|
+
try {
|
|
507
|
+
stat = fs.statSync(resolved);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
return { ok: false, reason: err.code === 'ENOENT' ? 'does not exist on disk' : `is unreadable (${err.code || err.message})` };
|
|
510
|
+
}
|
|
511
|
+
if (!stat.isFile()) return { ok: false, reason: 'is not a regular file' };
|
|
512
|
+
if (!(stat.size > 0)) return { ok: false, reason: 'is a zero-byte file' };
|
|
513
|
+
return { ok: true, resolved };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* EXECUTE-side evidence gate: does a green qa-run actually carry the visual
|
|
518
|
+
* evidence its session asked for, across every project and service?
|
|
519
|
+
*
|
|
520
|
+
* Runs only when the session requested screenshots or video AND a DRAFT
|
|
521
|
+
* visual-journey manifest was persisted on the record. A logs-only session, or
|
|
522
|
+
* a legacy session created before the manifest existed, is skipped entirely
|
|
523
|
+
* (`{ ok: true, required: false }`) so historical completions are untouched.
|
|
524
|
+
*
|
|
525
|
+
* When required, all three checks must hold:
|
|
526
|
+
* (a) every requested capture type has at least one registered artifact;
|
|
527
|
+
* (b) every such artifact resolves INSIDE the run's artifacts dir and exists
|
|
528
|
+
* on disk with non-zero size;
|
|
529
|
+
* (c) the union of `journeyCoverage[].projects` for PASSED journeys covers
|
|
530
|
+
* every `session.spec.projects`, the union of `.services` covers every
|
|
531
|
+
* `managedSpawnNamesForSession(session)` name, and every journey the
|
|
532
|
+
* manifest declared actually reported `passed`.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} args
|
|
535
|
+
* @param {object} args.session - the qa-sessions record
|
|
536
|
+
* @param {object|null} args.manifest - the persisted visual-journey manifest
|
|
537
|
+
* @param {object|null} args.run - the linked qa-runs record
|
|
538
|
+
* @param {string|null} args.artifactsDir - qaRuns.qaArtifactsDirForRun(runId)
|
|
539
|
+
* @param {Array} [args.services] - injected managed-spawn inventory
|
|
540
|
+
* @returns {{ok:boolean, required:boolean,
|
|
541
|
+
* missing:{files:string[],journeys:string[],projects:string[],
|
|
542
|
+
* services:string[],evidenceTypes:string[]},
|
|
543
|
+
* errors:string[], error:string}}
|
|
544
|
+
*/
|
|
545
|
+
function validateEvidenceCoverage({ session, manifest, run, artifactsDir, services } = {}) {
|
|
546
|
+
const sessionId = (session && session.id) || '(unknown session)';
|
|
547
|
+
const requestedCapture = _requestedCaptureTypes(session);
|
|
548
|
+
const hasManifest = !!manifest && typeof manifest === 'object' && !Array.isArray(manifest);
|
|
549
|
+
if (requestedCapture.length === 0 || !hasManifest) {
|
|
550
|
+
return { ok: true, required: false, missing: _emptyMissing(), errors: [], error: '' };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const missing = _emptyMissing();
|
|
554
|
+
const errors = [];
|
|
555
|
+
const journeys = Array.isArray(manifest.journeys)
|
|
556
|
+
? manifest.journeys.filter(j => j && typeof j === 'object' && _isNonEmptyString(j.id))
|
|
557
|
+
: [];
|
|
558
|
+
const artifacts = Array.isArray(run && run.artifacts)
|
|
559
|
+
? run.artifacts.filter(a => a && typeof a === 'object')
|
|
560
|
+
: [];
|
|
561
|
+
const coverage = Array.isArray(run && run.journeyCoverage)
|
|
562
|
+
? run.journeyCoverage.filter(c => c && typeof c === 'object' && _isNonEmptyString(c.journeyId))
|
|
563
|
+
: [];
|
|
564
|
+
|
|
565
|
+
// ── (a) + (b) registered, present, in-directory evidence ─────────────────
|
|
566
|
+
for (const type of requestedCapture) {
|
|
567
|
+
const contract = EVIDENCE_CONTRACT[type];
|
|
568
|
+
const candidates = artifacts.filter(a => _artifactIsEvidenceFor(a, contract));
|
|
569
|
+
if (candidates.length === 0) {
|
|
570
|
+
missing.evidenceTypes.push(type);
|
|
571
|
+
errors.push(
|
|
572
|
+
`QA Session ${sessionId} requested capture.${type} but the qa-run registered no ${type} artifact — ` +
|
|
573
|
+
`a green run with no ${type} evidence cannot close a visual session.`,
|
|
574
|
+
);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
let usable = 0;
|
|
578
|
+
for (const artifact of candidates) {
|
|
579
|
+
const check = _checkArtifactFile(artifact, artifactsDir);
|
|
580
|
+
if (check.ok) { usable += 1; continue; }
|
|
581
|
+
const label = _isNonEmptyString(artifact.path) ? artifact.path : '(no path)';
|
|
582
|
+
if (!missing.files.includes(label)) missing.files.push(label);
|
|
583
|
+
errors.push(`registered ${type} artifact "${label}" ${check.reason}.`);
|
|
584
|
+
}
|
|
585
|
+
if (usable === 0 && !missing.evidenceTypes.includes(type)) {
|
|
586
|
+
missing.evidenceTypes.push(type);
|
|
587
|
+
errors.push(`no usable ${type} evidence survived verification for QA Session ${sessionId}.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── (c) journey / project / service coverage ─────────────────────────────
|
|
592
|
+
const passed = coverage.filter(c => c.status === 'passed');
|
|
593
|
+
const passedIds = new Set(passed.map(c => c.journeyId));
|
|
594
|
+
const unproven = journeys.map(j => j.id).filter(id => !passedIds.has(id));
|
|
595
|
+
if (unproven.length) {
|
|
596
|
+
missing.journeys = unproven;
|
|
597
|
+
errors.push(
|
|
598
|
+
`journey(s) ${unproven.join(', ')} declared by the DRAFT manifest never reported ` +
|
|
599
|
+
`\`status: "passed"\` in the qa-run's journeyCoverage — the run cannot be green on evidence it did not produce.`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const coveredProjects = new Set(passed.flatMap(c => (Array.isArray(c.projects) ? c.projects : [])));
|
|
604
|
+
const sessionProjects = _sessionProjects(session);
|
|
605
|
+
const missingProjects = sessionProjects.filter(p => !coveredProjects.has(p));
|
|
606
|
+
if (missingProjects.length) {
|
|
607
|
+
missing.projects = missingProjects;
|
|
608
|
+
errors.push(
|
|
609
|
+
`no passed journey produced evidence for project(s): ${missingProjects.join(', ')} — every project in ` +
|
|
610
|
+
`the session (${sessionProjects.join(', ')}) must appear in some passed journey's \`projects\`.`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const serviceLookup = _requiredServiceNames(session, { services });
|
|
615
|
+
if (!serviceLookup.ok) {
|
|
616
|
+
errors.push(
|
|
617
|
+
`evidence coverage could not be checked against session ${sessionId}'s managed-spawn inventory ` +
|
|
618
|
+
`(${serviceLookup.error}) — refusing to close the session as done.`,
|
|
619
|
+
);
|
|
620
|
+
} else {
|
|
621
|
+
const coveredServices = new Set(passed.flatMap(c => (Array.isArray(c.services) ? c.services : [])));
|
|
622
|
+
const missingServices = serviceLookup.names.filter(n => !coveredServices.has(n));
|
|
623
|
+
if (missingServices.length) {
|
|
624
|
+
missing.services = missingServices;
|
|
625
|
+
errors.push(
|
|
626
|
+
`no passed journey exercised service(s): ${missingServices.join(', ')} — every managed-spawn the ` +
|
|
627
|
+
`session owns (${serviceLookup.names.join(', ')}) must appear in some passed journey's \`services\`.`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
ok: errors.length === 0,
|
|
634
|
+
required: true,
|
|
635
|
+
missing,
|
|
636
|
+
errors,
|
|
637
|
+
error: errors.join(' '),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
module.exports = {
|
|
642
|
+
DRAFT_RESULT_SIDECAR_FILENAME,
|
|
643
|
+
VISUAL_COVERAGE_FAILURE_CLASS,
|
|
644
|
+
EVIDENCE_COVERAGE_FAILURE_CLASS,
|
|
645
|
+
JOURNEY_KINDS,
|
|
646
|
+
EVIDENCE_CONTRACT,
|
|
647
|
+
LIMITS,
|
|
648
|
+
draftResultSidecarPath,
|
|
649
|
+
prepareDraftResultSidecar,
|
|
650
|
+
consumeDraftResultSidecar,
|
|
651
|
+
isVisualCaptureRequested,
|
|
652
|
+
validateVisualJourneyManifest,
|
|
653
|
+
validateEvidenceCoverage,
|
|
654
|
+
};
|