crewly 1.20.430 → 1.20.432
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/config/skills/agent/core/reproduce-ui/SKILL.md +84 -0
- package/config/skills/agent/core/reproduce-ui/execute.sh +100 -0
- package/config/skills/agent/core/reproduce-ui/execute.test.sh +123 -0
- package/config/skills/agent/core/reproduce-ui/reproduce.mjs +366 -0
- package/config/sops/developer/debugging.md +4 -2
- package/dist/backend/backend/src/constants.d.ts +22 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +20 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +6 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/core/dashboard-build.d.ts +20 -0
- package/dist/backend/backend/src/services/core/dashboard-build.d.ts.map +1 -1
- package/dist/backend/backend/src/services/core/dashboard-build.js +38 -0
- package/dist/backend/backend/src/services/core/dashboard-build.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts +32 -0
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js +74 -1
- package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
- package/dist/backend/backend/src/services/monitoring/agent-attention.d.ts +24 -0
- package/dist/backend/backend/src/services/monitoring/agent-attention.d.ts.map +1 -1
- package/dist/backend/backend/src/services/monitoring/agent-attention.js +59 -2
- package/dist/backend/backend/src/services/monitoring/agent-attention.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +22 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +20 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts +32 -0
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js +74 -1
- package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
- package/dist/cli/backend/src/services/monitoring/agent-attention.d.ts +24 -0
- package/dist/cli/backend/src/services/monitoring/agent-attention.d.ts.map +1 -1
- package/dist/cli/backend/src/services/monitoring/agent-attention.js +59 -2
- package/dist/cli/backend/src/services/monitoring/agent-attention.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|