state-scenarios-cli 0.5.0 → 0.7.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/README.md +6 -1
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +228 -11
- package/dist/cli.js.map +1 -1
- package/dist/compare-TBRNNO4A.js +69 -0
- package/dist/compare-TBRNNO4A.js.map +1 -0
- package/dist/compare.d.ts +14 -0
- package/dist/map-NAV75ZEE.js +159 -0
- package/dist/map-NAV75ZEE.js.map +1 -0
- package/dist/map.d.ts +73 -0
- package/dist/publish-K6YWPOCO.js +80 -0
- package/dist/publish-K6YWPOCO.js.map +1 -0
- package/dist/publish.d.ts +18 -0
- package/dist/record-QUE3LDEG.js +136 -0
- package/dist/record-QUE3LDEG.js.map +1 -0
- package/dist/record.d.ts +66 -1
- package/dist/{skills-NQRJHKHG.js → skills-P5SE3XSV.js} +7 -2
- package/dist/skills-P5SE3XSV.js.map +1 -0
- package/package.json +3 -3
- package/skills/create-scenario-map/SKILL.md +24 -4
- package/skills/scenario-coverage-loop/SKILL.md +122 -0
- package/skills/scenario-coverage-loop/references/controller.mjs +38 -0
- package/skills/scenario-coverage-loop/references/memory.md +13 -0
- package/skills/scenario-coverage-loop/references/pr-check.yml +50 -0
- package/skills/scenario-coverage-loop/references/prompt.md +58 -0
- package/skills/scenario-coverage-loop/references/workflow.yml +232 -0
- package/skills/scenario-pr/SKILL.md +114 -0
- package/skills/scenario-pr/references/pr-template.md +61 -0
- package/skills/state-scenarios/SKILL.md +13 -5
- package/skills/state-scenarios/references/media.md +38 -4
- package/skills/state-scenarios/references/verify.md +55 -3
- package/dist/record-HXIYGMWA.js +0 -65
- package/dist/record-HXIYGMWA.js.map +0 -1
- package/dist/skills-NQRJHKHG.js.map +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Scenario coverage loop: sense -> select -> capture -> check -> one PR.
|
|
2
|
+
# Replace every <placeholder>. Shown with Claude Code and npm; swap in the repo's agent and package manager.
|
|
3
|
+
#
|
|
4
|
+
# Three jobs, so the checks are a boundary and not the agent's word for it:
|
|
5
|
+
# agent read-only token. Runs the agent; hands over a patch, never a push.
|
|
6
|
+
# verify read-only token, fresh runner. Refuses a patch outside the scenarios folder and the map,
|
|
7
|
+
# then checks it with the default branch's own install and code: nothing the agent wrote runs.
|
|
8
|
+
# open-pr the only job with write access. Applies the checked patch and opens the PR.
|
|
9
|
+
|
|
10
|
+
name: "Agent: scenario coverage"
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
schedule:
|
|
14
|
+
- cron: "0 7 * * 1" # Mondays 07:00 UTC
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions: {}
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: scenario-coverage
|
|
21
|
+
cancel-in-progress: false
|
|
22
|
+
|
|
23
|
+
env:
|
|
24
|
+
SCENARIOS: <src/scenarios>
|
|
25
|
+
MAP: <.agents/skills/scenarios-app>
|
|
26
|
+
BASE_URL: <http://localhost:5173>
|
|
27
|
+
BATCH: 3
|
|
28
|
+
LABEL: scenario-coverage
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
agent:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
pull-requests: read
|
|
36
|
+
outputs:
|
|
37
|
+
work: ${{ steps.patch.outputs.work }}
|
|
38
|
+
steps:
|
|
39
|
+
# Flow control: scheduled runs wait until the last loop PR is merged or closed.
|
|
40
|
+
- name: One open loop PR at a time
|
|
41
|
+
id: gate
|
|
42
|
+
env:
|
|
43
|
+
GH_TOKEN: ${{ github.token }}
|
|
44
|
+
run: |
|
|
45
|
+
OPEN=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --label "$LABEL" --json number --jq length)
|
|
46
|
+
if [ "${{ github.event_name }}" = schedule ] && [ "$OPEN" -gt 0 ]; then
|
|
47
|
+
echo "run=false" >> "$GITHUB_OUTPUT"
|
|
48
|
+
else
|
|
49
|
+
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
- uses: actions/checkout@v5
|
|
53
|
+
if: steps.gate.outputs.run == 'true'
|
|
54
|
+
with:
|
|
55
|
+
fetch-depth: 0
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
|
|
58
|
+
- uses: actions/setup-node@v5
|
|
59
|
+
if: steps.gate.outputs.run == 'true'
|
|
60
|
+
with:
|
|
61
|
+
node-version: 24
|
|
62
|
+
|
|
63
|
+
- name: Install
|
|
64
|
+
if: steps.gate.outputs.run == 'true'
|
|
65
|
+
run: |
|
|
66
|
+
<npm ci>
|
|
67
|
+
npx playwright install --with-deps chromium
|
|
68
|
+
|
|
69
|
+
- name: Sensor
|
|
70
|
+
if: steps.gate.outputs.run == 'true'
|
|
71
|
+
run: npx state-scenarios coverage "$SCENARIOS" --json > /tmp/coverage.json
|
|
72
|
+
|
|
73
|
+
- name: Controller
|
|
74
|
+
id: select
|
|
75
|
+
if: steps.gate.outputs.run == 'true'
|
|
76
|
+
run: |
|
|
77
|
+
node scripts/scenario-coverage-next.mjs /tmp/coverage.json "$BATCH" > /tmp/selected.md
|
|
78
|
+
if [ -s /tmp/selected.md ]; then echo "work=true" >> "$GITHUB_OUTPUT"; else echo "Nothing missing."; fi
|
|
79
|
+
|
|
80
|
+
- name: Start the app
|
|
81
|
+
if: steps.select.outputs.work == 'true'
|
|
82
|
+
run: |
|
|
83
|
+
<npm run dev> > /tmp/dev.log 2>&1 &
|
|
84
|
+
for i in $(seq 60); do curl -sf "$BASE_URL" > /dev/null && exit 0; sleep 2; done
|
|
85
|
+
cat /tmp/dev.log; exit 1
|
|
86
|
+
|
|
87
|
+
- name: Actuator
|
|
88
|
+
if: steps.select.outputs.work == 'true'
|
|
89
|
+
env:
|
|
90
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
91
|
+
run: |
|
|
92
|
+
set -o pipefail
|
|
93
|
+
PROMPT="$(cat .github/agent-prompts/scenario-coverage.md)"
|
|
94
|
+
PROMPT="${PROMPT/\$SELECTED/$(cat /tmp/selected.md)}"
|
|
95
|
+
PROMPT="${PROMPT/\$MEMORY/$(cat .github/agent-memory/scenario-coverage.md)}"
|
|
96
|
+
|
|
97
|
+
# An explicit tool list rather than a permission bypass. It isn't a sandbox: --rules and
|
|
98
|
+
# --steps load JavaScript, and the agent can write files, so it can run code here. That's
|
|
99
|
+
# why this job holds no write token and the verify job decides what's kept.
|
|
100
|
+
npx -y @anthropic-ai/claude-code -p "$PROMPT" \
|
|
101
|
+
--mcp-config .mcp.json \
|
|
102
|
+
--permission-mode acceptEdits \
|
|
103
|
+
--allowedTools "Read,Edit,Write,Glob,Grep,mcp__state-scenarios,Bash(npx state-scenarios validate:*),Bash(npx state-scenarios coverage:*),Bash(npx state-scenarios list:*),Bash(npx state-scenarios shoot:*),Bash(git status:*),Bash(git diff:*)" \
|
|
104
|
+
--max-turns 80 \
|
|
105
|
+
--output-format stream-json --verbose \
|
|
106
|
+
2>&1 | tee /tmp/agent.jsonl
|
|
107
|
+
|
|
108
|
+
grep '^{' /tmp/agent.jsonl \
|
|
109
|
+
| jq -rs '[.[] | select(.type == "assistant")] | last | .message.content[] | select(.type == "text") | .text' \
|
|
110
|
+
> /tmp/pr-body.md
|
|
111
|
+
|
|
112
|
+
- name: Hand over a patch
|
|
113
|
+
id: patch
|
|
114
|
+
if: steps.select.outputs.work == 'true'
|
|
115
|
+
run: |
|
|
116
|
+
mkdir -p /tmp/handover
|
|
117
|
+
git add -A
|
|
118
|
+
git diff --cached --binary HEAD > /tmp/handover/change.patch
|
|
119
|
+
cp /tmp/pr-body.md /tmp/handover/pr-body.md
|
|
120
|
+
if [ -s /tmp/handover/change.patch ]; then echo "work=true" >> "$GITHUB_OUTPUT"; else echo "The agent changed nothing."; fi
|
|
121
|
+
|
|
122
|
+
- uses: actions/upload-artifact@v4
|
|
123
|
+
if: steps.patch.outputs.work == 'true'
|
|
124
|
+
with:
|
|
125
|
+
name: handover
|
|
126
|
+
path: /tmp/handover
|
|
127
|
+
|
|
128
|
+
- name: Keep the agent's log
|
|
129
|
+
if: always() && steps.select.outputs.work == 'true'
|
|
130
|
+
uses: actions/upload-artifact@v4
|
|
131
|
+
with:
|
|
132
|
+
name: agent-log
|
|
133
|
+
path: |
|
|
134
|
+
/tmp/agent.jsonl
|
|
135
|
+
/tmp/dev.log
|
|
136
|
+
|
|
137
|
+
verify:
|
|
138
|
+
needs: agent
|
|
139
|
+
if: needs.agent.outputs.work == 'true'
|
|
140
|
+
runs-on: ubuntu-latest
|
|
141
|
+
permissions:
|
|
142
|
+
contents: read
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v5
|
|
145
|
+
with:
|
|
146
|
+
persist-credentials: false
|
|
147
|
+
|
|
148
|
+
- uses: actions/download-artifact@v4
|
|
149
|
+
with:
|
|
150
|
+
name: handover
|
|
151
|
+
path: /tmp/handover
|
|
152
|
+
|
|
153
|
+
# Only data may change: scenario JSON and the map's Markdown. No symlinks, no "..",
|
|
154
|
+
# nothing else, so the install and dev server below are the default branch's own.
|
|
155
|
+
- name: The patch stays in scope
|
|
156
|
+
run: |
|
|
157
|
+
set -euo pipefail
|
|
158
|
+
PATCH=/tmp/handover/change.patch
|
|
159
|
+
git apply --check "$PATCH"
|
|
160
|
+
if git apply --summary "$PATCH" | grep -Eq 'mode 120000|rename|copy'; then
|
|
161
|
+
echo "::error::The patch adds a symlink, rename or copy."; exit 1
|
|
162
|
+
fi
|
|
163
|
+
BAD=0
|
|
164
|
+
while IFS= read -r path; do
|
|
165
|
+
case "$path" in
|
|
166
|
+
*..*) BAD=1 ;;
|
|
167
|
+
"$SCENARIOS"/*.json | "$MAP"/*.md) ;;
|
|
168
|
+
*) BAD=1 ;;
|
|
169
|
+
esac
|
|
170
|
+
[ "$BAD" = 1 ] && { echo "::error::Out of scope: $path"; exit 1; }
|
|
171
|
+
done < <(git apply --numstat "$PATCH" | cut -f3-)
|
|
172
|
+
git apply "$PATCH"
|
|
173
|
+
|
|
174
|
+
- uses: actions/setup-node@v5
|
|
175
|
+
with:
|
|
176
|
+
node-version: 24
|
|
177
|
+
|
|
178
|
+
- name: Install
|
|
179
|
+
run: |
|
|
180
|
+
<npm ci>
|
|
181
|
+
npx playwright install --with-deps chromium
|
|
182
|
+
|
|
183
|
+
- name: Scenarios parse and the map names only real ones
|
|
184
|
+
run: |
|
|
185
|
+
npx state-scenarios validate "$SCENARIOS"
|
|
186
|
+
npx state-scenarios coverage "$SCENARIOS"
|
|
187
|
+
|
|
188
|
+
- name: Start the app
|
|
189
|
+
run: |
|
|
190
|
+
<npm run dev> > /tmp/dev.log 2>&1 &
|
|
191
|
+
for i in $(seq 60); do curl -sf "$BASE_URL" > /dev/null && exit 0; sleep 2; done
|
|
192
|
+
cat /tmp/dev.log; exit 1
|
|
193
|
+
|
|
194
|
+
- name: Every state renders, the new ones included
|
|
195
|
+
run: npx state-scenarios shoot "$SCENARIOS" --base-url "$BASE_URL" --out /tmp/shots
|
|
196
|
+
|
|
197
|
+
- uses: actions/upload-artifact@v4
|
|
198
|
+
if: always()
|
|
199
|
+
with:
|
|
200
|
+
name: shots
|
|
201
|
+
path: /tmp/shots
|
|
202
|
+
|
|
203
|
+
open-pr:
|
|
204
|
+
needs: verify
|
|
205
|
+
runs-on: ubuntu-latest
|
|
206
|
+
permissions:
|
|
207
|
+
contents: write
|
|
208
|
+
pull-requests: write
|
|
209
|
+
steps:
|
|
210
|
+
- uses: actions/checkout@v5
|
|
211
|
+
|
|
212
|
+
- uses: actions/download-artifact@v4
|
|
213
|
+
with:
|
|
214
|
+
name: handover
|
|
215
|
+
path: /tmp/handover
|
|
216
|
+
|
|
217
|
+
# Nothing runs here but git and gh, on a patch verify already accepted.
|
|
218
|
+
- name: Open the PR
|
|
219
|
+
env:
|
|
220
|
+
GH_TOKEN: ${{ github.token }}
|
|
221
|
+
run: |
|
|
222
|
+
set -euo pipefail
|
|
223
|
+
BRANCH="agent/scenario-coverage-$GITHUB_RUN_ID"
|
|
224
|
+
git switch -c "$BRANCH"
|
|
225
|
+
git apply --index /tmp/handover/change.patch
|
|
226
|
+
git config user.name "github-actions[bot]"
|
|
227
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
228
|
+
git commit -m "scenarios: fill coverage gaps"
|
|
229
|
+
git push --set-upstream origin "$BRANCH"
|
|
230
|
+
gh label create "$LABEL" --color 0E8A16 --description "Scenario coverage loop" 2>/dev/null || true
|
|
231
|
+
gh pr create --head "$BRANCH" --title "Scenarios: fill coverage gaps ($(date +%F))" \
|
|
232
|
+
--body-file /tmp/handover/pr-body.md --label "$LABEL"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scenario-pr
|
|
3
|
+
description: >
|
|
4
|
+
Write or update a pull request description that shows the UI change: before and after
|
|
5
|
+
screenshots of every state the branch touches, taken with state-scenarios, published to the PR,
|
|
6
|
+
plus a short outline of the code's shape. Use for /scenario-pr, or when asked to describe a UI
|
|
7
|
+
PR "with screenshots", "show the before and after", or "make the PR visual".
|
|
8
|
+
disable-model-invocation: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Describe a UI pull request with its real states
|
|
12
|
+
|
|
13
|
+
A reviewer of a UI change needs to see it. This skill finds the states the branch touches, shoots
|
|
14
|
+
them before and after, publishes the images, and writes a short PR body around them. Write as one
|
|
15
|
+
person to another: plain words, no jargon, nothing a screenshot already says.
|
|
16
|
+
|
|
17
|
+
For the images alone on every PR, without an agent, the repository's GitHub Action
|
|
18
|
+
`jagreehal/state-scenarios/actions/pr-screenshots` posts them as a comment (see the README). This
|
|
19
|
+
skill adds the written description around them.
|
|
20
|
+
|
|
21
|
+
The `state-scenarios` skill is the reference for the library; its topic `verify` covers every
|
|
22
|
+
command used here. Read it first (`npx state-scenarios guide verify`).
|
|
23
|
+
|
|
24
|
+
## 1. Find the PR and the base
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gh pr view --json number,url,baseRefName,headRefName,title 2>/dev/null
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No PR yet: check `git status --short --branch` and the branch's commits. Commit work that belongs
|
|
31
|
+
to the task, push the branch, and `gh pr create --draft --fill`. Ask the user only when the branch
|
|
32
|
+
holds no relevant work.
|
|
33
|
+
|
|
34
|
+
Done when: you have the PR number and its base branch.
|
|
35
|
+
|
|
36
|
+
## 2. Understand the change
|
|
37
|
+
|
|
38
|
+
Read the full diff (`gh pr diff`) and enough of the code around it to explain what changed and why.
|
|
39
|
+
Read the ticket or task notes if they are linked.
|
|
40
|
+
|
|
41
|
+
Done when: you can say in one sentence why the change exists.
|
|
42
|
+
|
|
43
|
+
## 3. Pick the states
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx state-scenarios affected <dir> --base origin/<base> --src <app folders>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Treat the list as a starting point. Keep states the diff really changes; drop route or endpoint
|
|
50
|
+
matches the diff can't reach. When a shared file changed (layout, theme, global CSS), add the
|
|
51
|
+
main states of each feature, or all of them if there are few. Add loading, empty and error for
|
|
52
|
+
any feature the change touches. A change no scenario covers is worth saying in the PR; capturing
|
|
53
|
+
the state (topic `capture`) is better still.
|
|
54
|
+
|
|
55
|
+
Done when: you have the list of scenario names, each with a reason you could defend.
|
|
56
|
+
|
|
57
|
+
## 4. Shoot before and after
|
|
58
|
+
|
|
59
|
+
The dev server must be running (the project's scenario map skill, `scenarios-<app>`, says how).
|
|
60
|
+
|
|
61
|
+
1. Before: put the base's code in front of the dev server, e.g. `git switch --detach origin/<base>`
|
|
62
|
+
with a clean tree (or a worktree of the base on another port). Shoot the listed states that
|
|
63
|
+
exist on the base, with the same options you'll use after:
|
|
64
|
+
`npx state-scenarios shoot <dir> --base-url <url> --only <names> --out .shots/before`
|
|
65
|
+
2. After: switch back to the branch, let the dev server reload, then
|
|
66
|
+
`npx state-scenarios shoot <dir> --base-url <url> --only <names> --out .shots/after --compare .shots/before`
|
|
67
|
+
|
|
68
|
+
Look at every `.diff.png` and every `new` state. Tell the user about any diff the change didn't
|
|
69
|
+
intend before writing the PR. A scenario that fails (`✗`) needs fixing first; read its
|
|
70
|
+
`.md` page.
|
|
71
|
+
|
|
72
|
+
When the change is about an interaction (a flow, an animation, focus), record it too:
|
|
73
|
+
`npx state-scenarios record <dir> --base-url <url> --steps <file> --only <name> --format gif --width 900`
|
|
74
|
+
(topic `media`). GitHub plays GIFs inline; it doesn't play linked MP4s.
|
|
75
|
+
|
|
76
|
+
Done when: `.shots/after` holds a diff for each changed state, a PNG for each new one, and you
|
|
77
|
+
have looked at all of them.
|
|
78
|
+
|
|
79
|
+
## 5. Publish the images
|
|
80
|
+
|
|
81
|
+
If the repository is public, the images will be public: check them for real names, emails, tokens
|
|
82
|
+
or internal URLs first, and ask the user before the first publish.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx state-scenarios publish .shots/after/*.diff.png <new states' PNGs> <GIFs> --folder pr-<number>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It pushes to the `scenario-shots` branch without touching the checkout and prints one Markdown
|
|
89
|
+
image line per file. Publishing the same folder again replaces it.
|
|
90
|
+
|
|
91
|
+
Done when: you have an image line for each picture the PR body will show.
|
|
92
|
+
|
|
93
|
+
## 6. Write and save the description
|
|
94
|
+
|
|
95
|
+
Read [references/pr-template.md](references/pr-template.md) and follow it. Save the body to
|
|
96
|
+
`.shots/pr-body.md`, then:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
gh pr edit <number> --body-file .shots/pr-body.md
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Done when: `gh pr view <number>` shows the new body with its images.
|
|
103
|
+
|
|
104
|
+
## 7. Report
|
|
105
|
+
|
|
106
|
+
Tell the user, in a few lines: the PR URL, the states shown (changed, new, unchanged), anything
|
|
107
|
+
that looked wrong in the screenshots, and states the change touches that have no scenario.
|
|
108
|
+
|
|
109
|
+
## Rules
|
|
110
|
+
|
|
111
|
+
- Show only states the change touches. Unchanged states go in one line, not in pictures.
|
|
112
|
+
- Never publish a screenshot you haven't looked at.
|
|
113
|
+
- Keep `.shots/` out of commits (add it to `.gitignore` if it isn't).
|
|
114
|
+
- Don't restate the diff in prose. The outline shows shape; the screenshots show the result.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# PR description template
|
|
2
|
+
|
|
3
|
+
Fill this in. Leave out any section or view that doesn't apply; never add sections beyond these.
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
{One sentence: the problem this PR solves and what's possible once it ships.}
|
|
9
|
+
|
|
10
|
+
## What changes on screen
|
|
11
|
+
|
|
12
|
+
{One line per changed or new state, with its picture. Order by importance, not by name.}
|
|
13
|
+
|
|
14
|
+
**{State, e.g. "Orders: empty"}** · `{scenario-name}` · {changed | new}
|
|
15
|
+
{One short line on what changed, if the picture doesn't make it obvious.}
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
{Unchanged states that were checked, in one line: "No change: orders-error, orders-loading."}
|
|
19
|
+
|
|
20
|
+
## Things to note
|
|
21
|
+
|
|
22
|
+
- {1-3 bullets: anything the reviewer could miss. A state the change touches with no scenario,
|
|
23
|
+
an intended diff elsewhere, a migration, a deliberate omission. "None." when there is nothing.}
|
|
24
|
+
|
|
25
|
+
## Change outline
|
|
26
|
+
|
|
27
|
+
{The smallest code-shape views that explain the implementation, each under one line of text.
|
|
28
|
+
Pick from: a component tree, a call tree, a file tree with responsibilities, a key type, or
|
|
29
|
+
pseudocode for the logic. Use a `diff` block when the shape already existed, a plain block when
|
|
30
|
+
it's new.}
|
|
31
|
+
|
|
32
|
+
## Try it
|
|
33
|
+
|
|
34
|
+
{The scenario links for the states above, so a reviewer can open them in their own dev server.}
|
|
35
|
+
|
|
36
|
+
- `/orders?scenario=orders-empty`
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Change outline examples
|
|
40
|
+
|
|
41
|
+
A component change:
|
|
42
|
+
|
|
43
|
+
```diff
|
|
44
|
+
<OrdersPage> (src/features/orders/OrdersPage.tsx)
|
|
45
|
+
useOrders()
|
|
46
|
+
- <OrdersTable />
|
|
47
|
+
+ {orders.length ? <OrdersTable /> : <EmptyOrders />}
|
|
48
|
+
+ <EmptyOrders> (src/features/orders/EmptyOrders.tsx)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A control-flow change:
|
|
52
|
+
|
|
53
|
+
```diff
|
|
54
|
+
loadOrders
|
|
55
|
+
fetch /api/orders
|
|
56
|
+
- on error: throw
|
|
57
|
+
+ on error: show <OrdersError> with Retry
|
|
58
|
+
+ retry: loadOrders
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Keep each view to what the reviewer needs. Leave out files and calls the change didn't touch.
|
|
@@ -44,6 +44,13 @@ http://localhost:5173/orders?scenario=orders-empty&scenario-strict
|
|
|
44
44
|
real backend. Once `<html data-scenario="orders-empty">` is set and the `ready` selector is
|
|
45
45
|
visible, the state is on screen.
|
|
46
46
|
|
|
47
|
+
## Show the real state, not a mock-up
|
|
48
|
+
|
|
49
|
+
When you explain a UI change or a design choice to a person, show the app: `shoot --only` the states
|
|
50
|
+
in question and put the PNGs (or `--compare` diffs) in front of them, instead of drawing the UI in
|
|
51
|
+
HTML or ASCII. Real screenshots show real data, real styles and real bugs. Describe in words only
|
|
52
|
+
what a screenshot can't show, such as the order things happen in.
|
|
53
|
+
|
|
47
54
|
## Topics
|
|
48
55
|
|
|
49
56
|
Read the topic for the job before you start. The files sit next to this one, and
|
|
@@ -56,8 +63,8 @@ Read the topic for the job before you start. The files sit next to this one, and
|
|
|
56
63
|
| `capture` | [references/capture.md](references/capture.md) | Making a scenario from the running app: the panel, `?scenario-record`, MCP `save_scenario` |
|
|
57
64
|
| `auth` | [references/auth.md](references/auth.md) | Signed-in and signed-out states, cookies, apps that validate a token on startup |
|
|
58
65
|
| `network` | [references/network.md](references/network.md) | Strict mode, `allow`, `ignore` for analytics, an existing MSW setup, named handlers |
|
|
59
|
-
| `verify` | [references/verify.md](references/verify.md) | Checking a UI change
|
|
60
|
-
| `media` | [references/media.md](references/media.md) | Screenshots and videos for docs and websites: viewports, dark mode, locales, `record
|
|
66
|
+
| `verify` | [references/verify.md](references/verify.md) | Checking a UI change: `affected`, `shoot`, before/after `--compare`, failure pages, `publish` |
|
|
67
|
+
| `media` | [references/media.md](references/media.md) | Screenshots and videos for docs and websites: viewports, dark mode, locales, `record`, Remotion |
|
|
61
68
|
| `mcp` | [references/mcp.md](references/mcp.md) | Driving the live app from a coding agent: open, observe, act, check ready, save |
|
|
62
69
|
| `debugging` | [references/debugging.md](references/debugging.md) | Something fails: `doctor`, error codes and exit codes, common causes |
|
|
63
70
|
|
|
@@ -69,9 +76,10 @@ Read the topic for the job before you start. The files sit next to this one, and
|
|
|
69
76
|
the wiring one step at a time and prints the fix for each problem.
|
|
70
77
|
3. New state: capture it from the running app (topic `capture`) instead of writing fixtures by
|
|
71
78
|
hand. Set a `ready` selector and check it matches.
|
|
72
|
-
4. Changed UI: `npx state-scenarios
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
4. Changed UI: `npx state-scenarios affected <dir>` names the states your branch touches; shoot
|
|
80
|
+
them with `--only`, and with `--compare` against a run from before the change (topic `verify`).
|
|
81
|
+
Look at the screenshots. Each failed scenario gets `<out>/<name>.md` with the fix; read that
|
|
82
|
+
page, not the JSON.
|
|
75
83
|
5. `npx state-scenarios validate <dir>` before committing.
|
|
76
84
|
|
|
77
85
|
## Rules
|
|
@@ -40,8 +40,10 @@ export default {
|
|
|
40
40
|
npx state-scenarios record src/scenarios --base-url http://localhost:5173 --steps steps.ts --format mp4
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
- The panel is hidden and a cursor is drawn (Playwright videos don't show the mouse).
|
|
44
|
-
|
|
43
|
+
- The panel is hidden and a bright arrow cursor is drawn (Playwright videos don't show the mouse).
|
|
44
|
+
It pulses, glides to each target before `locator.click()` lands, and sends out a ripple on the
|
|
45
|
+
click. `--cursor-color <css>` (default `#ff2d55`) recolours it; `--cursor <file.svg>` draws your
|
|
46
|
+
own, with its top-left corner at the mouse.
|
|
45
47
|
- The video starts once the scenario is ready and holds the last frame for `--hold` ms (default
|
|
46
48
|
800), so there's no need for a `waitForTimeout` at the end of steps. Uncropped
|
|
47
49
|
`--format webm` is the exception: it keeps the page load, and needs no ffmpeg.
|
|
@@ -54,7 +56,39 @@ npx state-scenarios record src/scenarios --base-url http://localhost:5173 --step
|
|
|
54
56
|
- Video records at viewport size, so use a larger `--viewport` for sharper video; `--scale`
|
|
55
57
|
affects screenshots only.
|
|
56
58
|
- MP4, GIF, `--element`, `--fps` and `--width` need ffmpeg on `PATH`, or `FFMPEG_PATH`. WebM without them doesn't.
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
+
- Each video gets a `<name>.video.json` manifest: size, length, description and clicks. See
|
|
60
|
+
[Videos in Remotion](#videos-in-remotion).
|
|
61
|
+
- In your own Playwright scripts, `showCursor(page, { color, svg })` from `state-scenarios/playwright`
|
|
62
|
+
draws the cursor; call it before `openScenario(page, name, { panel: false })`. Hover before a
|
|
63
|
+
click there, so the cursor arrives first.
|
|
64
|
+
|
|
65
|
+
## Videos in Remotion
|
|
66
|
+
|
|
67
|
+
`record` writes `<name>.video.json` next to each video, for editors such as
|
|
68
|
+
[Remotion](https://www.remotion.dev):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"name": "default",
|
|
73
|
+
"description": "All 248 countries from the API",
|
|
74
|
+
"video": "default.mp4",
|
|
75
|
+
"width": 1280,
|
|
76
|
+
"height": 720,
|
|
77
|
+
"durationMs": 4185,
|
|
78
|
+
"clicks": [{ "t": 731, "x": 93, "y": 56 }]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`durationMs` and click times count from the first frame of the saved video. Click `x` and `y` are
|
|
83
|
+
pixels in it, after `--element` and `--width`. Record into the Remotion project's `public/` folder,
|
|
84
|
+
then:
|
|
85
|
+
|
|
86
|
+
- Read the manifests in the composition's `calculateMetadata` to set `durationInFrames`.
|
|
87
|
+
- Play each video with `<Video src={staticFile(...)} />` from `@remotion/media`.
|
|
88
|
+
- Zoom to each click by scaling the video around its `x` and `y` shortly before `t`.
|
|
89
|
+
- Use `description` as a caption, or replace it per scenario.
|
|
90
|
+
|
|
91
|
+
Pace steps with a short `page.waitForTimeout` before each click, so the zoom has time to arrive.
|
|
92
|
+
`examples/remotion-promo` in the state-scenarios repo does all of this.
|
|
59
93
|
|
|
60
94
|
Use demo data in scenarios meant for public images: fictional names, no real customer records.
|
|
@@ -10,6 +10,47 @@ For each scenario `shoot` opens the link in strict mode, waits for `ready`, save
|
|
|
10
10
|
checks for unmocked requests, uncaught exceptions and `console.error` calls. `--only a,b` limits
|
|
11
11
|
the run; `--json` prints the report for scripts.
|
|
12
12
|
|
|
13
|
+
## Which states a change touches
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx state-scenarios affected src/scenarios --base main --src src
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It lists each scenario the branch can affect (committed, uncommitted and new files) and why:
|
|
20
|
+
|
|
21
|
+
| Reason | Means |
|
|
22
|
+
| ---------- | ------------------------------------------------------------------ |
|
|
23
|
+
| `scenario` | The scenario's file changed |
|
|
24
|
+
| `extends` | It extends a scenario that changed |
|
|
25
|
+
| `map` | A scenario map row's Source column covers a changed file |
|
|
26
|
+
| `endpoint` | A changed file mentions an endpoint the scenario mocks |
|
|
27
|
+
| `route` | A changed file sits under a folder named like the scenario's route |
|
|
28
|
+
|
|
29
|
+
The last line is `--only a,b,c`, ready for `shoot`. Changed files nothing pointed at are listed
|
|
30
|
+
too: a shared file (layout, theme, global CSS) can change every state, so shoot them all then.
|
|
31
|
+
`--src` limits it to the app's folders in a monorepo; `--json` is for scripts. A scenario map with
|
|
32
|
+
Source columns (`/create-scenario-map`) makes the result much sharper than the endpoint and route
|
|
33
|
+
guesses.
|
|
34
|
+
|
|
35
|
+
## Before and after
|
|
36
|
+
|
|
37
|
+
Shoot the base branch first, then the change, and compare:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git stash # or check out the base branch in a worktree on another port
|
|
41
|
+
npx state-scenarios shoot src/scenarios --base-url http://localhost:5173 --only <affected> --out .shots/before
|
|
42
|
+
git stash pop
|
|
43
|
+
npx state-scenarios shoot src/scenarios --base-url http://localhost:5173 --only <affected> --out .shots/after --compare .shots/before
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each state that changed gets `.shots/after/<name>.diff.png`: before, after and the changed pixels
|
|
47
|
+
in red, side by side. States are reported `changed`, `same` or `new` (no earlier shot), and ones
|
|
48
|
+
that are gone as `removed`. A diff doesn't fail the run: in a UI change, diffs are the point. Look
|
|
49
|
+
at every `changed` state and ask whether the change meant to touch it.
|
|
50
|
+
|
|
51
|
+
Shoot both runs with the same viewport and options, and with the dev server warm, or fonts and
|
|
52
|
+
images still loading show as changes.
|
|
53
|
+
|
|
13
54
|
## When a scenario fails
|
|
14
55
|
|
|
15
56
|
The terminal names a page for it: `❯ details .scenario-shots/<name>.md`. Read that page. It has:
|
|
@@ -26,9 +67,20 @@ wrong, or that the UI change broke the state. Fix the app when the screenshot sh
|
|
|
26
67
|
|
|
27
68
|
## Evidence
|
|
28
69
|
|
|
29
|
-
Look at the PNGs for the states the change touches, plus loading, empty and error.
|
|
30
|
-
|
|
31
|
-
|
|
70
|
+
Look at the PNGs for the states the change touches, plus loading, empty and error. Pages for
|
|
71
|
+
passing scenarios are deleted on each run, so the `.md` files in the output folder are always the
|
|
72
|
+
current failures.
|
|
73
|
+
|
|
74
|
+
To show them in a PR, publish them; `gh` can't upload images:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx state-scenarios publish .shots/after/*.diff.png --folder pr-42
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It commits the files to a `scenario-shots` branch with git plumbing (the checkout, index and
|
|
81
|
+
current branch stay as they are), pushes it, and prints a Markdown image line per file, linked to
|
|
82
|
+
that commit so the links don't change. Paste those lines into the PR body. `--branch` and
|
|
83
|
+
`--remote` change where they go. The `scenario-pr` skill writes the whole PR description this way.
|
|
32
84
|
|
|
33
85
|
## In Playwright tests
|
|
34
86
|
|
package/dist/record-HXIYGMWA.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CliError
|
|
3
|
-
} from "./chunk-GCYOLAFU.js";
|
|
4
|
-
|
|
5
|
-
// src/record.ts
|
|
6
|
-
import { spawnSync } from "child_process";
|
|
7
|
-
import { copyFileSync } from "fs";
|
|
8
|
-
function visibleCrop(box, viewport) {
|
|
9
|
-
const x = Math.max(0, box.x);
|
|
10
|
-
const y = Math.max(0, box.y);
|
|
11
|
-
const width = Math.min(viewport.width, box.x + box.width) - x;
|
|
12
|
-
const height = Math.min(viewport.height, box.y + box.height) - y;
|
|
13
|
-
if (width < 2 || height < 2) return void 0;
|
|
14
|
-
return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };
|
|
15
|
-
}
|
|
16
|
-
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
17
|
-
function ffmpegArgs({ input, output, format, start, crop, fps, width }) {
|
|
18
|
-
const rate = fps ?? (format === "gif" ? 12 : void 0);
|
|
19
|
-
const filters = [
|
|
20
|
-
crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,
|
|
21
|
-
rate && `fps=${rate}`,
|
|
22
|
-
// Quoted so the comma in min() isn't read as a filter separator.
|
|
23
|
-
width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`
|
|
24
|
-
].filter(Boolean);
|
|
25
|
-
const codec = {
|
|
26
|
-
mp4: ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", "-movflags", "+faststart"],
|
|
27
|
-
webm: ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32"],
|
|
28
|
-
gif: []
|
|
29
|
-
}[format];
|
|
30
|
-
const vf = format === "gif" ? `${filters.join(",")},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer` : filters.join(",");
|
|
31
|
-
return [
|
|
32
|
-
"-y",
|
|
33
|
-
"-loglevel",
|
|
34
|
-
"error",
|
|
35
|
-
"-i",
|
|
36
|
-
input,
|
|
37
|
-
"-ss",
|
|
38
|
-
start.toFixed(2),
|
|
39
|
-
...vf ? ["-vf", vf] : [],
|
|
40
|
-
"-an",
|
|
41
|
-
...codec,
|
|
42
|
-
output
|
|
43
|
-
];
|
|
44
|
-
}
|
|
45
|
-
function encodeVideo(options) {
|
|
46
|
-
if (options.format === "webm" && !options.crop && !options.fps && !options.width) {
|
|
47
|
-
copyFileSync(options.input, options.output);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
|
|
51
|
-
const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: "utf8" });
|
|
52
|
-
if (result.error) {
|
|
53
|
-
throw new CliError(
|
|
54
|
-
"FFMPEG_MISSING",
|
|
55
|
-
`record needs ffmpeg for ${options.format}${options.format === "webm" ? " with --element, --fps or --width" : ""}: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);
|
|
59
|
-
}
|
|
60
|
-
export {
|
|
61
|
-
encodeVideo,
|
|
62
|
-
ffmpegArgs,
|
|
63
|
-
visibleCrop
|
|
64
|
-
};
|
|
65
|
-
//# sourceMappingURL=record-HXIYGMWA.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/record.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { copyFileSync } from 'node:fs';\nimport { CliError } from './errors.js';\n\nexport type VideoFormat = 'mp4' | 'gif' | 'webm';\n\nexport interface Crop {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * The part of an element's box the video shows. Video holds only the viewport, so a crop must\n * stay inside it; `clipped` says the element was larger than the visible area.\n */\nexport function visibleCrop(box: Crop, viewport: { width: number; height: number; }) {\n const x = Math.max(0, box.x);\n const y = Math.max(0, box.y);\n const width = Math.min(viewport.width, box.x + box.width) - x;\n const height = Math.min(viewport.height, box.y + box.height) - y;\n\n if (width < 2 || height < 2) return undefined;\n\n return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };\n}\n\nconst even = (n: number) => Math.max(2, Math.floor(n / 2) * 2);\n\n/**\n * ffmpeg arguments that trim the page load off the start, crop to an element, and encode.\n * MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a\n * generated palette, which keeps it sharp at a fraction of the naive size.\n */\nexport function ffmpegArgs(\n { input, output, format, start, crop, fps, width }: {\n input: string;\n output: string;\n format: VideoFormat;\n /** Seconds to skip, e.g. the page loading before the steps begin. */\n start: number;\n crop?: Crop;\n /** Frames per second; GIFs default to 12. */\n fps?: number;\n /** Maximum output width in pixels, never upscaled; height follows the aspect ratio. */\n width?: number;\n },\n): string[] {\n const rate = fps ?? (format === 'gif' ? 12 : undefined);\n\n // yuv420p needs even sizes.\n const filters = [\n crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,\n rate && `fps=${rate}`,\n // Quoted so the comma in min() isn't read as a filter separator.\n width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`,\n ].filter(Boolean);\n\n const codec = {\n mp4: ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '23', '-movflags', '+faststart'],\n webm: ['-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '32'],\n gif: [],\n }[format];\n\n const vf = format === 'gif'\n ? `${filters.join(',')},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer`\n : filters.join(',');\n\n return [\n '-y',\n '-loglevel',\n 'error',\n '-i',\n input,\n '-ss',\n start.toFixed(2),\n ...(vf ? ['-vf', vf] : []),\n '-an',\n ...codec,\n output,\n ];\n}\n\n/**\n * Convert Playwright's WebM into the requested format. WebM without a crop, --fps or --width is\n * copied as recorded (page load included), so it works without ffmpeg.\n */\nexport function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]) {\n if (options.format === 'webm' && !options.crop && !options.fps && !options.width) {\n copyFileSync(options.input, options.output);\n\n return;\n }\n\n // FFMPEG_PATH is the usual override when ffmpeg isn't on PATH (common on Windows).\n const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg';\n const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: 'utf8' });\n\n if (result.error) {\n throw new CliError(\n 'FFMPEG_MISSING',\n `record needs ffmpeg for ${options.format}${\n options.format === 'webm' ? ' with --element, --fps or --width' : ''\n }: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`,\n );\n }\n\n if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);\n}\n"],"mappings":";;;;;AAAA,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAgBtB,SAAS,YAAY,KAAW,UAA8C;AACnF,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,QAAQ,KAAK,IAAI,SAAS,OAAO,IAAI,IAAI,IAAI,KAAK,IAAI;AAC5D,QAAM,SAAS,KAAK,IAAI,SAAS,QAAQ,IAAI,IAAI,IAAI,MAAM,IAAI;AAE/D,MAAI,QAAQ,KAAK,SAAS,EAAG,QAAO;AAEpC,SAAO,EAAE,MAAM,EAAE,GAAG,GAAG,OAAO,OAAO,GAAG,SAAS,QAAQ,IAAI,SAAS,SAAS,IAAI,OAAO;AAC5F;AAEA,IAAM,OAAO,CAAC,MAAc,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC;AAOtD,SAAS,WACd,EAAE,OAAO,QAAQ,QAAQ,OAAO,MAAM,KAAK,MAAM,GAYvC;AACV,QAAM,OAAO,QAAQ,WAAW,QAAQ,KAAK;AAG7C,QAAM,UAAU;AAAA,IACd,QAAQ,QAAQ,KAAK,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,MAAM,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC;AAAA,IACjG,QAAQ,OAAO,IAAI;AAAA;AAAA,IAEnB,SAAS,oBAAoB,KAAK;AAAA,EACpC,EAAE,OAAO,OAAO;AAEhB,QAAM,QAAQ;AAAA,IACZ,KAAK,CAAC,QAAQ,WAAW,YAAY,WAAW,QAAQ,MAAM,aAAa,YAAY;AAAA,IACvF,MAAM,CAAC,QAAQ,cAAc,QAAQ,KAAK,QAAQ,IAAI;AAAA,IACtD,KAAK,CAAC;AAAA,EACR,EAAE,MAAM;AAER,QAAM,KAAK,WAAW,QAClB,GAAG,QAAQ,KAAK,GAAG,CAAC,gFACpB,QAAQ,KAAK,GAAG;AAEpB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,MAAM,QAAQ,CAAC;AAAA,IACf,GAAI,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC;AAAA,IACxB;AAAA,IACA,GAAG;AAAA,IACH;AAAA,EACF;AACF;AAMO,SAAS,YAAY,SAA2C;AACrE,MAAI,QAAQ,WAAW,UAAU,CAAC,QAAQ,QAAQ,CAAC,QAAQ,OAAO,CAAC,QAAQ,OAAO;AAChF,iBAAa,QAAQ,OAAO,QAAQ,MAAM;AAE1C;AAAA,EACF;AAGA,QAAM,SAAS,QAAQ,IAAI,eAAe;AAC1C,QAAM,SAAS,UAAU,QAAQ,WAAW,OAAO,GAAG,EAAE,UAAU,OAAO,CAAC;AAE1E,MAAI,OAAO,OAAO;AAChB,UAAM,IAAI;AAAA,MACR;AAAA,MACA,2BAA2B,QAAQ,MAAM,GACvC,QAAQ,WAAW,SAAS,sCAAsC,EACpE;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,WAAW,EAAG,OAAM,IAAI,MAAM,kBAAkB,OAAO,OAAO,KAAK,CAAC,EAAE;AACnF;","names":[]}
|