@momentiq/dark-factory-cli 2.5.0 → 2.6.0
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/cli.d.ts.map +1 -1
- package/dist/cli.js +75 -38
- package/dist/cli.js.map +1 -1
- package/dist/commands/verify.d.ts +6 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +223 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/evidence/route-runner.d.ts +1 -0
- package/dist/evidence/route-runner.d.ts.map +1 -1
- package/dist/evidence/route-runner.js +23 -2
- package/dist/evidence/route-runner.js.map +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +30 -18
- package/dist/policy/gate.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +11 -6
- package/dist/runner.js.map +1 -1
- package/dist/skills/install.d.ts.map +1 -1
- package/dist/skills/install.js +1 -0
- package/dist/skills/install.js.map +1 -1
- package/package.json +2 -2
- package/skills/chief-engineer-blitz/SKILL.md.tmpl +7 -6
- package/skills/chief-engineer-blitz/skill.json +1 -1
- package/skills/verify/SKILL.md.tmpl +100 -0
- package/skills/verify/producer/README.md +73 -0
- package/skills/verify/producer/coverage.ts +121 -0
- package/skills/verify/producer/playwright-route.sh +161 -0
- package/skills/verify/producer/playwright.ui-route.config.ts +111 -0
- package/skills/verify/producer/ui-route.producer.spec.ts +281 -0
- package/skills/verify/skill.json +22 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reusable Evidence-Gated Validation **playwright (UI) route** producer command
|
|
3
|
+
# (momentiq-ai/dark-factory#193). Generalized from the dark-factory-dashboard
|
|
4
|
+
# dogfood (Cycle 21 PR-4).
|
|
5
|
+
#
|
|
6
|
+
# This is the `command` of the `playwright` route in
|
|
7
|
+
# `.agent-review/config.json:validation.verificationRoutes`. The
|
|
8
|
+
# @momentiq/dark-factory-cli route-runner (`df verify` / `df gate-push`) spawns
|
|
9
|
+
# it with NO shell — the route command is `command.trim().split(/\s+/)` then
|
|
10
|
+
# `spawn(argv[0], argv.slice(1))` — which is why the route command is the single
|
|
11
|
+
# token-pair `bash scripts/verify/playwright-route.sh` and ALL the real work
|
|
12
|
+
# (guards, Playwright, exit mapping) lives here.
|
|
13
|
+
#
|
|
14
|
+
# Exit-code contract (the 0/1/2 route contract):
|
|
15
|
+
# 0 — green: evidence captured, the routed UI surface(s) rendered + asserted.
|
|
16
|
+
# 1 — block: a routed UI surface failed to render/assert, OR a real producer
|
|
17
|
+
# error (Playwright crashed, a guarded file is dirty). Fail closed.
|
|
18
|
+
# 2 — soft-skip: the producer cannot run HERE (Playwright not installed, or a
|
|
19
|
+
# required auth storage-state is configured but absent). Mapped by the CLI
|
|
20
|
+
# to `requiresHumanJudgment` — NOT a pass. We NEVER fabricate evidence.
|
|
21
|
+
#
|
|
22
|
+
# ── CONSUMER REFERENCE FILE — copy into your repo (e.g. scripts/verify/) and
|
|
23
|
+
# OWN it. Adjust the env knobs at the top to your repo layout. Then arm the
|
|
24
|
+
# `playwright` route's `command` to `bash <path-to-this-script>` in
|
|
25
|
+
# .agent-review/config.json. `df skills install verify` does NOT overwrite it.
|
|
26
|
+
#
|
|
27
|
+
# Usage (normally invoked by the route-runner via `df verify --route playwright`):
|
|
28
|
+
# bash scripts/verify/playwright-route.sh [--commit <ref>]
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
EXIT_GREEN=0
|
|
33
|
+
EXIT_BLOCK=1
|
|
34
|
+
EXIT_SOFT_SKIP=2
|
|
35
|
+
|
|
36
|
+
# --- consumer-tunable knobs (env overrides) --------------------------------
|
|
37
|
+
# Directory where your web app + @playwright/test live (Playwright runs there).
|
|
38
|
+
WEB_DIR="${DF_UI_ROUTE_WEB_DIR:-web}"
|
|
39
|
+
# The producer config, relative to WEB_DIR.
|
|
40
|
+
ROUTE_CONFIG="${DF_UI_ROUTE_CONFIG:-playwright.ui-route.config.ts}"
|
|
41
|
+
|
|
42
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
43
|
+
cd "${REPO_ROOT}"
|
|
44
|
+
|
|
45
|
+
# Path of THIS script relative to the repo root — added to the clean-tree guard
|
|
46
|
+
# so a dirty producer (which would run DIFFERENT logic than the committed SHA)
|
|
47
|
+
# fails closed too. Computed dynamically so it works wherever you copy it.
|
|
48
|
+
SCRIPT_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
49
|
+
SCRIPT_REL="${SCRIPT_ABS#"${REPO_ROOT}/"}"
|
|
50
|
+
|
|
51
|
+
# --- resolve the SHA under review ------------------------------------------
|
|
52
|
+
# Prefer DF_VERIFY_COMMIT (exported by a route orchestrator that pins the gated
|
|
53
|
+
# commit), then an explicit --commit, then HEAD.
|
|
54
|
+
SHA_REF="${DF_VERIFY_COMMIT:-HEAD}"
|
|
55
|
+
while [[ $# -gt 0 ]]; do
|
|
56
|
+
case "$1" in
|
|
57
|
+
--commit) SHA_REF="${2:-HEAD}"; shift 2 ;;
|
|
58
|
+
--commit=*) SHA_REF="${1#*=}"; shift ;;
|
|
59
|
+
*) shift ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
SHA="$(git rev-parse "${SHA_REF}" 2>/dev/null || echo "")"
|
|
63
|
+
if [[ -z "${SHA}" ]]; then
|
|
64
|
+
echo "[playwright-route] FAIL: could not resolve commit '${SHA_REF}'." >&2
|
|
65
|
+
exit "${EXIT_BLOCK}"
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# SHA-binding guard: the producer drives a browser against the CHECKED-OUT
|
|
69
|
+
# working tree (HEAD), so it can only honestly attest evidence for HEAD. If
|
|
70
|
+
# asked to produce evidence for a non-HEAD SHA, FAIL CLOSED rather than stamp a
|
|
71
|
+
# passing record whose screenshots actually reflect HEAD.
|
|
72
|
+
HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo "")"
|
|
73
|
+
if [[ "${SHA}" != "${HEAD_SHA}" ]]; then
|
|
74
|
+
echo "[playwright-route] BLOCK: asked to produce evidence for ${SHA:0:12} but HEAD is ${HEAD_SHA:0:12}." >&2
|
|
75
|
+
echo "[playwright-route] the producer validates the checked-out tree (HEAD) only; refusing to mis-bind evidence. Check out the target SHA and re-run." >&2
|
|
76
|
+
exit "${EXIT_BLOCK}"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# Clean-tree guard: the producer screenshots the WORKING TREE, but evidence
|
|
80
|
+
# binds to the committed SHA. If any file that affects either the rendered UI OR
|
|
81
|
+
# the route's execution/evidence contract is dirty or untracked, the capture
|
|
82
|
+
# would attest code that is NOT in ${SHA}. FAIL CLOSED. The guarded set is the
|
|
83
|
+
# UI surface (web/** + *.tsx) AND the route machinery itself (this script + the
|
|
84
|
+
# route config) — a dirty producer runs different logic than the committed SHA.
|
|
85
|
+
GUARDED_PATHS=(
|
|
86
|
+
"${WEB_DIR}/**" '*.tsx'
|
|
87
|
+
"${SCRIPT_REL}"
|
|
88
|
+
'.agent-review/config.json'
|
|
89
|
+
)
|
|
90
|
+
if ! git diff-index --quiet HEAD -- "${GUARDED_PATHS[@]}" 2>/dev/null; then
|
|
91
|
+
echo "[playwright-route] BLOCK: tracked UI or route-machinery files are dirty (uncommitted changes)." >&2
|
|
92
|
+
echo "[playwright-route] the producer captures the working tree but evidence binds to ${SHA:0:12}; commit or stash changes under ${WEB_DIR}/**, *.tsx, ${SCRIPT_REL}, or .agent-review/config.json, then re-run." >&2
|
|
93
|
+
exit "${EXIT_BLOCK}"
|
|
94
|
+
fi
|
|
95
|
+
UNTRACKED_GUARDED="$(git ls-files --others --exclude-standard -- "${GUARDED_PATHS[@]}" 2>/dev/null | head -1 || true)"
|
|
96
|
+
if [[ -n "${UNTRACKED_GUARDED}" ]]; then
|
|
97
|
+
echo "[playwright-route] BLOCK: untracked UI/route-machinery file(s) present (e.g. ${UNTRACKED_GUARDED}) not in ${SHA:0:12}." >&2
|
|
98
|
+
echo "[playwright-route] commit or remove untracked files under the guarded set so the capture matches the gated commit, then re-run." >&2
|
|
99
|
+
exit "${EXIT_BLOCK}"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
echo "[playwright-route] producing UI evidence for ${SHA:0:12} (ref=${SHA_REF}, =HEAD, clean tree)" >&2
|
|
103
|
+
|
|
104
|
+
# Compute the UI paths this commit actually changed (parent..SHA), restricted to
|
|
105
|
+
# the route's trigger surface (web/** + *.tsx). Handed to the producer so it can
|
|
106
|
+
# FAIL CLOSED when a changed UI path has no mapped capture surface. Excludes
|
|
107
|
+
# deletions (D) — a removed file has no surface to render.
|
|
108
|
+
PARENT="$(git rev-parse "${SHA}^" 2>/dev/null || echo "")"
|
|
109
|
+
if [[ -n "${PARENT}" ]]; then
|
|
110
|
+
CHANGED_UI_PATHS="$(git diff --name-only --diff-filter=ACMRT "${PARENT}" "${SHA}" -- "${WEB_DIR}/**" '*.tsx' 2>/dev/null || true)"
|
|
111
|
+
else
|
|
112
|
+
# Root commit — list all tracked UI paths.
|
|
113
|
+
CHANGED_UI_PATHS="$(git ls-files -- "${WEB_DIR}/**" '*.tsx' 2>/dev/null || true)"
|
|
114
|
+
fi
|
|
115
|
+
export DF_UI_ROUTE_CHANGED_PATHS="${CHANGED_UI_PATHS}"
|
|
116
|
+
|
|
117
|
+
# Anchor the evidence dir at the REPO ROOT (absolute), because Playwright runs
|
|
118
|
+
# with cwd=${WEB_DIR} — a relative path would land under ${WEB_DIR}.
|
|
119
|
+
OUT_DIR="${REPO_ROOT}/agent-reviews/quality-gates/ui/${SHA}"
|
|
120
|
+
mkdir -p "${OUT_DIR}"
|
|
121
|
+
export DF_UI_ROUTE_SHA="${SHA}"
|
|
122
|
+
export DF_UI_ROUTE_OUT="${OUT_DIR}"
|
|
123
|
+
|
|
124
|
+
# --- preflight: web tier present + Playwright installed --------------------
|
|
125
|
+
if [[ ! -d "${WEB_DIR}/node_modules/@playwright" ]]; then
|
|
126
|
+
echo "[playwright-route] SOFT-SKIP: ${WEB_DIR}/ Playwright not installed (run \`cd ${WEB_DIR} && npm ci\`)." >&2
|
|
127
|
+
echo "[playwright-route] not fabricating evidence — requiresHumanJudgment." >&2
|
|
128
|
+
exit "${EXIT_SOFT_SKIP}"
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# --- auth storage-state gate (optional) ------------------------------------
|
|
132
|
+
# Public surfaces need no auth. If you configured an auth storage-state
|
|
133
|
+
# (DF_UI_ROUTE_STORAGE_STATE) but the file is absent (e.g. a cloud sandbox where
|
|
134
|
+
# your auth harness could not mint it), SOFT-SKIP rather than pass — we will not
|
|
135
|
+
# produce a protected-surface artifact we cannot legitimately authenticate.
|
|
136
|
+
if [[ -n "${DF_UI_ROUTE_STORAGE_STATE:-}" && ! -f "${DF_UI_ROUTE_STORAGE_STATE}" ]]; then
|
|
137
|
+
echo "[playwright-route] SOFT-SKIP: DF_UI_ROUTE_STORAGE_STATE=${DF_UI_ROUTE_STORAGE_STATE} is set but the file is absent." >&2
|
|
138
|
+
echo "[playwright-route] cannot authenticate the configured session; not fabricating evidence (requiresHumanJudgment)." >&2
|
|
139
|
+
exit "${EXIT_SOFT_SKIP}"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
# --- run the producer -------------------------------------------------------
|
|
143
|
+
LOG_FILE="$(mktemp -t df-playwright-route.XXXXXX)"
|
|
144
|
+
trap 'rm -f "${LOG_FILE}"' EXIT
|
|
145
|
+
|
|
146
|
+
set +e
|
|
147
|
+
( cd "${WEB_DIR}" && npx playwright test --config "${ROUTE_CONFIG}" ) \
|
|
148
|
+
>"${LOG_FILE}" 2>&1
|
|
149
|
+
RC=$?
|
|
150
|
+
set -e 2>/dev/null || true
|
|
151
|
+
|
|
152
|
+
# Surface the producer output (bounded) for the gate log / critic.
|
|
153
|
+
tail -n 120 "${LOG_FILE}" >&2 || true
|
|
154
|
+
|
|
155
|
+
if [[ ${RC} -eq 0 ]]; then
|
|
156
|
+
echo "[playwright-route] OK — evidence in ${OUT_DIR}" >&2
|
|
157
|
+
exit "${EXIT_GREEN}"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
echo "[playwright-route] BLOCK: producer failed (exit ${RC}) — see output above." >&2
|
|
161
|
+
exit "${EXIT_BLOCK}"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reusable Playwright config for the Evidence-Gated Validation **playwright
|
|
7
|
+
* (UI) route producer** (momentiq-ai/dark-factory#193). Generalized from the
|
|
8
|
+
* dark-factory-dashboard dogfood (Cycle 21 PR-4; ADR 2026-06).
|
|
9
|
+
*
|
|
10
|
+
* Kept SEPARATE from your broad e2e config so the route producer is a single,
|
|
11
|
+
* fast, deterministic capture:
|
|
12
|
+
* - one chromium project, the producer spec only.
|
|
13
|
+
* - no retries (a flaky retry could mask a genuinely-failing UI surface).
|
|
14
|
+
* - an OPTIONAL authenticated storage-state (public surfaces need none).
|
|
15
|
+
* - an OPTIONAL local preview `webServer` (the SHA under review) for the
|
|
16
|
+
* no-auth dogfood path; set `PLAYWRIGHT_TEST_BASE_URL` to point at a
|
|
17
|
+
* deployed preview of the SHA instead.
|
|
18
|
+
*
|
|
19
|
+
* This config is invoked by `playwright-route.sh`, which is the route's
|
|
20
|
+
* `command` in `.agent-review/config.json`.
|
|
21
|
+
*
|
|
22
|
+
* ── CONSUMER REFERENCE FILE — copy into your repo (next to the producer spec)
|
|
23
|
+
* and OWN it. Adjust `testDir`, the `webServer.command`, and the env knobs
|
|
24
|
+
* to your repo's layout. `df skills install verify` does NOT overwrite it.
|
|
25
|
+
*
|
|
26
|
+
* NEVER set a routine pre-push bypass to get around a failing producer — a
|
|
27
|
+
* failing/absent capture MUST block the route (that is the feature).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Auth is OPTIONAL and consumer-supplied. Mint an authenticated Playwright
|
|
31
|
+
// storageState JSON with YOUR auth (Clerk/Auth0/bot login/etc.) and point
|
|
32
|
+
// DF_UI_ROUTE_STORAGE_STATE at it for protected surfaces. Public surfaces need
|
|
33
|
+
// none — when the file is absent we run unauthenticated rather than fail.
|
|
34
|
+
//
|
|
35
|
+
// The default path is anchored to `process.cwd()` — NOT `__dirname` or
|
|
36
|
+
// `import.meta.url`. Playwright may load this config as CJS or ESM depending on
|
|
37
|
+
// the consumer's `"type"` + Playwright version: `__dirname` is undefined under
|
|
38
|
+
// ESM and `import.meta` is invalid under CJS, so either module-global would
|
|
39
|
+
// crash the producer for half of consumers before it could even soft-skip. The
|
|
40
|
+
// route script runs Playwright with `cwd` = the web dir (where this config
|
|
41
|
+
// lives), so cwd-relative resolves to `<web>/.auth/storage-state.json` and
|
|
42
|
+
// works in both. Gitignore **/.auth/.
|
|
43
|
+
const STORAGE_STATE_PATH =
|
|
44
|
+
process.env.DF_UI_ROUTE_STORAGE_STATE ||
|
|
45
|
+
resolve(process.cwd(), ".auth/storage-state.json");
|
|
46
|
+
const storageState = existsSync(STORAGE_STATE_PATH) ? STORAGE_STATE_PATH : undefined;
|
|
47
|
+
|
|
48
|
+
// Local preview port for the SHA-under-review server. Overridable via
|
|
49
|
+
// DF_UI_ROUTE_PORT to avoid collisions with other local services.
|
|
50
|
+
const PREVIEW_PORT = process.env.DF_UI_ROUTE_PORT || "3217";
|
|
51
|
+
const BASE_URL =
|
|
52
|
+
process.env.PLAYWRIGHT_TEST_BASE_URL?.replace(/\/$/, "") ||
|
|
53
|
+
`http://localhost:${PREVIEW_PORT}`;
|
|
54
|
+
|
|
55
|
+
// Only stand up the local preview server when we are NOT pointed at a remote
|
|
56
|
+
// deployed surface. (A remote base URL means the SHA is already served.)
|
|
57
|
+
const usingLocalPreview =
|
|
58
|
+
!process.env.PLAYWRIGHT_TEST_BASE_URL ||
|
|
59
|
+
/^https?:\/\/localhost(:|\/|$)/.test(process.env.PLAYWRIGHT_TEST_BASE_URL);
|
|
60
|
+
|
|
61
|
+
// The command that serves the SHA under review for the local-preview path.
|
|
62
|
+
// Default `npm run dev`; override via DF_UI_ROUTE_DEV_CMD for your stack.
|
|
63
|
+
const DEV_CMD = process.env.DF_UI_ROUTE_DEV_CMD || "npm run dev";
|
|
64
|
+
|
|
65
|
+
export default defineConfig({
|
|
66
|
+
testDir: "./tests/e2e",
|
|
67
|
+
// Only the route producer — not your broad e2e suite.
|
|
68
|
+
testMatch: /ui-route\.producer\.spec\.ts$/,
|
|
69
|
+
fullyParallel: false,
|
|
70
|
+
forbidOnly: !!process.env.CI,
|
|
71
|
+
// The producer must be deterministic; no flaky retries that could mask a
|
|
72
|
+
// genuinely-failing UI surface behind a lucky re-run.
|
|
73
|
+
retries: 0,
|
|
74
|
+
workers: 1,
|
|
75
|
+
timeout: 90 * 1000,
|
|
76
|
+
expect: { timeout: 15 * 1000 },
|
|
77
|
+
reporter: [["list"]],
|
|
78
|
+
|
|
79
|
+
use: {
|
|
80
|
+
baseURL: BASE_URL,
|
|
81
|
+
// Authenticate ONLY when a storage-state file is present (protected
|
|
82
|
+
// surfaces). Public surfaces ignore it.
|
|
83
|
+
...(storageState ? { storageState } : {}),
|
|
84
|
+
trace: "retain-on-failure",
|
|
85
|
+
screenshot: "only-on-failure",
|
|
86
|
+
ignoreHTTPSErrors: true,
|
|
87
|
+
actionTimeout: 15 * 1000,
|
|
88
|
+
navigationTimeout: 30 * 1000,
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
projects: [
|
|
92
|
+
{
|
|
93
|
+
name: "ui-route-producer",
|
|
94
|
+
use: { ...devices["Desktop Chrome"] },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
|
|
98
|
+
webServer: usingLocalPreview
|
|
99
|
+
? {
|
|
100
|
+
// Serve the SHA under review. Set the port via the PORT env var (the
|
|
101
|
+
// common convention); adjust DEV_CMD if your dev server differs.
|
|
102
|
+
command: DEV_CMD,
|
|
103
|
+
url: BASE_URL,
|
|
104
|
+
reuseExistingServer: !process.env.CI,
|
|
105
|
+
timeout: 120 * 1000,
|
|
106
|
+
env: {
|
|
107
|
+
PORT: PREVIEW_PORT,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: undefined,
|
|
111
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable **playwright (UI) verification-route producer** (momentiq-ai/
|
|
3
|
+
* dark-factory#193).
|
|
4
|
+
*
|
|
5
|
+
* Generalized from the dark-factory-dashboard dogfood (Cycle 21 EC2/EC8, ADR
|
|
6
|
+
* 2026-06). This is the `playwright` route's producer: for every UI surface
|
|
7
|
+
* armed by the change under review it captures the required floor of the SOTA
|
|
8
|
+
* UI-grounding triad:
|
|
9
|
+
*
|
|
10
|
+
* (1) an **ARIA snapshot** (Playwright `ariaSnapshot()` — the accessibility
|
|
11
|
+
* tree, a structural, tamper-resistant assertion that the surface renders
|
|
12
|
+
* its expected landmark/heading/role structure) — ALWAYS produced, and
|
|
13
|
+
* (2) an **after screenshot** at the SHA under review — ALWAYS produced; plus
|
|
14
|
+
* an optional **before screenshot** baseline (completing the before/after
|
|
15
|
+
* pair) ONLY when `DF_UI_ROUTE_BEFORE_BASE_URL` is set. A missing baseline
|
|
16
|
+
* never fails the route.
|
|
17
|
+
*
|
|
18
|
+
* The **optional independent VLM check** (the triad's third leg) is DEFERRED in
|
|
19
|
+
* v1: the ARIA + after capture is the required floor; the VLM leg is additive
|
|
20
|
+
* corroboration gated behind a provider/cost decision. The hook is
|
|
21
|
+
* `runOptionalVlmCheck()` below — a no-op unless `DF_UI_ROUTE_VLM=1` AND a
|
|
22
|
+
* provider is wired, and it can only ADD a finding, never relax the floor.
|
|
23
|
+
*
|
|
24
|
+
* ## Fail-closed coverage
|
|
25
|
+
*
|
|
26
|
+
* A `web/**`/`*.tsx` change ARMS the route, but the producer maps each *changed*
|
|
27
|
+
* UI path to a capture surface; a changed product-UI path with NO mapped surface
|
|
28
|
+
* BLOCKS the route (the `ui-route-coverage` test below). This is the difference
|
|
29
|
+
* between an evidence gate and a false-positive generator — a `*.tsx` change
|
|
30
|
+
* cannot be satisfied by rendering only an unaffected surface. The mapping logic
|
|
31
|
+
* lives in the unit-tested `coverage.ts`.
|
|
32
|
+
*
|
|
33
|
+
* ## Auth (consumer-supplied storage-state — NOT vendor-coupled)
|
|
34
|
+
*
|
|
35
|
+
* Public surfaces need no auth. For PROTECTED surfaces, supply an authenticated
|
|
36
|
+
* Playwright `storageState` JSON and point `DF_UI_ROUTE_STORAGE_STATE` at it
|
|
37
|
+
* (see `playwright.ui-route.config.ts`). How you mint that session (Clerk, Auth0,
|
|
38
|
+
* a bot login, a long-lived test cookie) is YOUR repo's concern — the producer
|
|
39
|
+
* only consumes the storage-state. v1 ships a single public surface so it works
|
|
40
|
+
* zero-auth out of the box; add protected surfaces + storage-state as needed.
|
|
41
|
+
*
|
|
42
|
+
* ## Evidence shape
|
|
43
|
+
*
|
|
44
|
+
* Artifacts land under `DF_UI_ROUTE_OUT` (default
|
|
45
|
+
* `agent-reviews/quality-gates/ui/<sha>/`), one subdir per surface:
|
|
46
|
+
* <surface-slug>/aria.snapshot.txt — the ARIA tree (floor)
|
|
47
|
+
* <surface-slug>/after.png — screenshot at the gated SHA (floor)
|
|
48
|
+
* <surface-slug>/before.png — baseline (only when BEFORE_BASE_URL set)
|
|
49
|
+
* <surface-slug>/meta.json — capture provenance
|
|
50
|
+
*
|
|
51
|
+
* The spec FAILS (→ route blocks) if a routed UI surface does not render its
|
|
52
|
+
* required structure — the point: an over-claimed "the UI works" cannot pass the
|
|
53
|
+
* gate without a real, passing capture bound to the SHA.
|
|
54
|
+
*
|
|
55
|
+
* ── CONSUMER REFERENCE FILE — copy into your repo and OWN it. The one block you
|
|
56
|
+
* MUST edit is `SURFACES[]` below (your routes + their required headings +
|
|
57
|
+
* the source globs each one covers). `df skills install verify` does NOT
|
|
58
|
+
* overwrite this file; it is yours to maintain.
|
|
59
|
+
*/
|
|
60
|
+
import { test, expect, type Page } from "@playwright/test";
|
|
61
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import { partitionChangedPaths, type UiSurface } from "./coverage";
|
|
64
|
+
|
|
65
|
+
const SHA = process.env.DF_UI_ROUTE_SHA ?? "unknown";
|
|
66
|
+
const OUT_DIR =
|
|
67
|
+
process.env.DF_UI_ROUTE_OUT ?? `agent-reviews/quality-gates/ui/${SHA}`;
|
|
68
|
+
// Optional baseline origin for the "before" screenshot (e.g. the deployed
|
|
69
|
+
// tip-of-main app). When unset, only the "after" capture is taken — the ARIA
|
|
70
|
+
// snapshot + after screenshot are the required floor regardless.
|
|
71
|
+
const BEFORE_BASE_URL = process.env.DF_UI_ROUTE_BEFORE_BASE_URL?.replace(
|
|
72
|
+
/\/$/,
|
|
73
|
+
"",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ▼▼▼ EDIT ME ▼▼▼ — the UI surfaces this producer asserts.
|
|
78
|
+
*
|
|
79
|
+
* Each entry pairs a route `path` with a stable, STRUCTURAL expectation
|
|
80
|
+
* (`requiredHeading` — an accessible-name regex for a landmark heading) so the
|
|
81
|
+
* capture also VERIFIES the surface (not just photographs it), AND a `covers`
|
|
82
|
+
* glob list declaring which changed source paths it is the evidence for (the
|
|
83
|
+
* fail-closed coverage map). Prefer role-based, copy-stable assertions.
|
|
84
|
+
*
|
|
85
|
+
* v1 ships ONE public surface (the home page) so the route works zero-auth out
|
|
86
|
+
* of the box. Replace it with your app's surfaces; add protected routes (they
|
|
87
|
+
* render under the storage-state session from the config) as your auth harness
|
|
88
|
+
* hardens.
|
|
89
|
+
*/
|
|
90
|
+
const SURFACES: readonly UiSurface[] = [
|
|
91
|
+
{
|
|
92
|
+
path: "/",
|
|
93
|
+
slug: "home",
|
|
94
|
+
// EDIT: a heading name (role=heading, accessible name) YOUR home page must
|
|
95
|
+
// expose. Keep it a REAL assertion — a vacuous `/.+/` ("some heading
|
|
96
|
+
// exists") neither verifies the surface nor reliably renders.
|
|
97
|
+
requiredHeading: /Welcome/i,
|
|
98
|
+
// EDIT: the source globs THIS surface (the home page) ACTUALLY renders.
|
|
99
|
+
// Keep them SCOPED. A broad glob like `web/app/**` or `web/**/*.tsx` would
|
|
100
|
+
// claim the home surface is the evidence for your WHOLE app, so a change to
|
|
101
|
+
// any OTHER page (e.g. web/app/settings/page.tsx) would be wrongly "covered"
|
|
102
|
+
// by `/` and the route would PASS without ever rendering it — defeating the
|
|
103
|
+
// fail-closed coverage below. Add one SURFACES[] entry per real surface; let
|
|
104
|
+
// anything you have not scoped fall into `uncovered` and fail closed.
|
|
105
|
+
covers: ["web/app/page.tsx"],
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Changed UI paths that are NOT a renderable surface and so do not require a
|
|
111
|
+
* capture — the route's own producer harness/coverage lib, test scaffolding,
|
|
112
|
+
* type decls, styles, config. Excluding these keeps the fail-closed check
|
|
113
|
+
* honest: it fires for genuinely-uncovered *product* UI, not for the harness
|
|
114
|
+
* that implements the route. Anything NOT excluded and NOT covered by a surface
|
|
115
|
+
* blocks (fail closed) until a surface is added for it.
|
|
116
|
+
*/
|
|
117
|
+
const NON_SURFACE_GLOBS: readonly string[] = [
|
|
118
|
+
"web/tests/**",
|
|
119
|
+
"web/playwright.ui-route.config.ts",
|
|
120
|
+
"web/playwright.config.ts",
|
|
121
|
+
"web/**/coverage.ts",
|
|
122
|
+
"web/**/ui-route.producer.spec.ts",
|
|
123
|
+
"web/**/*.test.ts",
|
|
124
|
+
"web/**/*.test.tsx",
|
|
125
|
+
"web/**/*.spec.ts",
|
|
126
|
+
"web/**/*.d.ts",
|
|
127
|
+
"web/**/*.css",
|
|
128
|
+
"web/**/*.json",
|
|
129
|
+
"web/**/*.md",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const CHANGED_UI_PATHS = (process.env.DF_UI_ROUTE_CHANGED_PATHS ?? "")
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.map((p) => p.trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
|
|
137
|
+
const {
|
|
138
|
+
armed: ARMED_SURFACES,
|
|
139
|
+
uncovered: UNCOVERED_PATHS,
|
|
140
|
+
smokeAllSurfaces: SMOKE_ALL_SURFACES,
|
|
141
|
+
} = partitionChangedPaths(CHANGED_UI_PATHS, SURFACES, NON_SURFACE_GLOBS);
|
|
142
|
+
|
|
143
|
+
function routeOutDir(slug: string): string {
|
|
144
|
+
return join(OUT_DIR, slug);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Optional independent VLM check (DEFERRED in v1).
|
|
149
|
+
*
|
|
150
|
+
* Additive-only: it may push a soft finding but can NEVER turn a failed floor
|
|
151
|
+
* into a pass, nor is it required for the route to pass. Wired off by default;
|
|
152
|
+
* enable with DF_UI_ROUTE_VLM=1 once you wire a provider + cost ceiling.
|
|
153
|
+
* Intentionally surfaces its intent loudly rather than faking a check (which
|
|
154
|
+
* would be the exact reward-hack this feature prevents).
|
|
155
|
+
*/
|
|
156
|
+
async function runOptionalVlmCheck(
|
|
157
|
+
_afterScreenshotPath: string,
|
|
158
|
+
_surface: UiSurface,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
if (process.env.DF_UI_ROUTE_VLM !== "1") return;
|
|
161
|
+
test.info().annotations.push({
|
|
162
|
+
type: "vlm-check",
|
|
163
|
+
description:
|
|
164
|
+
"DF_UI_ROUTE_VLM=1 set but no VLM provider wired in this producer " +
|
|
165
|
+
"(v1 deferred). The ARIA + after floor is the gate; the VLM leg is " +
|
|
166
|
+
"additive corroboration only. No check performed.",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function captureBefore(
|
|
171
|
+
page: Page,
|
|
172
|
+
surface: UiSurface,
|
|
173
|
+
dir: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (!BEFORE_BASE_URL) return;
|
|
176
|
+
const beforeUrl = `${BEFORE_BASE_URL}${surface.path}`;
|
|
177
|
+
try {
|
|
178
|
+
await page.goto(beforeUrl, { waitUntil: "networkidle", timeout: 30_000 });
|
|
179
|
+
await page.screenshot({ path: join(dir, "before.png"), fullPage: true });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// The baseline is best-effort; a missing/unreachable baseline must not fail
|
|
182
|
+
// the route (the ARIA + after capture is the floor). Record why.
|
|
183
|
+
test.info().annotations.push({
|
|
184
|
+
type: "before-capture-skipped",
|
|
185
|
+
description: `baseline ${beforeUrl} unreachable: ${
|
|
186
|
+
err instanceof Error ? err.message : String(err)
|
|
187
|
+
}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// FAIL-CLOSED coverage gate: if the change touched a UI path that maps to NO
|
|
193
|
+
// capture surface, the route MUST block — it cannot be satisfied by rendering
|
|
194
|
+
// only an unaffected surface. This is the difference between an evidence gate
|
|
195
|
+
// and a false-positive generator. The fix is to ADD a surface (path +
|
|
196
|
+
// requiredHeading + covers glob) for the changed area, not to widen
|
|
197
|
+
// NON_SURFACE_GLOBS to hide it.
|
|
198
|
+
test("ui-route-coverage — every changed UI path maps to a capture surface", async () => {
|
|
199
|
+
if (UNCOVERED_PATHS.length > 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Evidence-Gated Validation playwright route: the following changed UI " +
|
|
202
|
+
"path(s) have NO mapped capture surface, so this route cannot produce " +
|
|
203
|
+
"real evidence for them and FAILS CLOSED:\n" +
|
|
204
|
+
UNCOVERED_PATHS.map((p) => ` - ${p}`).join("\n") +
|
|
205
|
+
"\n\nAdd a surface for the affected area to SURFACES[] in " +
|
|
206
|
+
"ui-route.producer.spec.ts. Do NOT widen NON_SURFACE_GLOBS to suppress " +
|
|
207
|
+
"a real UI change — that re-opens the false-positive hole this gate closes.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
expect(UNCOVERED_PATHS).toEqual([]);
|
|
211
|
+
// Harness-only change → smoke-capture ALL surfaces (the route still produces
|
|
212
|
+
// real evidence; it is never passable with zero captures). Surfaced so the
|
|
213
|
+
// evidence log shows why every surface was captured.
|
|
214
|
+
if (SMOKE_ALL_SURFACES) {
|
|
215
|
+
test.info().annotations.push({
|
|
216
|
+
type: "ui-route-smoke",
|
|
217
|
+
description:
|
|
218
|
+
"Harness/non-UI-only change: capturing ALL surfaces as a regression " +
|
|
219
|
+
"smoke (no product-UI surface was directly armed). The route still " +
|
|
220
|
+
"produces ARIA + after evidence — it cannot pass with zero captures.",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
for (const surface of ARMED_SURFACES) {
|
|
226
|
+
test(`UI route evidence — ${surface.slug} (${surface.path})`, async ({
|
|
227
|
+
page,
|
|
228
|
+
}) => {
|
|
229
|
+
const dir = routeOutDir(surface.slug);
|
|
230
|
+
mkdirSync(dir, { recursive: true });
|
|
231
|
+
|
|
232
|
+
// --- (optional) BEFORE: baseline screenshot from the configured origin ---
|
|
233
|
+
await captureBefore(page, surface, dir);
|
|
234
|
+
|
|
235
|
+
// --- AFTER: the SHA under review (the locally-served / configured app) ---
|
|
236
|
+
await page.goto(surface.path, { waitUntil: "networkidle", timeout: 30_000 });
|
|
237
|
+
|
|
238
|
+
// Floor assertion — the surface renders its required structure. A REAL gate:
|
|
239
|
+
// an over-claimed render that doesn't expose the heading FAILS here, blocking
|
|
240
|
+
// the route. (Role-based → robust to copy churn.)
|
|
241
|
+
await expect(
|
|
242
|
+
page.getByRole("heading", { name: surface.requiredHeading }),
|
|
243
|
+
).toBeVisible({ timeout: 15_000 });
|
|
244
|
+
|
|
245
|
+
// Floor evidence #1 — ARIA snapshot (the accessibility tree). Structural,
|
|
246
|
+
// diff-able, hard to stage. Assert it is non-trivial, then persist it.
|
|
247
|
+
const ariaSnapshot = await page.locator("body").ariaSnapshot();
|
|
248
|
+
expect(ariaSnapshot.trim().length).toBeGreaterThan(0);
|
|
249
|
+
writeFileSync(join(dir, "aria.snapshot.txt"), `${ariaSnapshot}\n`, "utf8");
|
|
250
|
+
|
|
251
|
+
// Floor evidence #2 — the "after" screenshot bound to the gated SHA.
|
|
252
|
+
const afterPath = join(dir, "after.png");
|
|
253
|
+
await page.screenshot({ path: afterPath, fullPage: true });
|
|
254
|
+
|
|
255
|
+
// Per-surface metadata (the human-reviewable provenance of this capture).
|
|
256
|
+
writeFileSync(
|
|
257
|
+
join(dir, "meta.json"),
|
|
258
|
+
`${JSON.stringify(
|
|
259
|
+
{
|
|
260
|
+
surface: surface.slug,
|
|
261
|
+
path: surface.path,
|
|
262
|
+
sha: SHA,
|
|
263
|
+
capturedAt: new Date().toISOString(),
|
|
264
|
+
baseURL: test.info().project.use.baseURL ?? null,
|
|
265
|
+
beforeBaseURL: BEFORE_BASE_URL ?? null,
|
|
266
|
+
ariaSnapshotBytes: Buffer.byteLength(ariaSnapshot, "utf8"),
|
|
267
|
+
evidenceKind: "playwright",
|
|
268
|
+
floor: ["aria-snapshot", "after-screenshot"],
|
|
269
|
+
beforeScreenshot: BEFORE_BASE_URL ? "before.png" : null,
|
|
270
|
+
vlm: "deferred-v1",
|
|
271
|
+
},
|
|
272
|
+
null,
|
|
273
|
+
2,
|
|
274
|
+
)}\n`,
|
|
275
|
+
"utf8",
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// --- optional, additive-only VLM corroboration (deferred in v1) ---
|
|
279
|
+
await runOptionalVlmCheck(afterPath, surface);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../skill-schema.json",
|
|
3
|
+
"name": "verify",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"summary": "Produce evidence-gated validation-route evidence for a change before pushing — drive `df verify` over the armed verification routes (terraform/helm/docker/targeted-test/UI), writing per-SHA, diffHash-bound QualityGateEvidence the local pre-push gate asserts.",
|
|
6
|
+
"originatingRepo": "momentiq-ai/dark-factory-platform",
|
|
7
|
+
"files": [
|
|
8
|
+
{ "template": "SKILL.md.tmpl", "target": "SKILL.md" }
|
|
9
|
+
],
|
|
10
|
+
"variables": {
|
|
11
|
+
"REPO_NAME": {
|
|
12
|
+
"description": "Display name of the consumer repo (e.g. 'Dark Factory Platform').",
|
|
13
|
+
"source": "darkfactory.yaml: repo.displayName",
|
|
14
|
+
"default": "this repo"
|
|
15
|
+
},
|
|
16
|
+
"ARTIFACT_DIR": {
|
|
17
|
+
"description": "Display path to the per-SHA quality-gate artifact dir (where `<sha>.json` evidence lands), used in the skill's example commands. The real value is resolved at runtime from .agent-review/config.json:git.artifactDir under the git common dir; this is the default for the docs.",
|
|
18
|
+
"source": "fixed default (the canonical .agent-review/config.json: git.artifactDir resolution)",
|
|
19
|
+
"default": ".git/agent-reviews"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|