crewly 1.20.430 → 1.20.431

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.
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: Reproduce UI
3
+ description: Open a Crewly dashboard feature by its feature-map entry in a headless browser and save evidence (screenshot, console errors, failed requests, timings) before you reproduce or fix a UI bug report.
4
+ version: 1.0.0
5
+ category: development
6
+ skillType: claude-skill
7
+ assignableRoles:
8
+ - developer
9
+ - frontend-developer
10
+ - fullstack-dev
11
+ - qa
12
+ - qa-engineer
13
+ - team-leader
14
+ - orchestrator
15
+ - generalist
16
+ triggers:
17
+ - reproduce
18
+ - screenshot bug
19
+ - UI bug
20
+ - feature map
21
+ tags:
22
+ - frontend
23
+ - debugging
24
+ - evidence
25
+ execution:
26
+ type: script
27
+ script:
28
+ file: execute.sh
29
+ interpreter: bash
30
+ timeoutMs: 180000
31
+ ---
32
+
33
+ # Reproduce UI
34
+
35
+ A bug report from the owner is often a phone screenshot and one sentence.
36
+ Before you guess, open the feature the way a user does and keep the evidence.
37
+ The feature map (`frontend/FEATURE_MAP.md` in a Crewly checkout, #811) says
38
+ for every route how a user reaches it, what it should do and which
39
+ `data-testid` anchors it has; this skill turns one map entry into a run.
40
+
41
+ ```bash
42
+ # By route (tabs go in the query string, as the map shows them)
43
+ bash execute.sh --route "/tickets?tab=runs"
44
+
45
+ # By a word from the map's Routes table; :params get values
46
+ bash execute.sh --feature "Run detail" --param id=<work item id>
47
+
48
+ # Your own wait target and a full-page shot
49
+ bash execute.sh --route /wiki --wait '[data-testid="wiki-recent-list"]' --full-page
50
+
51
+ # See what would run (route, URL, wait selector, evidence folder) without a browser
52
+ bash execute.sh --feature Usage --dry-run
53
+ ```
54
+
55
+ What you get back is one JSON line with `dir`: the evidence folder under
56
+ `$CREWLY_HOME/evidence/<utc stamp>-<route>/` holding `screenshot.png`,
57
+ `console.json` (console errors, warnings and page errors), `requests.json`
58
+ (failed requests and responses ≥ 400), `report.md` and `summary.json`.
59
+ Exit 0 means the key element appeared; exit 2 means it did not (the
60
+ evidence is still there — that is the bug, or the wrong selector); exit 1 is
61
+ a usage or browser problem.
62
+
63
+ How to use it on a report:
64
+
65
+ 1. Find the feature in the map (the words on the screenshot are usually in
66
+ a page's **Expected** or **Elements** line). Note its route and `?tab=`.
67
+ 2. Run this skill on that route. Compare `screenshot.png` with the owner's
68
+ screenshot; read `console.json` and `requests.json` first — a failed
69
+ `/api/...` call explains most blank or stale pages.
70
+ 3. Put the `report.md` path (or its key lines) in the ticket, then fix. After
71
+ the fix, run it again and attach the second folder as proof.
72
+
73
+ Notes:
74
+
75
+ - The dashboard must be running on this machine (`CREWLY_API_URL`, default
76
+ `http://localhost:8787`); loopback needs no token.
77
+ - Needs Google Chrome (or `--browser <chromium path>`); playwright-core ships
78
+ with Crewly, no browser download.
79
+ - `--feature` needs the map: a Crewly checkout at `CREWLY_PROJECT_PATH`, or
80
+ `--map <path>`. Without a map, pass `--route` and `--wait`.
81
+ - Pages behind a Cloud sign-in or that need a real agent session show their
82
+ empty or signed-out state; that is still evidence, say so in the ticket.
83
+ - For a page the owner is looking at in Chrome right now, or a site outside
84
+ this dashboard, use the `remote-browser` skill instead.
@@ -0,0 +1,100 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # reproduce-ui — open a dashboard feature by its feature-map entry and save
4
+ # evidence: screenshot, console errors, failed requests, timings (#811).
5
+ #
6
+ # Drives a headless Chrome with playwright-core (a Crewly dependency) against
7
+ # the local dashboard. The feature map (frontend/FEATURE_MAP.md in a Crewly
8
+ # checkout) supplies the route and the element to wait for; without it, pass
9
+ # --route and --wait.
10
+ # =============================================================================
11
+ set -euo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "${SCRIPT_DIR}/../../_common/lib.sh"
15
+
16
+ # config/skills/agent/core/<skill> → the Crewly package root (repo or npm install)
17
+ PKG_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)"
18
+
19
+ print_usage() {
20
+ cat <<'EOF_USAGE'
21
+ Usage:
22
+ bash execute.sh --route /tickets?tab=runs Open a route, wait for its key element, save evidence
23
+ bash execute.sh --feature "Run detail" --param id=<uuid> Resolve the route from the feature map
24
+ bash execute.sh --route /wiki --wait '[data-testid="wiki-recent-list"]' --full-page
25
+
26
+ Options:
27
+ --route Path to open (with ?tab= when the page has tabs)
28
+ --feature A route or a word from a Routes row of FEATURE_MAP.md (needs the map)
29
+ --param k=v Value for a :param in the route (repeatable)
30
+ --wait CSS selector to wait for (default: the feature's first data-testid, else body)
31
+ --map FEATURE_MAP.md path (default: <project>/frontend/FEATURE_MAP.md when present)
32
+ --base-url Dashboard origin (default: CREWLY_API_URL)
33
+ --out Evidence root (default: $CREWLY_HOME/evidence)
34
+ --timeout Milliseconds to wait for navigation and for the element (default 60000)
35
+ --full-page Full-page screenshot
36
+ --browser Chrome/Chromium executable (default: the installed Google Chrome)
37
+ --dry-run Print the resolved plan, open nothing
38
+ --help | -h Show this help
39
+
40
+ Output: one JSON line with the evidence folder (screenshot.png, console.json,
41
+ requests.json, report.md, summary.json). Exit 0 when the element appeared, 2
42
+ when it did not (evidence still saved), 1 on a usage or browser error.
43
+ EOF_USAGE
44
+ }
45
+
46
+ ROUTE=""; FEATURE=""; WAIT=""; MAP=""; BASE_URL=""; OUT=""; TIMEOUT=""; FULL_PAGE=""; BROWSER=""; DRY_RUN=""
47
+ PARAMS=()
48
+
49
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
50
+ ROUTE=$(printf '%s' "$1" | jq -r '.route // empty')
51
+ FEATURE=$(printf '%s' "$1" | jq -r '.feature // empty')
52
+ WAIT=$(printf '%s' "$1" | jq -r '.wait // empty')
53
+ MAP=$(printf '%s' "$1" | jq -r '.map // empty')
54
+ BASE_URL=$(printf '%s' "$1" | jq -r '.baseUrl // empty')
55
+ OUT=$(printf '%s' "$1" | jq -r '.out // empty')
56
+ TIMEOUT=$(printf '%s' "$1" | jq -r '.timeout // empty')
57
+ FULL_PAGE=$(printf '%s' "$1" | jq -r 'if .fullPage == true then "1" else empty end')
58
+ DRY_RUN=$(printf '%s' "$1" | jq -r 'if .dryRun == true then "1" else empty end')
59
+ while IFS= read -r kv; do [ -n "$kv" ] && PARAMS+=("$kv"); done < <(printf '%s' "$1" | jq -r '(.params // {}) | to_entries[] | "\(.key)=\(.value)"')
60
+ shift || true
61
+ fi
62
+
63
+ while [[ $# -gt 0 ]]; do
64
+ case "$1" in
65
+ --route) [ $# -ge 2 ] || error_exit "--route requires a value"; ROUTE="$2"; shift 2 ;;
66
+ --feature) [ $# -ge 2 ] || error_exit "--feature requires a value"; FEATURE="$2"; shift 2 ;;
67
+ --param) [ $# -ge 2 ] || error_exit "--param requires k=v"; PARAMS+=("$2"); shift 2 ;;
68
+ --wait) [ $# -ge 2 ] || error_exit "--wait requires a selector"; WAIT="$2"; shift 2 ;;
69
+ --map) [ $# -ge 2 ] || error_exit "--map requires a path"; MAP="$2"; shift 2 ;;
70
+ --base-url) [ $# -ge 2 ] || error_exit "--base-url requires a value"; BASE_URL="$2"; shift 2 ;;
71
+ --out) [ $# -ge 2 ] || error_exit "--out requires a path"; OUT="$2"; shift 2 ;;
72
+ --timeout) [ $# -ge 2 ] || error_exit "--timeout requires ms"; TIMEOUT="$2"; shift 2 ;;
73
+ --browser) [ $# -ge 2 ] || error_exit "--browser requires a path"; BROWSER="$2"; shift 2 ;;
74
+ --full-page) FULL_PAGE="1"; shift ;;
75
+ --dry-run) DRY_RUN="1"; shift ;;
76
+ --help|-h) print_usage; exit 0 ;;
77
+ *) unknown_arg_exit "$1" ;;
78
+ esac
79
+ done
80
+
81
+ [ -n "$ROUTE" ] || [ -n "$FEATURE" ] || error_exit "one of --route or --feature is required (--help for usage)"
82
+ command -v node >/dev/null 2>&1 || error_exit "node is required"
83
+
84
+ # The map: explicit, else the project's checkout, else the package's own copy (a repo install).
85
+ if [ -z "$MAP" ]; then
86
+ for cand in "${CREWLY_PROJECT_PATH:-$PWD}/frontend/FEATURE_MAP.md" "${PKG_ROOT}/frontend/FEATURE_MAP.md"; do
87
+ if [ -f "$cand" ]; then MAP="$cand"; break; fi
88
+ done
89
+ fi
90
+
91
+ for kv in "${PARAMS[@]+"${PARAMS[@]}"}"; do
92
+ [[ "$kv" == *=* ]] || error_exit "--param needs k=v, got: $kv"
93
+ key="${kv%%=*}"; val="${kv#*=}"
94
+ [[ "$key" =~ ^[A-Za-z_]+$ ]] || error_exit "--param name must be a word: $key"
95
+ export "RU_PARAM_${key}=${val}"
96
+ done
97
+
98
+ RU_MAIN=1 RU_ROUTE="$ROUTE" RU_FEATURE="$FEATURE" RU_WAIT="$WAIT" RU_MAP="$MAP" RU_BASE_URL="$BASE_URL" \
99
+ RU_OUT="$OUT" RU_TIMEOUT_MS="$TIMEOUT" RU_FULL_PAGE="$FULL_PAGE" RU_BROWSER="$BROWSER" RU_DRY_RUN="$DRY_RUN" \
100
+ RU_PKG_ROOT="$PKG_ROOT" node "${SCRIPT_DIR}/reproduce.mjs"
@@ -0,0 +1,123 @@
1
+ #!/bin/bash
2
+ # Tests for reproduce-ui — run with: bash execute.test.sh
3
+ # Offline: a fixture feature map, --dry-run only (no browser is started).
4
+ set -u
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ EXEC="$HERE/execute.sh"
7
+ PASS=0; FAIL=0
8
+ check() {
9
+ local name="$1"; local got="$2"; local want="$3"
10
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
11
+ }
12
+
13
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
14
+ MAP="$T/FEATURE_MAP.md"
15
+ cat > "$MAP" <<'EOF_MAP'
16
+ # Feature map
17
+
18
+ ## Routes
19
+
20
+ | Route | Page | Reached from | One job |
21
+ | --- | --- | --- | --- |
22
+ | `/` | `src/pages/Dashboard.tsx` | sidebar Dashboard | What needs me |
23
+ | `/tickets` | `src/pages/hubs/TicketsHub.tsx` | sidebar Tickets | Board · Requests · Runs tabs |
24
+ | `/tickets/runs/:id` | `src/pages/WorkItemDetail.tsx` | Tickets › Runs row | One run (work item) |
25
+ | `/usage` | `src/pages/Usage.tsx` | sidebar Usage | Tokens by team |
26
+
27
+ ## Old URLs
28
+
29
+ | Old URL | Goes to |
30
+ | --- | --- |
31
+ | `/workitems` | `/tickets?tab=runs` |
32
+
33
+ ## Features
34
+
35
+ ### Dashboard (`/`)
36
+
37
+ - **Elements**: `dashboard`, `crew-now`.
38
+
39
+ ### Tickets hub (`/tickets`, tabs `board` · `runs`)
40
+
41
+ - **Elements**: `tickets-panel-${tab}`, `tickets-page`, `tickets-board`.
42
+
43
+ ### Run detail (`/tickets/runs/:id`)
44
+
45
+ - **Elements**: `workitem-detail-loading|error|not-found`, `workitem-detail`.
46
+
47
+ ### Usage (`/usage`)
48
+
49
+ - **Expected**: one headline.
50
+ EOF_MAP
51
+ export CREWLY_API_URL="http://127.0.0.1:18799"
52
+ export CREWLY_HOME="$T/home"
53
+
54
+ # ---- help ----
55
+ OUT=$(bash "$EXEC" --help 2>&1); RC=$?
56
+ check "help exits 0" "$RC" "0"
57
+ check "help names --feature" "$(printf '%s' "$OUT" | grep -c -- '--feature')" "$(printf '%s' "$OUT" | grep -c -- '--feature')"
58
+ [ "$(printf '%s' "$OUT" | grep -c -- '--feature')" -ge 1 ] || { FAIL=$((FAIL+1)); echo "FAIL: help lacks --feature"; }
59
+
60
+ # ---- no route / feature ----
61
+ ERR=$(bash "$EXEC" --dry-run 2>&1 >/dev/null); RC=$?
62
+ check "no route: exit 1" "$RC" "1"
63
+ check "no route: error says so" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'route or --feature')" "1"
64
+
65
+ # ---- route + map: wait selector from the Elements line; ${} and a|b skipped ----
66
+ OUT=$(bash "$EXEC" --route "/tickets?tab=runs" --map "$MAP" --dry-run)
67
+ check "route: dryRun" "$(printf '%s' "$OUT" | jq -r .dryRun)" "true"
68
+ check "route: url" "$(printf '%s' "$OUT" | jq -r .url)" "http://127.0.0.1:18799/tickets?tab=runs"
69
+ check "route: wait = the tab's panel" "$(printf '%s' "$OUT" | jq -r .waitFor)" '[data-testid="tickets-panel-runs"]'
70
+ check "route without tab: first static id" "$(bash "$EXEC" --route /tickets --map "$MAP" --dry-run | jq -r .waitFor)" '[data-testid="tickets-page"]'
71
+ check "route: waitSource map" "$(printf '%s' "$OUT" | jq -r .waitSource)" "map"
72
+ check "route: feature job from the row" "$(printf '%s' "$OUT" | jq -r .feature.job)" "Board · Requests · Runs tabs"
73
+ check "route: evidence root under CREWLY_HOME" "$(printf '%s' "$OUT" | jq -r .outRoot)" "$T/home/evidence"
74
+
75
+ OUT=$(bash "$EXEC" --route "/tickets/runs/abc" --map "$MAP" --dry-run)
76
+ check "run detail literal id: alternatives skipped" "$(printf '%s' "$OUT" | jq -r .waitFor)" '[data-testid="workitem-detail"]'
77
+
78
+ # ---- feature word → route; :id from --param ----
79
+ OUT=$(bash "$EXEC" --feature "One run" --param id=wi-9 --map "$MAP" --dry-run)
80
+ check "feature: route resolved" "$(printf '%s' "$OUT" | jq -r .route)" "/tickets/runs/:id"
81
+ check "feature: param filled" "$(printf '%s' "$OUT" | jq -r .path)" "/tickets/runs/wi-9"
82
+
83
+ ERR=$(bash "$EXEC" --feature "One run" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
84
+ check "feature without param: exit 1" "$RC" "1"
85
+ check "feature without param: names it" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'param id')" "1"
86
+
87
+ OUT=$(bash "$EXEC" --feature "/usage" --map "$MAP" --dry-run)
88
+ check "exact route as feature" "$(printf '%s' "$OUT" | jq -r .path)" "/usage"
89
+ check "no Elements line → body" "$(printf '%s' "$OUT" | jq -r .waitFor)" "body"
90
+ check "waitSource default" "$(printf '%s' "$OUT" | jq -r .waitSource)" "default"
91
+
92
+ ERR=$(bash "$EXEC" --feature "tickets" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
93
+ check "ambiguous feature: exit 1" "$RC" "1"
94
+ check "ambiguous feature: lists candidates" "$(printf '%s' "$ERR" | jq -r .error | grep -c '/tickets/runs/:id')" "1"
95
+
96
+ ERR=$(bash "$EXEC" --feature "nothing-like-this" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
97
+ check "unknown feature: exit 1" "$RC" "1"
98
+ check "unknown feature: says no row" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'no feature-map row')" "1"
99
+
100
+ ERR=$(bash "$EXEC" --feature "Usage" --map "$T/missing.md" --dry-run 2>&1 >/dev/null); RC=$?
101
+ check "feature without map: exit 1" "$RC" "1"
102
+ check "feature without map: explains" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'needs the feature map')" "1"
103
+
104
+ # ---- explicit wait, base url, timeout, full page, JSON input ----
105
+ OUT=$(bash "$EXEC" --route /x --wait '#main' --base-url http://localhost:9999/ --timeout 500 --full-page --dry-run)
106
+ check "explicit wait" "$(printf '%s' "$OUT" | jq -r .waitFor)" "#main"
107
+ check "waitSource arg" "$(printf '%s' "$OUT" | jq -r .waitSource)" "arg"
108
+ check "base url trailing slash trimmed" "$(printf '%s' "$OUT" | jq -r .url)" "http://localhost:9999/x"
109
+ check "timeout" "$(printf '%s' "$OUT" | jq -r .timeoutMs)" "500"
110
+ check "full page" "$(printf '%s' "$OUT" | jq -r .fullPage)" "true"
111
+
112
+ OUT=$(bash "$EXEC" '{"route":"/usage","map":"'"$MAP"'","dryRun":true,"params":{"id":"1"},"fullPage":true}')
113
+ check "json input: route" "$(printf '%s' "$OUT" | jq -r .path)" "/usage"
114
+ check "json input: fullPage" "$(printf '%s' "$OUT" | jq -r .fullPage)" "true"
115
+
116
+ ERR=$(bash "$EXEC" --route relative --dry-run 2>&1 >/dev/null); RC=$?
117
+ check "route must start with /" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'must start with /')" "1"
118
+
119
+ ERR=$(bash "$EXEC" --route /x --bogus 2>&1 >/dev/null); RC=$?
120
+ check "unknown arg: exit 2" "$RC" "2"
121
+
122
+ echo "reproduce-ui: $PASS passed, $FAIL failed"
123
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,366 @@
1
+ /**
2
+ * reproduce-ui — open a dashboard feature by its feature-map entry and save
3
+ * evidence (#811 slice 2, specs/2026-10-26-feature-map.md).
4
+ *
5
+ * Driven by execute.sh through environment variables:
6
+ * RU_ROUTE route to open, e.g. `/tickets?tab=runs` (or RU_FEATURE)
7
+ * RU_FEATURE a word of a Routes row of the map, resolved to its route
8
+ * RU_WAIT CSS selector to wait for; default = the first data-testid
9
+ * of the feature's Elements line in the map, else `body`
10
+ * RU_MAP path of FEATURE_MAP.md (optional; lookups need it)
11
+ * RU_BASE_URL dashboard origin, default CREWLY_API_URL
12
+ * RU_OUT evidence root, default $CREWLY_HOME/evidence
13
+ * RU_TIMEOUT_MS how long to wait for the element (default 60000)
14
+ * RU_FULL_PAGE "1" = full-page screenshot
15
+ * RU_BROWSER path of a Chromium/Chrome executable (default: installed Chrome)
16
+ * RU_DRY_RUN "1" = print the resolved plan as JSON and exit (no browser)
17
+ * RU_PKG_ROOT Crewly package root (where playwright-core resolves from)
18
+ *
19
+ * Output: one JSON line on stdout — the plan (dry run) or the summary with the
20
+ * evidence folder. Exit 0 when the element appeared, 2 when it did not (the
21
+ * evidence is still written), 1 on a usage or launch error.
22
+ */
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { join, resolve } from 'node:path';
28
+
29
+ const env = process.env;
30
+
31
+ /**
32
+ * How long to wait for navigation and for the element. The dashboard bundle
33
+ * takes 20–40 s to render on a loaded machine (headless Chrome shares the
34
+ * CPU with the agents), so a short default would report bugs that are not.
35
+ */
36
+ const DEFAULT_TIMEOUT_MS = 60000;
37
+
38
+ /**
39
+ * Print a JSON error and exit.
40
+ *
41
+ * @param {string} message - What went wrong
42
+ * @param {number} [code] - Exit code
43
+ */
44
+ function fail(message, code = 1) {
45
+ process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`);
46
+ process.exit(code);
47
+ }
48
+
49
+ /**
50
+ * The text of one `## ` section of a markdown document.
51
+ *
52
+ * @param {string} markdown - The document
53
+ * @param {string} heading - The exact heading line
54
+ * @returns {string} The section body, '' when absent
55
+ */
56
+ export function markdownSection(markdown, heading) {
57
+ const lines = markdown.split('\n');
58
+ const start = lines.findIndex((l) => l.trim() === heading);
59
+ if (start < 0) return '';
60
+ const rest = lines.slice(start + 1);
61
+ const end = rest.findIndex((l) => /^## /.test(l));
62
+ return (end < 0 ? rest : rest.slice(0, end)).join('\n');
63
+ }
64
+
65
+ /**
66
+ * The rows of the map's Routes table.
67
+ *
68
+ * @param {string} markdown - FEATURE_MAP.md
69
+ * @returns {Array<{route: string, page: string, reach: string, job: string}>}
70
+ */
71
+ export function routeRows(markdown) {
72
+ const rows = [];
73
+ for (const line of markdownSection(markdown, '## Routes').split('\n')) {
74
+ const cells = line.trim().split('|').map((c) => c.trim());
75
+ // A table row is `| a | b | c | d |` → ['', a, b, c, d, '']
76
+ if (cells.length < 5 || !/^`\/.*`$/.test(cells[1])) continue;
77
+ rows.push({ route: cells[1].replace(/`/g, ''), page: cells[2].replace(/`/g, ''), reach: cells[3], job: cells[4] ?? '' });
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ /**
83
+ * Resolve a feature word to one Routes row: an exact route, else the only
84
+ * row whose route, page, reach or job contains the word (case-insensitive).
85
+ *
86
+ * @param {string} markdown - FEATURE_MAP.md
87
+ * @param {string} feature - Route or word
88
+ * @returns {{row?: object, candidates?: string[], error?: string}}
89
+ */
90
+ export function resolveFeature(markdown, feature) {
91
+ const rows = routeRows(markdown);
92
+ const exact = rows.find((r) => r.route === feature);
93
+ if (exact) return { row: exact };
94
+ const needle = feature.toLowerCase();
95
+ const hits = rows.filter((r) => [r.route, r.page, r.reach, r.job].some((c) => c.toLowerCase().includes(needle)));
96
+ if (hits.length === 1) return { row: hits[0] };
97
+ if (hits.length === 0) return { error: `no feature-map row matches "${feature}"`, candidates: rows.map((r) => r.route) };
98
+ return { error: `"${feature}" matches several routes; pass --route`, candidates: hits.map((r) => r.route) };
99
+ }
100
+
101
+ /**
102
+ * The data-testid to wait for on a route, from the Features section that
103
+ * mentions it (its `**Elements**` line), as a CSS selector.
104
+ *
105
+ * A page with tabs lists its panel as `x-panel-${tab}`; with a `tab` the
106
+ * placeholder is filled (`tickets-panel-runs`), which is the element that
107
+ * proves the right tab rendered. Otherwise the first static id wins: `${…}`
108
+ * ids are per-item, `a|b` lists variants, neither is one selector.
109
+ *
110
+ * @param {string} markdown - FEATURE_MAP.md
111
+ * @param {string} route - Route pattern, e.g. `/tickets/runs/:id`
112
+ * @param {string} [tab] - The `?tab=` value of the opened path
113
+ * @returns {string|null} `[data-testid="x"]`, or null when none fits
114
+ */
115
+ export function defaultWaitSelector(markdown, route, tab) {
116
+ const features = markdownSection(markdown, '## Features');
117
+ const blocks = features.split(/\n(?=### )/);
118
+ const block = blocks.find((b) => b.split('\n')[0].includes(`\`${route}\``));
119
+ if (!block) return null;
120
+ const elements = block.split('\n').find((l) => l.includes('**Elements**'));
121
+ if (!elements) return null;
122
+ const ids = [...elements.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
123
+ if (tab) {
124
+ const panel = ids.find((id) => id.includes('${tab}'));
125
+ if (panel) return `[data-testid="${panel.replace('${tab}', tab)}"]`;
126
+ }
127
+ for (const id of ids) {
128
+ if (id.includes('$') || id.includes('|') || /\s/.test(id)) continue;
129
+ return `[data-testid="${id}"]`;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * The Routes row whose pattern matches an opened path (`/tickets/runs/abc`
136
+ * → `/tickets/runs/:id`); an exact match wins, the query string is ignored.
137
+ *
138
+ * @param {Array<{route: string}>} rows - From {@link routeRows}
139
+ * @param {string} path - Route or path, with or without `?tab=`
140
+ * @returns {object|null} The row, or null
141
+ */
142
+ export function matchRouteRow(rows, path) {
143
+ const bare = path.split('?')[0].split('#')[0];
144
+ const exact = rows.find((r) => r.route === bare);
145
+ if (exact) return exact;
146
+ return (
147
+ rows.find((r) => {
148
+ const re = new RegExp(`^${r.route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:[A-Za-z_]+/g, '[^/]+')}$`);
149
+ return re.test(bare);
150
+ }) ?? null
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Turn a route pattern into an openable path: `:id` params need values.
156
+ *
157
+ * @param {string} route - Route or pattern
158
+ * @param {Record<string,string>} params - RU_PARAM_<name> values
159
+ * @returns {{path: string, missing: string[]}}
160
+ */
161
+ export function fillRoute(route, params) {
162
+ const missing = [];
163
+ const path = route.replace(/:([A-Za-z_]+)/g, (_, name) => {
164
+ const v = params[name];
165
+ if (v === undefined) missing.push(name);
166
+ return v === undefined ? `:${name}` : encodeURIComponent(v);
167
+ });
168
+ return { path, missing };
169
+ }
170
+
171
+ /**
172
+ * A folder name for one run: `<utc-stamp>-<route slug>`.
173
+ *
174
+ * @param {string} route - The opened path
175
+ * @param {Date} now - Clock
176
+ * @returns {string}
177
+ */
178
+ export function runFolderName(route, now) {
179
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
180
+ const slug = route.replace(/^\//, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'home';
181
+ return `${stamp}-${slug.slice(0, 60)}`;
182
+ }
183
+
184
+ /**
185
+ * The run plan from the environment (pure apart from reading the map file).
186
+ *
187
+ * @returns {object} plan
188
+ */
189
+ export function buildPlan() {
190
+ const mapPath = env.RU_MAP && existsSync(env.RU_MAP) ? env.RU_MAP : null;
191
+ const map = mapPath ? readFileSync(mapPath, 'utf8') : null;
192
+ let route = env.RU_ROUTE || '';
193
+ let row = null;
194
+ if (!route && env.RU_FEATURE) {
195
+ if (!map) fail(`--feature needs the feature map; pass --map or --route (looked for ${env.RU_MAP || 'FEATURE_MAP.md'})`);
196
+ const r = resolveFeature(map, env.RU_FEATURE);
197
+ if (r.error) fail(`${r.error}${r.candidates?.length ? ` (routes: ${r.candidates.join(', ')})` : ''}`);
198
+ row = r.row;
199
+ route = row.route;
200
+ } else if (route && map) {
201
+ row = matchRouteRow(routeRows(map), route);
202
+ }
203
+ if (!route) fail('one of --route or --feature is required');
204
+ if (!route.startsWith('/')) fail('--route must start with /');
205
+ const params = {};
206
+ for (const [k, v] of Object.entries(env)) if (k.startsWith('RU_PARAM_') && v) params[k.slice('RU_PARAM_'.length)] = v;
207
+ const { path, missing } = fillRoute(route, params);
208
+ if (missing.length) fail(`route ${route} needs a value for ${missing.map((m) => `--param ${m}=…`).join(', ')}`);
209
+ const mapRoute = row?.route ?? route.split('?')[0];
210
+ const tab = /[?&]tab=([^&#]+)/.exec(route)?.[1];
211
+ const waitFor = env.RU_WAIT || (map ? defaultWaitSelector(map, mapRoute, tab ? decodeURIComponent(tab) : undefined) : null) || 'body';
212
+ const baseUrl = (env.RU_BASE_URL || env.CREWLY_API_URL || `http://localhost:${env.WEB_PORT || 8787}`).replace(/\/+$/, '');
213
+ const outRoot = env.RU_OUT || join(env.CREWLY_HOME || join(homedir(), '.crewly'), 'evidence');
214
+ return {
215
+ route,
216
+ path,
217
+ url: `${baseUrl}${path}`,
218
+ waitFor,
219
+ waitSource: env.RU_WAIT ? 'arg' : waitFor === 'body' ? 'default' : 'map',
220
+ timeoutMs: Number(env.RU_TIMEOUT_MS) > 0 ? Number(env.RU_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS,
221
+ fullPage: env.RU_FULL_PAGE === '1',
222
+ map: mapPath,
223
+ feature: row ? { page: row.page, reach: row.reach, job: row.job } : null,
224
+ outRoot,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Load playwright-core from the Crewly package.
230
+ *
231
+ * @returns {import('playwright-core')}
232
+ */
233
+ function loadPlaywright() {
234
+ const root = env.RU_PKG_ROOT || resolve(process.cwd());
235
+ try {
236
+ const req = createRequire(join(root, 'package.json'));
237
+ return req('playwright-core');
238
+ } catch (e) {
239
+ fail(`playwright-core is not available under ${root} (${e instanceof Error ? e.message : String(e)})`);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Run the plan in a headless browser and write the evidence.
245
+ *
246
+ * @param {object} plan - From {@link buildPlan}
247
+ * @returns {Promise<number>} Exit code
248
+ */
249
+ async function run(plan) {
250
+ const started = Date.now();
251
+ const debug = (m) => {
252
+ if (env.RU_DEBUG === '1') process.stderr.write(`[reproduce-ui +${Date.now() - started}ms] ${m}\n`);
253
+ };
254
+ const pw = loadPlaywright();
255
+ const dir = join(plan.outRoot, runFolderName(plan.path, new Date()));
256
+ mkdirSync(dir, { recursive: true });
257
+ const launch = plan.browser ? { executablePath: plan.browser } : { channel: 'chrome' };
258
+ let browser;
259
+ try {
260
+ browser = await pw.chromium.launch({ headless: true, ...launch });
261
+ } catch (e) {
262
+ fail(`could not start a browser (${e instanceof Error ? e.message.split('\n')[0] : String(e)}); install Google Chrome or pass --browser <path>`);
263
+ }
264
+ debug('browser launched');
265
+ const console_ = [];
266
+ const requests = [];
267
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
268
+ page.on('console', (m) => {
269
+ if (m.type() === 'error' || m.type() === 'warning') console_.push({ type: m.type(), text: m.text().slice(0, 500), at: Date.now() });
270
+ });
271
+ page.on('pageerror', (e) => console_.push({ type: 'pageerror', text: String(e.message ?? e).slice(0, 500), at: Date.now() }));
272
+ page.on('requestfailed', (r) => requests.push({ url: r.url(), method: r.method(), failure: r.failure()?.errorText ?? 'failed' }));
273
+ page.on('response', (r) => {
274
+ if (r.status() >= 400) requests.push({ url: r.url(), method: r.request().method(), status: r.status() });
275
+ });
276
+ const t0 = Date.now();
277
+ let navMs = null;
278
+ let waitMs = null;
279
+ let found = false;
280
+ let navError = null;
281
+ try {
282
+ await page.goto(plan.url, { waitUntil: 'domcontentloaded', timeout: plan.timeoutMs });
283
+ navMs = Date.now() - t0;
284
+ debug(`navigated in ${navMs}ms`);
285
+ const t1 = Date.now();
286
+ await page.waitForSelector(plan.waitFor, { state: 'visible', timeout: plan.timeoutMs });
287
+ waitMs = Date.now() - t1;
288
+ found = true;
289
+ debug(`element in ${waitMs}ms`);
290
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
291
+ } catch (e) {
292
+ navError = e instanceof Error ? e.message.split('\n')[0] : String(e);
293
+ debug(`stopped: ${navError}`);
294
+ }
295
+ const screenshot = join(dir, 'screenshot.png');
296
+ try {
297
+ await page.screenshot({ path: screenshot, fullPage: plan.fullPage, timeout: plan.timeoutMs });
298
+ } catch {
299
+ // A page that never rendered has nothing to shoot; the report says so.
300
+ }
301
+ debug('screenshot taken');
302
+ const title = await page.title().catch(() => '');
303
+ const finalUrl = page.url();
304
+ await browser.close();
305
+ debug('browser closed');
306
+
307
+ const summary = {
308
+ ok: found,
309
+ route: plan.route,
310
+ url: plan.url,
311
+ finalUrl,
312
+ title,
313
+ waitFor: plan.waitFor,
314
+ waitSource: plan.waitSource,
315
+ found,
316
+ error: navError,
317
+ timings: { navigationMs: navMs, elementMs: waitMs, totalMs: Date.now() - t0 },
318
+ consoleErrors: console_.length,
319
+ failedRequests: requests.length,
320
+ dir,
321
+ screenshot: existsSync(screenshot) ? screenshot : null,
322
+ feature: plan.feature,
323
+ };
324
+ writeFileSync(join(dir, 'console.json'), `${JSON.stringify(console_, null, 2)}\n`);
325
+ writeFileSync(join(dir, 'requests.json'), `${JSON.stringify(requests, null, 2)}\n`);
326
+ writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
327
+ writeFileSync(join(dir, 'report.md'), renderReport(summary, console_, requests));
328
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
329
+ return found ? 0 : 2;
330
+ }
331
+
332
+ /**
333
+ * The human-readable report of one run.
334
+ *
335
+ * @param {object} s - Summary
336
+ * @param {Array<object>} console_ - Console errors
337
+ * @param {Array<object>} requests - Failed requests
338
+ * @returns {string} Markdown
339
+ */
340
+ export function renderReport(s, console_, requests) {
341
+ const lines = [
342
+ `# Reproduce: ${s.route}`,
343
+ '',
344
+ `- URL: ${s.url}${s.finalUrl && s.finalUrl !== s.url ? ` → ${s.finalUrl}` : ''}`,
345
+ `- Title: ${s.title || '(none)'}`,
346
+ `- Waited for: \`${s.waitFor}\` (${s.waitSource}) — ${s.found ? 'appeared' : `NOT found${s.error ? `: ${s.error}` : ''}`}`,
347
+ `- Timings: navigation ${s.timings.navigationMs ?? '–'} ms, element ${s.timings.elementMs ?? '–'} ms, total ${s.timings.totalMs} ms`,
348
+ `- Screenshot: ${s.screenshot ? 'screenshot.png' : '(none)'}`,
349
+ ];
350
+ if (s.feature) lines.push(`- Feature: ${s.feature.job} — reached from ${s.feature.reach}; page \`${s.feature.page}\``);
351
+ lines.push('', `## Console errors (${console_.length})`, '');
352
+ lines.push(...(console_.length ? console_.map((c) => `- [${c.type}] ${c.text}`) : ['- none']));
353
+ lines.push('', `## Failed requests (${requests.length})`, '');
354
+ lines.push(...(requests.length ? requests.map((r) => `- ${r.method} ${r.url} → ${r.status ?? r.failure}`) : ['- none']));
355
+ return `${lines.join('\n')}\n`;
356
+ }
357
+
358
+ if (env.RU_MAIN === '1') {
359
+ const plan = buildPlan();
360
+ plan.browser = env.RU_BROWSER || null;
361
+ if (env.RU_DRY_RUN === '1') {
362
+ process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, ...plan })}\n`);
363
+ } else {
364
+ run(plan).then((code) => process.exit(code));
365
+ }
366
+ }