mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
158
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f89b1345 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
fd38b303f0 | ||
|
|
266233a473 | ||
|
|
387b242aea | ||
|
|
2bc69c2790 | ||
|
|
9da44217f7 | ||
|
|
5f77b06044 | ||
|
|
5c973c4ac5 | ||
|
|
a13bbc3f65 | ||
|
|
10c4ae8d84 | ||
|
|
ac4a05d5ed | ||
|
|
c7ee8a4851 | ||
|
|
9957fa43f2 | ||
|
|
502452c9e4 | ||
|
|
e6bb368bc8 | ||
|
|
aedba2a480 | ||
|
|
78900a24fb | ||
|
|
03443fe898 | ||
|
|
b60c788362 | ||
|
|
61ec3692b7 | ||
|
|
1482305810 | ||
|
|
8b37569818 | ||
|
|
2178750d07 | ||
|
|
028e21e54c | ||
|
|
b24a402b4c | ||
|
|
ce0b188776 | ||
|
|
f35114dc77 | ||
|
|
aacb4f8731 | ||
|
|
1be54175e4 | ||
|
|
a7470310ae | ||
|
|
3acadeff7c | ||
|
|
91544629fa | ||
|
|
32f5214751 | ||
|
|
96882642c7 | ||
|
|
a455325147 | ||
|
|
2dad7aa352 | ||
|
|
be20391788 | ||
|
|
006761d3b0 | ||
|
|
7a0de45482 | ||
|
|
72e031eb84 | ||
|
|
2ab976ec42 | ||
|
|
c0650ed5b7 | ||
|
|
94c76535a4 | ||
|
|
b43804d8fd | ||
|
|
79862dac13 | ||
|
|
bf2ef59688 | ||
|
|
d869592a8b | ||
|
|
d43ae1ef05 | ||
|
|
024dc4d288 | ||
|
|
04dee69888 | ||
|
|
650c81856f | ||
|
|
7545289dd3 | ||
|
|
6df9442cd0 | ||
|
|
01bb8225ae | ||
|
|
5a69bbc1a5 | ||
|
|
3afb091e90 | ||
|
|
de0d667f4f | ||
|
|
458f18be03 | ||
|
|
785e73d6c0 | ||
|
|
4c18afaeae | ||
|
|
520d0a8444 | ||
|
|
964fc25fc7 | ||
|
|
c8aaa8967d | ||
|
|
8a44fef764 | ||
|
|
edb2cedda0 | ||
|
|
73d7c1a200 | ||
|
|
307af2c1e9 | ||
|
|
057c682c6c | ||
|
|
e29183f104 | ||
|
|
f07f80b529 | ||
|
|
25db338861 | ||
|
|
e380c0a31c | ||
|
|
14b0199625 | ||
|
|
620369a7ce | ||
|
|
ce419fee8b | ||
|
|
368399f463 | ||
|
|
1370576dd5 | ||
|
|
6957502985 | ||
|
|
5a9defb1ee | ||
|
|
70449659cf | ||
|
|
f302cb6c63 | ||
|
|
a01079a7c3 | ||
|
|
8d4d9d8750 | ||
|
|
209dc652d2 | ||
|
|
06dfc5b245 | ||
|
|
45499edc9a | ||
|
|
0711080112 | ||
|
|
a531f43d91 | ||
|
|
1e4974e2a0 | ||
|
|
7952d98d18 | ||
|
|
d4e05bc7ff | ||
|
|
a6b3b6a251 | ||
|
|
9402369246 | ||
|
|
1daf386765 | ||
|
|
8612b16b93 | ||
|
|
6c2e471d47 | ||
|
|
69ac0940d3 | ||
|
|
bc38e41572 | ||
|
|
6f1be7a4e1 | ||
|
|
f88ee513d4 | ||
|
|
5ae776d921 | ||
|
|
dcf5588d55 | ||
|
|
df9f711a3e | ||
|
|
9ce864afa5 | ||
|
|
22a2c8b5c6 | ||
|
|
b090400604 | ||
|
|
4b6df665f3 | ||
|
|
c309fbaa98 | ||
|
|
7e2e723880 | ||
|
|
d2db86ec7f | ||
|
|
2ad85f6009 | ||
|
|
19a6bb7cda | ||
|
|
e28dcc1592 | ||
|
|
3e70669a27 | ||
|
|
b070a6f6a2 | ||
|
|
349f460e15 | ||
|
|
ce68d57a49 | ||
|
|
b8c271cce1 | ||
|
|
a0049b7f8a | ||
|
|
494ed17fe2 | ||
|
|
12f312421b | ||
|
|
a558f5e67e | ||
|
|
0be833bba2 | ||
|
|
1911f8f058 | ||
|
|
23e24aa4c2 | ||
|
|
e671ae9d1c | ||
|
|
a7a13c9614 | ||
|
|
df955812fe | ||
|
|
efe93a3db4 | ||
|
|
f0c2cba60e | ||
|
|
0dedf44e70 | ||
|
|
1698a7c148 | ||
|
|
3009e80dd4 | ||
|
|
2e05105bb0 | ||
|
|
98621ca3fa | ||
|
|
4d32226541 | ||
|
|
23a449fcca | ||
|
|
b2925daf4f | ||
|
|
3c9a021f11 | ||
|
|
4e40b92b16 | ||
|
|
b0f1700e40 | ||
|
|
595d6bd394 | ||
|
|
fc515a19b0 | ||
|
|
b933c3c421 | ||
|
|
aff0bc0888 | ||
|
|
b60b72a289 |
No files matched your search
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: YYYY-MM-DD-short-slug
|
||||
date: YYYY-MM-DD
|
||||
status: inbox
|
||||
scope: path or workflow
|
||||
evidence: command, test, or artifact
|
||||
review_after: YYYY-MM-DD
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Observable failure or friction.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Exact bounded reproduction.
|
||||
|
||||
## Cause
|
||||
|
||||
Confirmed cause, not a hypothesis.
|
||||
|
||||
## Remediation
|
||||
|
||||
Proven correction.
|
||||
|
||||
## Evidence
|
||||
|
||||
Read-back, test, or other verification.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: 2026-08-27-migration-context-conflict
|
||||
date: 2026-08-27
|
||||
status: promoted
|
||||
scope: database migration instructions
|
||||
evidence: root and scoped agent context comparison
|
||||
destination: AGENTS.md and scripts/lint-harness.mjs
|
||||
review_after: 2027-02-27
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Root instructions prohibited remote migration application while scoped Supabase
|
||||
instructions authorized it.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Compare the former root `AGENTS.md` migration rule with the former
|
||||
`apps/supabase/agents.md` migration section.
|
||||
|
||||
## Cause
|
||||
|
||||
Runtime-specific instruction files evolved independently without an executable
|
||||
consistency check.
|
||||
|
||||
## Remediation
|
||||
|
||||
Make root context authoritative, scope only additional local rules, and reject
|
||||
positive `apply_migration` instructions in the harness lint.
|
||||
|
||||
## Evidence
|
||||
|
||||
`npm run lint:harness` checks instruction consistency and the authoritative
|
||||
migration location.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: 2026-08-28-maestro-stable-login-readiness
|
||||
date: 2026-08-28
|
||||
status: promoted
|
||||
scope: native Auth-shell acceptance flows
|
||||
evidence: iOS simulator login-shell acceptance on current main
|
||||
destination: e2e/maestro
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
The credential-free native smoke failed even though the current app reached the
|
||||
login screen and displayed its required controls.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
|
||||
for the retired tagline `Explore intimacy together`; the app displayed updated
|
||||
marketing copy while `Sauci` and `Password` were present.
|
||||
|
||||
## Cause
|
||||
|
||||
Login readiness was coupled to mutable marketing copy rather than a stable
|
||||
required control.
|
||||
|
||||
## Remediation
|
||||
|
||||
Wait for the password control that defines login readiness, while retaining the
|
||||
brand and password assertions. Apply the same readiness condition to the
|
||||
authenticated and credential-free flows.
|
||||
|
||||
## Evidence
|
||||
|
||||
Simulator read-back confirmed the current login shell exposes `Sauci` and
|
||||
`Password`. The maintained Maestro flows now wait for that stable control rather
|
||||
than the tagline.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: 2026-08-28-mobile-release-source-integrity
|
||||
date: 2026-08-28
|
||||
status: promoted
|
||||
scope: mobile release build and upload workflow
|
||||
evidence: clean-source local EAS builds for iOS 43 and Android 46
|
||||
destination: scripts/lint-harness.mjs
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
A build-only request initially regenerated tracked native projects. The generated
|
||||
Android widget provider replaced the checked-in implementation, and the Android
|
||||
release script would also have uploaded after building.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run the former mobile release scripts or run `expo prebuild` before a local EAS
|
||||
build, then inspect `git diff`. The reproduced diff included the Android widget
|
||||
provider, widget XML, and Xcode project rather than version metadata alone.
|
||||
|
||||
## Cause
|
||||
|
||||
The release scripts combined three independently reviewed operations: native
|
||||
regeneration, artifact creation, and store upload. Sauci already tracks native
|
||||
projects, and EAS skips prebuild when those directories exist.
|
||||
|
||||
## Remediation
|
||||
|
||||
Keep native regeneration explicit, make release scripts artifact-only, expose
|
||||
upload separately, and lint the scripts so those responsibilities cannot be
|
||||
silently recombined.
|
||||
|
||||
## Evidence
|
||||
|
||||
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
|
||||
built successfully from the clean tracked sources; the post-build worktree
|
||||
contained only the four expected build-number files. `npm run lint:harness`
|
||||
enforces the release-script boundary.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Build
|
||||
|
||||
You are an autonomous coding agent. Your task is to complete the work for exactly one story and record the outcome.
|
||||
|
||||
## Paths
|
||||
- PRD: {{PRD_PATH}}
|
||||
- AGENTS (optional): {{AGENTS_PATH}}
|
||||
- Progress Log: {{PROGRESS_PATH}}
|
||||
- Guardrails: {{GUARDRAILS_PATH}}
|
||||
- Guardrails Reference: {{GUARDRAILS_REF}}
|
||||
- Context Reference: {{CONTEXT_REF}}
|
||||
- Errors Log: {{ERRORS_LOG_PATH}}
|
||||
- Activity Log: {{ACTIVITY_LOG_PATH}}
|
||||
- Activity Logger: {{ACTIVITY_CMD}}
|
||||
- No-commit: {{NO_COMMIT}}
|
||||
- Repo Root: {{REPO_ROOT}}
|
||||
- Run ID: {{RUN_ID}}
|
||||
- Iteration: {{ITERATION}}
|
||||
- Run Log: {{RUN_LOG_PATH}}
|
||||
- Run Summary: {{RUN_META_PATH}}
|
||||
|
||||
## Global Quality Gates (apply to every story)
|
||||
{{QUALITY_GATES}}
|
||||
|
||||
## Selected Story (Do not change scope)
|
||||
ID: {{STORY_ID}}
|
||||
Title: {{STORY_TITLE}}
|
||||
|
||||
Story details:
|
||||
{{STORY_BLOCK}}
|
||||
|
||||
If the story details are empty or missing, STOP and report that the PRD story format could not be parsed.
|
||||
|
||||
## Rules (Non-Negotiable)
|
||||
- Implement **only** the work required to complete the selected story.
|
||||
- Complete all tasks associated with this story (and only this story).
|
||||
- Do NOT ask the user questions.
|
||||
- Do NOT change unrelated code.
|
||||
- Do NOT assume something is unimplemented — confirm by reading code.
|
||||
- Implement completely; no placeholders or stubs.
|
||||
- If No-commit is true, do NOT commit or push changes.
|
||||
- Do NOT edit the PRD JSON (status is handled by the loop).
|
||||
- Commit only task-owned source, tests, and maintained documentation. Runtime logs
|
||||
and evidence remain ignored.
|
||||
- Before committing, perform a final **security**, **performance**, and **regression** review of your changes.
|
||||
|
||||
## Your Task (Do this in order)
|
||||
1. Read {{GUARDRAILS_PATH}} before any code changes.
|
||||
2. Read {{ERRORS_LOG_PATH}} for repeated failures to avoid.
|
||||
3. Read {{PRD_PATH}} for global context (do not edit).
|
||||
4. Fully audit and read all necessary files to understand the task end-to-end before implementing. Do not assume missing functionality.
|
||||
5. If {{AGENTS_PATH}} exists, follow its build/test instructions.
|
||||
6. Implement only the tasks that belong to {{STORY_ID}}.
|
||||
7. Run verification commands listed in the story, the global quality gates, and in {{AGENTS_PATH}} (if required).
|
||||
8. If the project has a build or dev workflow, run what applies:
|
||||
- Build step (e.g., `npm run build`) if defined.
|
||||
- Dev server (e.g., `npm run dev`, `wrangler dev`) if it is the normal validation path.
|
||||
- Confirm no runtime/build errors in the console.
|
||||
9. Perform a brief audit before committing:
|
||||
- **Security:** check for obvious vulnerabilities or unsafe handling introduced by your changes.
|
||||
- **Performance:** check for avoidable regressions (extra queries, heavy loops, unnecessary re-renders).
|
||||
- **Regression:** verify existing behavior that could be impacted still works.
|
||||
10. If No-commit is false, commit changes using the `$commit` skill.
|
||||
- Stage only the files owned by this story; never blanket-stage unrelated work.
|
||||
- Confirm a clean working tree after commit: `git status --porcelain` should be empty.
|
||||
- After committing, capture the commit hash and subject using:
|
||||
`git show -s --format="%h %s" HEAD`.
|
||||
11. Append a progress entry to {{PROGRESS_PATH}} with run/commit/test details (format below).
|
||||
If No-commit is true, skip committing and note it in the progress entry.
|
||||
|
||||
## Progress Entry Format (Append Only)
|
||||
```
|
||||
## [Date/Time] - {{STORY_ID}}: {{STORY_TITLE}}
|
||||
Thread: [codex exec session id if available, otherwise leave blank]
|
||||
Run: {{RUN_ID}} (iteration {{ITERATION}})
|
||||
Run log: {{RUN_LOG_PATH}}
|
||||
Run summary: {{RUN_META_PATH}}
|
||||
- Guardrails reviewed: yes
|
||||
- No-commit run: {{NO_COMMIT}}
|
||||
- Commit: <hash> <subject> (or `none` + reason)
|
||||
- Post-commit status: `clean` or list remaining files
|
||||
- Verification:
|
||||
- Command: <exact command> -> PASS/FAIL
|
||||
- Command: <exact command> -> PASS/FAIL
|
||||
- Files changed:
|
||||
- <file path>
|
||||
- <file path>
|
||||
- What was implemented
|
||||
- **Learnings for future iterations:**
|
||||
- Patterns discovered
|
||||
- Gotchas encountered
|
||||
- Useful context
|
||||
---
|
||||
```
|
||||
|
||||
## Completion Signal
|
||||
Only output the completion signal when the **selected story** is fully complete and verified.
|
||||
When the selected story is complete, output:
|
||||
<promise>COMPLETE</promise>
|
||||
|
||||
Otherwise, end normally without the signal.
|
||||
|
||||
## Additional Guardrails
|
||||
- When authoring documentation, capture the why (tests + implementation intent).
|
||||
- If you learn how to run/build/test the project, update {{AGENTS_PATH}} briefly (operational only).
|
||||
- Keep AGENTS operational only; progress notes belong in {{PROGRESS_PATH}}.
|
||||
- If you reproduce a reusable failure, record it using
|
||||
`.agents/learnings/TEMPLATE.md`. Promote it only after review into a maintained
|
||||
doc, skill, fixture, or executable invariant; do not add speculative Signs.
|
||||
|
||||
## Activity Logging (Required)
|
||||
Log major actions to {{ACTIVITY_LOG_PATH}} using the helper:
|
||||
```
|
||||
{{ACTIVITY_CMD}} "message"
|
||||
```
|
||||
Log at least:
|
||||
- Start of work on the story
|
||||
- After major code changes
|
||||
- After tests/verification
|
||||
- After updating progress log
|
||||
|
||||
## Browser Testing (Required for Frontend Stories)
|
||||
If the selected story changes UI, you MUST verify it in the browser:
|
||||
1. Load the `dev-browser` skill.
|
||||
2. Navigate to the relevant page.
|
||||
3. Verify the UI changes work as expected.
|
||||
4. Take a screenshot if helpful for the progress log.
|
||||
|
||||
A frontend story is NOT complete until browser verification passes.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Ralph
|
||||
|
||||

|
||||
|
||||
Ralph is a minimal, file‑based agent loop for autonomous coding. Each iteration starts fresh, reads the same on‑disk state, and commits work for one story at a time.
|
||||
|
||||
## How it works
|
||||
|
||||
Ralph treats **files and git** as memory, not the model context:
|
||||
|
||||
- **PRD (JSON)** defines stories, gates, and status
|
||||
- **Loop** executes one story per iteration
|
||||
- **State** persists in `.ralph/`
|
||||
|
||||

|
||||
|
||||
## Global CLI (recommended)
|
||||
|
||||
Install and run Ralph from anywhere:
|
||||
|
||||
```bash
|
||||
npm i -g @iannuttall/ralph
|
||||
ralph prd # launches an interactive prompt
|
||||
ralph build 1 # one Ralph run
|
||||
```
|
||||
|
||||
### Template hierarchy
|
||||
|
||||
Ralph will look for templates in this order:
|
||||
|
||||
1. `.agents/ralph/` in the current project (if present)
|
||||
2. Bundled defaults shipped with this repo
|
||||
|
||||
State and logs always go to `.ralph/` in the project.
|
||||
|
||||
### Install templates into a project (optional overrides)
|
||||
|
||||
```bash
|
||||
ralph install
|
||||
```
|
||||
|
||||
This creates `.agents/ralph/` in the current repo so you can customize prompts and loop behavior. During install, you’ll be asked if you want to add the required skills.
|
||||
|
||||
### Install required skills (optional)
|
||||
|
||||
```bash
|
||||
ralph install --skills
|
||||
```
|
||||
|
||||
You’ll be prompted for agent (codex/claude/droid/opencode) and local vs global install. Skills installed: **commit**, **dev-browser**, **prd**.
|
||||
If you skipped skills during `ralph install`, you can run `ralph install --skills` anytime.
|
||||
|
||||
## Quick start (project)
|
||||
|
||||
1) Create your PRD (JSON) or generate one:
|
||||
```
|
||||
ralph prd
|
||||
```
|
||||
Requires the **prd** skill (install via `ralph install --skills`).
|
||||
|
||||
Example prompt text:
|
||||
```
|
||||
A lightweight uptime monitor (Hono app), deployed on Cloudflare, with email alerts via AWS SES
|
||||
```
|
||||
|
||||
Default output (agent chooses a short filename in `.agents/tasks/`):
|
||||
```
|
||||
.agents/tasks/prd-<short>.json
|
||||
```
|
||||
|
||||
2) Run one build iteration:
|
||||
```
|
||||
ralph build 1 # one Ralph run
|
||||
```
|
||||
|
||||
No‑commit dry run:
|
||||
```
|
||||
ralph build 1 --no-commit # one Ralph run
|
||||
```
|
||||
|
||||
Override PRD output for `ralph prd`:
|
||||
```
|
||||
ralph prd --out .agents/tasks/prd-api.json
|
||||
```
|
||||
Optional human overview (generated from JSON):
|
||||
```
|
||||
ralph overview
|
||||
```
|
||||
This writes a tiny overview alongside the PRD: `prd-<slug>.overview.md`.
|
||||
|
||||
PRD story status fields are updated automatically by the loop:
|
||||
- `open` → selectable
|
||||
- `in_progress` → locked by a running loop (with `startedAt`)
|
||||
- `done` → completed (with `completedAt`)
|
||||
|
||||
If a loop crashes and a story stays `in_progress`, you can set `STALE_SECONDS` in `.agents/ralph/config.sh` to allow Ralph to automatically reopen stalled stories.
|
||||
|
||||
## Override PRD paths
|
||||
|
||||
You can point Ralph at a different PRD JSON file via CLI flags:
|
||||
|
||||
```bash
|
||||
ralph build 1 --prd .agents/tasks/prd-api.json # one Ralph run
|
||||
```
|
||||
|
||||
Optional progress override:
|
||||
|
||||
```bash
|
||||
ralph build 1 --progress .ralph/progress-api.md # one Ralph run
|
||||
```
|
||||
|
||||
If multiple PRD JSON files exist in `.agents/tasks/` and you omit `--prd`, Ralph will prompt you to choose.
|
||||
|
||||
Optional config file (if you installed templates):
|
||||
|
||||
```
|
||||
.agents/ralph/config.sh
|
||||
```
|
||||
|
||||
## Choose the agent runner
|
||||
|
||||
Set `AGENT_CMD` in `.agents/ralph/config.sh` to switch agents:
|
||||
|
||||
```
|
||||
AGENT_CMD="codex exec --yolo -"
|
||||
AGENT_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
|
||||
AGENT_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
|
||||
AGENT_CMD="opencode run \"$(cat {prompt})\""
|
||||
```
|
||||
|
||||
Or override per run:
|
||||
|
||||
```
|
||||
ralph prd --agent=codex
|
||||
ralph build 1 --agent=codex # one Ralph run
|
||||
ralph build 1 --agent=claude # one Ralph run
|
||||
ralph build 1 --agent=droid # one Ralph run
|
||||
ralph build 1 --agent=opencode # one Ralph run
|
||||
```
|
||||
|
||||
If the CLI isn’t installed, Ralph prints install hints:
|
||||
|
||||
```
|
||||
codex -> npm i -g @openai/codex
|
||||
claude -> curl -fsSL https://claude.ai/install.sh | bash
|
||||
droid -> curl -fsSL https://app.factory.ai/cli | sh
|
||||
opencode -> curl -fsSL https://opencode.ai/install.sh | bash
|
||||
```
|
||||
|
||||
## State files (.ralph/)
|
||||
|
||||
- `progress.md` — append‑only progress log
|
||||
- `guardrails.md` — “Signs” (lessons learned)
|
||||
- `activity.log` — activity + timing log
|
||||
- `errors.log` — repeated failures and notes
|
||||
- `runs/` — raw run logs + summaries
|
||||
|
||||
## Notes
|
||||
|
||||
- `.agents/ralph` is portable and can be copied between repos.
|
||||
- `.ralph` is per‑project state.
|
||||
- Use `{prompt}` in `AGENT_CMD` when agent needs a file path instead of stdin.
|
||||
- Examples: see `examples/commands.md`.
|
||||
- **OpenCode server mode**: For faster performance with OpenCode, run `opencode serve` in a separate terminal and uncomment the `AGENT_OPENCODE_CMD` lines in `.agents/ralph/agents.sh` to use `--attach http://localhost:4096`. This avoids cold boot on every run.
|
||||
|
||||
## Tests
|
||||
|
||||
Dry-run smoke tests (no agent required):
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Fast agent health check (real agent call, minimal output):
|
||||
|
||||
```bash
|
||||
npm run test:ping
|
||||
```
|
||||
|
||||
Optional integration test (requires agents installed):
|
||||
|
||||
```bash
|
||||
RALPH_INTEGRATION=1 npm test
|
||||
```
|
||||
|
||||
Full real-agent loop test:
|
||||
|
||||
```bash
|
||||
npm run test:real
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Default agent command templates (used by loop.sh and CLI).
|
||||
|
||||
AGENT_CODEX_CMD="codex exec --yolo --skip-git-repo-check -"
|
||||
AGENT_CODEX_INTERACTIVE_CMD="codex --yolo {prompt}"
|
||||
AGENT_CLAUDE_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
|
||||
AGENT_CLAUDE_INTERACTIVE_CMD="claude --dangerously-skip-permissions {prompt}"
|
||||
AGENT_DROID_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
|
||||
AGENT_DROID_INTERACTIVE_CMD="droid --skip-permissions-unsafe {prompt}"
|
||||
AGENT_OPENCODE_CMD="opencode run \"\$(cat {prompt})\""
|
||||
AGENT_OPENCODE_INTERACTIVE_CMD="opencode --prompt {prompt}"
|
||||
# Uncomment to use server mode (faster, avoids cold boot):
|
||||
# AGENT_OPENCODE_CMD="opencode run --attach http://localhost:4096 \"\$(cat {prompt})\""
|
||||
# AGENT_OPENCODE_INTERACTIVE_CMD="opencode --prompt {prompt} --attach http://localhost:4096"
|
||||
DEFAULT_AGENT="codex"
|
||||
@@ -0,0 +1,25 @@
|
||||
# Optional Ralph config overrides.
|
||||
# All paths are relative to repo root unless absolute.
|
||||
# Uncomment and edit as needed.
|
||||
|
||||
# PRD_PATH=".agents/tasks/prd.json"
|
||||
# PROGRESS_PATH=".ralph/progress.md"
|
||||
# GUARDRAILS_PATH=".ralph/guardrails.md"
|
||||
# ERRORS_LOG_PATH=".ralph/errors.log"
|
||||
# ACTIVITY_LOG_PATH=".ralph/activity.log"
|
||||
# TMP_DIR=".ralph/.tmp"
|
||||
# RUNS_DIR=".ralph/runs"
|
||||
# GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
|
||||
# CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
|
||||
# ACTIVITY_CMD=".agents/ralph/log-activity.sh"
|
||||
# AGENT_CMD defaults are defined in agents.sh. Override here if needed.
|
||||
# AGENT_CMD="codex exec --yolo --skip-git-repo-check -"
|
||||
# PRD_AGENT_CMD defaults are defined in agents.sh (interactive).
|
||||
# PRD_AGENT_CMD="codex --yolo --skip-git-repo-check {prompt}"
|
||||
# AGENT_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
|
||||
# AGENT_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
|
||||
# AGENTS_PATH="AGENTS.md"
|
||||
# PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
|
||||
# NO_COMMIT=false
|
||||
# MAX_ITERATIONS=25
|
||||
# STALE_SECONDS=0
|
||||
@@ -0,0 +1,55 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 520" role="img" aria-label="Ralph architecture diagram">
|
||||
<style>
|
||||
.bg { fill: #0f1218; }
|
||||
.box { fill: #141a23; stroke: #e6e6e6; stroke-width: 1.5; }
|
||||
.line { stroke: #e6e6e6; stroke-width: 1.5; }
|
||||
.text { fill: #f3f3f3; font-family: Menlo, Consolas, Monaco, monospace; font-size: 16px; }
|
||||
.small { font-size: 13px; opacity: 0.9; }
|
||||
.muted { opacity: 0.7; }
|
||||
</style>
|
||||
|
||||
<rect class="bg" x="0" y="0" width="1000" height="520" rx="0" />
|
||||
|
||||
<!-- Top boxes -->
|
||||
<rect class="box" x="390" y="30" width="220" height="44" rx="0" />
|
||||
<text class="text" x="500" y="58" text-anchor="middle">ralph CLI</text>
|
||||
|
||||
<rect class="box" x="240" y="114" width="520" height="48" rx="0" />
|
||||
<text class="text small muted" x="500" y="144" text-anchor="middle">agent CLI: codex | claude | droid</text>
|
||||
|
||||
<!-- Arrow from top to agent (gap matches lower arrows) -->
|
||||
<line class="line" x1="500" y1="74" x2="500" y2="96" />
|
||||
<polygon points="496,96 500,104 504,96" fill="#e6e6e6" />
|
||||
|
||||
<!-- Branch lines (gap for heads) -->
|
||||
<line class="line" x1="500" y1="162" x2="500" y2="200" />
|
||||
<line class="line" x1="500" y1="200" x2="280" y2="200" />
|
||||
<line class="line" x1="500" y1="200" x2="720" y2="200" />
|
||||
<line class="line" x1="280" y1="200" x2="280" y2="214" />
|
||||
<line class="line" x1="720" y1="200" x2="720" y2="214" />
|
||||
<polygon points="276,214 280,222 284,214" fill="#e6e6e6" />
|
||||
<polygon points="716,214 720,222 724,214" fill="#e6e6e6" />
|
||||
|
||||
<!-- Bottom boxes -->
|
||||
<rect class="box" x="90" y="226" width="380" height="230" rx="0" />
|
||||
<text class="text" x="110" y="256">.agents/ralph/</text>
|
||||
<text class="text small muted" x="110" y="278">local overrides (optional)</text>
|
||||
<text class="text small" x="110" y="306">loop.sh</text>
|
||||
<text class="text small" x="110" y="328">PROMPT_build.md</text>
|
||||
<text class="text small" x="110" y="350">references/</text>
|
||||
<text class="text small" x="110" y="372">log-activity.sh</text>
|
||||
<text class="text small" x="110" y="394">config.sh (optional)</text>
|
||||
|
||||
<rect class="box" x="530" y="226" width="380" height="230" rx="0" />
|
||||
<text class="text" x="550" y="256">.ralph/</text>
|
||||
<text class="text small muted" x="550" y="278">state + logs</text>
|
||||
<text class="text small" x="550" y="306">errors.log</text>
|
||||
<text class="text small" x="550" y="328">progress.md</text>
|
||||
<text class="text small" x="550" y="350">guardrails.md</text>
|
||||
<text class="text small" x="550" y="372">activity.log</text>
|
||||
<text class="text small" x="550" y="394">runs/</text>
|
||||
|
||||
<text class="text small muted" x="500" y="500" text-anchor="middle">
|
||||
Local templates override bundled defaults. State persists between runs.
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
ACTIVITY_LOG="$ROOT_DIR/.ralph/activity.log"
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 \"message\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$ACTIVITY_LOG")"
|
||||
TS=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$TS] $*" >> "$ACTIVITY_LOG"
|
||||
Executable
+955
@@ -0,0 +1,955 @@
|
||||
#!/bin/bash
|
||||
# Ralph loop — simple, portable, single-agent
|
||||
# Usage:
|
||||
# ./.agents/ralph/loop.sh # build mode, default iterations
|
||||
# ./.agents/ralph/loop.sh build # build mode
|
||||
# ./.agents/ralph/loop.sh prd "request" # generate PRD via agent
|
||||
# ./.agents/ralph/loop.sh 10 # build mode, 10 iterations
|
||||
# ./.agents/ralph/loop.sh build 1 --no-commit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)"
|
||||
CONFIG_FILE="${SCRIPT_DIR}/config.sh"
|
||||
|
||||
DEFAULT_PRD_PATH=".agents/tasks/prd.json"
|
||||
DEFAULT_PROGRESS_PATH=".ralph/progress.md"
|
||||
DEFAULT_AGENTS_PATH="AGENTS.md"
|
||||
DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
|
||||
DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md"
|
||||
DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log"
|
||||
DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log"
|
||||
DEFAULT_TMP_DIR=".ralph/.tmp"
|
||||
DEFAULT_RUNS_DIR=".ralph/runs"
|
||||
DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
|
||||
DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
|
||||
DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh"
|
||||
if [[ -n "${RALPH_ROOT:-}" ]]; then
|
||||
agents_path="$RALPH_ROOT/.agents/ralph/agents.sh"
|
||||
else
|
||||
agents_path="$SCRIPT_DIR/agents.sh"
|
||||
fi
|
||||
if [[ -f "$agents_path" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$agents_path"
|
||||
fi
|
||||
|
||||
DEFAULT_MAX_ITERATIONS=25
|
||||
DEFAULT_NO_COMMIT=false
|
||||
DEFAULT_STALE_SECONDS=0
|
||||
PRD_REQUEST_PATH=""
|
||||
PRD_INLINE=""
|
||||
|
||||
# Optional config overrides (simple shell vars)
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}"
|
||||
resolve_agent_cmd() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
claude)
|
||||
echo "${AGENT_CLAUDE_CMD:-claude -p --dangerously-skip-permissions \"\$(cat {prompt})\"}"
|
||||
;;
|
||||
droid)
|
||||
echo "${AGENT_DROID_CMD:-droid exec --skip-permissions-unsafe -f {prompt}}"
|
||||
;;
|
||||
codex|"")
|
||||
echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
|
||||
;;
|
||||
*)
|
||||
echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME")"
|
||||
|
||||
PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}"
|
||||
PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}"
|
||||
AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}"
|
||||
PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}"
|
||||
GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}"
|
||||
ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}"
|
||||
ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}"
|
||||
TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}"
|
||||
RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}"
|
||||
GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}"
|
||||
CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}"
|
||||
ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}"
|
||||
AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}"
|
||||
MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}"
|
||||
NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}"
|
||||
STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}"
|
||||
|
||||
abs_path() {
|
||||
local p="$1"
|
||||
if [[ "$p" = /* ]]; then
|
||||
echo "$p"
|
||||
else
|
||||
echo "$ROOT_DIR/$p"
|
||||
fi
|
||||
}
|
||||
|
||||
PRD_PATH="$(abs_path "$PRD_PATH")"
|
||||
PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")"
|
||||
AGENTS_PATH="$(abs_path "$AGENTS_PATH")"
|
||||
PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")"
|
||||
GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")"
|
||||
ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")"
|
||||
ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")"
|
||||
TMP_DIR="$(abs_path "$TMP_DIR")"
|
||||
RUNS_DIR="$(abs_path "$RUNS_DIR")"
|
||||
GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")"
|
||||
CONTEXT_REF="$(abs_path "$CONTEXT_REF")"
|
||||
ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")"
|
||||
|
||||
require_agent() {
|
||||
local agent_cmd="${1:-$AGENT_CMD}"
|
||||
local agent_bin
|
||||
agent_bin="${agent_cmd%% *}"
|
||||
if [ -z "$agent_bin" ]; then
|
||||
echo "AGENT_CMD is empty. Set it in config.sh."
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v "$agent_bin" >/dev/null 2>&1; then
|
||||
echo "Agent command not found: $agent_bin"
|
||||
case "$agent_bin" in
|
||||
codex)
|
||||
echo "Install: npm i -g @openai/codex"
|
||||
;;
|
||||
claude)
|
||||
echo "Install: curl -fsSL https://claude.ai/install.sh | bash"
|
||||
;;
|
||||
droid)
|
||||
echo "Install: curl -fsSL https://app.factory.ai/cli | sh"
|
||||
;;
|
||||
opencode)
|
||||
echo "Install: curl -fsSL https://opencode.ai/install.sh | bash"
|
||||
;;
|
||||
esac
|
||||
echo "Then authenticate per the CLI's instructions."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_agent() {
|
||||
local prompt_file="$1"
|
||||
if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then
|
||||
local escaped
|
||||
escaped=$(printf '%q' "$prompt_file")
|
||||
local cmd="${AGENT_CMD//\{prompt\}/$escaped}"
|
||||
eval "$cmd"
|
||||
else
|
||||
cat "$prompt_file" | eval "$AGENT_CMD"
|
||||
fi
|
||||
}
|
||||
|
||||
run_agent_inline() {
|
||||
local prompt_file="$1"
|
||||
local prompt_content
|
||||
prompt_content="$(cat "$prompt_file")"
|
||||
local escaped
|
||||
escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g")
|
||||
local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}"
|
||||
if [[ "$cmd" == *"{prompt}"* ]]; then
|
||||
cmd="${cmd//\{prompt\}/'$escaped'}"
|
||||
else
|
||||
cmd="$cmd '$escaped'"
|
||||
fi
|
||||
eval "$cmd"
|
||||
}
|
||||
|
||||
MODE="build"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
build|prd)
|
||||
MODE="$1"
|
||||
shift
|
||||
;;
|
||||
--prompt)
|
||||
PRD_REQUEST_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-commit)
|
||||
NO_COMMIT=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
if [ "$MODE" = "prd" ]; then
|
||||
PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1"
|
||||
shift
|
||||
elif [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
MAX_ITERATIONS="$1"
|
||||
shift
|
||||
else
|
||||
echo "Unknown arg: $1"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROMPT_FILE="$PROMPT_BUILD"
|
||||
|
||||
if [ "$MODE" = "prd" ]; then
|
||||
PRD_USE_INLINE=1
|
||||
if [ -z "${PRD_AGENT_CMD:-}" ]; then
|
||||
PRD_AGENT_CMD="$AGENT_CMD"
|
||||
PRD_USE_INLINE=0
|
||||
fi
|
||||
if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
|
||||
require_agent "${PRD_AGENT_CMD:-$AGENT_CMD}"
|
||||
fi
|
||||
|
||||
if [[ "$PRD_PATH" == *.json ]]; then
|
||||
mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR"
|
||||
else
|
||||
mkdir -p "$PRD_PATH" "$TMP_DIR"
|
||||
fi
|
||||
|
||||
if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then
|
||||
PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt"
|
||||
printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then
|
||||
echo "PRD request missing. Provide a prompt string or --prompt <file>."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
|
||||
if [[ "$PRD_PATH" == *.json ]]; then
|
||||
if [ ! -f "$PRD_PATH" ]; then
|
||||
{
|
||||
echo '{'
|
||||
echo ' "version": 1,'
|
||||
echo ' "project": "ralph",'
|
||||
echo ' "qualityGates": [],'
|
||||
echo ' "stories": []'
|
||||
echo '}'
|
||||
} > "$PRD_PATH"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md"
|
||||
{
|
||||
echo "You are an autonomous coding agent."
|
||||
echo "Use the \$prd skill to create a Product Requirements Document in JSON."
|
||||
if [[ "$PRD_PATH" == *.json ]]; then
|
||||
echo "Save the PRD to: $PRD_PATH"
|
||||
else
|
||||
echo "Save the PRD as JSON in directory: $PRD_PATH"
|
||||
echo "Filename rules: prd-<short-slug>.json using 1-3 meaningful words."
|
||||
echo "Examples: prd-workout-tracker.json, prd-usage-billing.json"
|
||||
fi
|
||||
echo "Do NOT implement anything."
|
||||
echo "After creating the PRD, end with:"
|
||||
echo "PRD JSON saved to <path>. Close this chat and run \`ralph build\`."
|
||||
echo ""
|
||||
echo "User request:"
|
||||
cat "$PRD_REQUEST_PATH"
|
||||
} > "$PRD_PROMPT_FILE"
|
||||
|
||||
if [ "$PRD_USE_INLINE" -eq 1 ]; then
|
||||
run_agent_inline "$PRD_PROMPT_FILE"
|
||||
else
|
||||
run_agent "$PRD_PROMPT_FILE"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
|
||||
require_agent
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROMPT_FILE" ]; then
|
||||
echo "Prompt not found: $PROMPT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then
|
||||
echo "PRD not found: $PRD_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR"
|
||||
|
||||
if [ ! -f "$PROGRESS_PATH" ]; then
|
||||
{
|
||||
echo "# Progress Log"
|
||||
echo "Started: $(date)"
|
||||
echo ""
|
||||
echo "## Codebase Patterns"
|
||||
echo "- (add reusable patterns here)"
|
||||
echo ""
|
||||
echo "---"
|
||||
} > "$PROGRESS_PATH"
|
||||
fi
|
||||
|
||||
if [ ! -f "$GUARDRAILS_PATH" ]; then
|
||||
{
|
||||
echo "# Guardrails (Signs)"
|
||||
echo ""
|
||||
echo "> Lessons learned from failures. Read before acting."
|
||||
echo ""
|
||||
echo "## Core Signs"
|
||||
echo ""
|
||||
echo "### Sign: Read Before Writing"
|
||||
echo "- **Trigger**: Before modifying any file"
|
||||
echo "- **Instruction**: Read the file first"
|
||||
echo "- **Added after**: Core principle"
|
||||
echo ""
|
||||
echo "### Sign: Test Before Commit"
|
||||
echo "- **Trigger**: Before committing changes"
|
||||
echo "- **Instruction**: Run required tests and verify outputs"
|
||||
echo "- **Added after**: Core principle"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Learned Signs"
|
||||
echo ""
|
||||
} > "$GUARDRAILS_PATH"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ERRORS_LOG_PATH" ]; then
|
||||
{
|
||||
echo "# Error Log"
|
||||
echo ""
|
||||
echo "> Failures and repeated issues. Use this to add guardrails."
|
||||
echo ""
|
||||
} > "$ERRORS_LOG_PATH"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ACTIVITY_LOG_PATH" ]; then
|
||||
{
|
||||
echo "# Activity Log"
|
||||
echo ""
|
||||
echo "## Run Summary"
|
||||
echo ""
|
||||
echo "## Events"
|
||||
echo ""
|
||||
} > "$ACTIVITY_LOG_PATH"
|
||||
fi
|
||||
|
||||
RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$"
|
||||
|
||||
render_prompt() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
local story_meta="$3"
|
||||
local story_block="$4"
|
||||
local run_id="$5"
|
||||
local iter="$6"
|
||||
local run_log="$7"
|
||||
local run_meta="$8"
|
||||
python3 - "$src" "$dst" "$PRD_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$ROOT_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1]).read_text()
|
||||
prd, agents, progress, root = sys.argv[3:7]
|
||||
guardrails = sys.argv[7]
|
||||
errors_log = sys.argv[8]
|
||||
activity_log = sys.argv[9]
|
||||
guardrails_ref = sys.argv[10]
|
||||
context_ref = sys.argv[11]
|
||||
activity_cmd = sys.argv[12]
|
||||
no_commit = sys.argv[13]
|
||||
meta_path = sys.argv[14] if len(sys.argv) > 14 else ""
|
||||
block_path = sys.argv[15] if len(sys.argv) > 15 else ""
|
||||
run_id = sys.argv[16] if len(sys.argv) > 16 else ""
|
||||
iteration = sys.argv[17] if len(sys.argv) > 17 else ""
|
||||
run_log = sys.argv[18] if len(sys.argv) > 18 else ""
|
||||
run_meta = sys.argv[19] if len(sys.argv) > 19 else ""
|
||||
repl = {
|
||||
"PRD_PATH": prd,
|
||||
"AGENTS_PATH": agents,
|
||||
"PROGRESS_PATH": progress,
|
||||
"REPO_ROOT": root,
|
||||
"GUARDRAILS_PATH": guardrails,
|
||||
"ERRORS_LOG_PATH": errors_log,
|
||||
"ACTIVITY_LOG_PATH": activity_log,
|
||||
"GUARDRAILS_REF": guardrails_ref,
|
||||
"CONTEXT_REF": context_ref,
|
||||
"ACTIVITY_CMD": activity_cmd,
|
||||
"NO_COMMIT": no_commit,
|
||||
"RUN_ID": run_id,
|
||||
"ITERATION": iteration,
|
||||
"RUN_LOG_PATH": run_log,
|
||||
"RUN_META_PATH": run_meta,
|
||||
}
|
||||
story = {"id": "", "title": "", "block": ""}
|
||||
quality_gates = []
|
||||
if meta_path:
|
||||
try:
|
||||
import json
|
||||
meta = json.loads(Path(meta_path).read_text())
|
||||
story["id"] = meta.get("id", "") or ""
|
||||
story["title"] = meta.get("title", "") or ""
|
||||
quality_gates = meta.get("quality_gates", []) or []
|
||||
except Exception:
|
||||
pass
|
||||
if block_path and Path(block_path).exists():
|
||||
story["block"] = Path(block_path).read_text()
|
||||
repl["STORY_ID"] = story["id"]
|
||||
repl["STORY_TITLE"] = story["title"]
|
||||
repl["STORY_BLOCK"] = story["block"]
|
||||
if quality_gates:
|
||||
repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates])
|
||||
else:
|
||||
repl["QUALITY_GATES"] = "- (none)"
|
||||
for k, v in repl.items():
|
||||
src = src.replace("{{" + k + "}}", v)
|
||||
Path(sys.argv[2]).write_text(src)
|
||||
PY
|
||||
}
|
||||
|
||||
select_story() {
|
||||
local meta_out="$1"
|
||||
local block_out="$2"
|
||||
python3 - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
import fcntl
|
||||
except Exception:
|
||||
fcntl = None
|
||||
|
||||
prd_path = Path(sys.argv[1])
|
||||
meta_out = Path(sys.argv[2])
|
||||
block_out = Path(sys.argv[3])
|
||||
stale_seconds = 0
|
||||
if len(sys.argv) > 4:
|
||||
try:
|
||||
stale_seconds = int(sys.argv[4])
|
||||
except Exception:
|
||||
stale_seconds = 0
|
||||
|
||||
if not prd_path.exists():
|
||||
meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n")
|
||||
block_out.write_text("")
|
||||
sys.exit(0)
|
||||
|
||||
def normalize_status(value):
|
||||
if value is None:
|
||||
return "open"
|
||||
return str(value).strip().lower()
|
||||
|
||||
def parse_ts(value):
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with prd_path.open("r+", encoding="utf-8") as fh:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
try:
|
||||
data = json.load(fh)
|
||||
except Exception as exc:
|
||||
meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n")
|
||||
block_out.write_text("")
|
||||
sys.exit(0)
|
||||
|
||||
stories = data.get("stories") if isinstance(data, dict) else None
|
||||
if not isinstance(stories, list) or not stories:
|
||||
meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n")
|
||||
block_out.write_text("")
|
||||
sys.exit(0)
|
||||
|
||||
story_index = {s.get("id"): s for s in stories if isinstance(s, dict)}
|
||||
|
||||
def is_done(story_id: str) -> bool:
|
||||
target = story_index.get(story_id)
|
||||
if not isinstance(target, dict):
|
||||
return False
|
||||
return normalize_status(target.get("status")) == "done"
|
||||
|
||||
if stale_seconds > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
for story in stories:
|
||||
if not isinstance(story, dict):
|
||||
continue
|
||||
if normalize_status(story.get("status")) != "in_progress":
|
||||
continue
|
||||
started = parse_ts(story.get("startedAt"))
|
||||
if started is None or (now - started).total_seconds() > stale_seconds:
|
||||
story["status"] = "open"
|
||||
story["startedAt"] = None
|
||||
story["completedAt"] = None
|
||||
story["updatedAt"] = now_iso()
|
||||
|
||||
candidate = None
|
||||
for story in stories:
|
||||
if not isinstance(story, dict):
|
||||
continue
|
||||
if normalize_status(story.get("status")) != "open":
|
||||
continue
|
||||
deps = story.get("dependsOn") or []
|
||||
if not isinstance(deps, list):
|
||||
deps = []
|
||||
if all(is_done(dep) for dep in deps):
|
||||
candidate = story
|
||||
break
|
||||
|
||||
remaining = sum(
|
||||
1 for story in stories
|
||||
if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
|
||||
)
|
||||
|
||||
meta = {
|
||||
"ok": True,
|
||||
"total": len(stories),
|
||||
"remaining": remaining,
|
||||
"quality_gates": data.get("qualityGates", []) or [],
|
||||
}
|
||||
|
||||
if candidate:
|
||||
candidate["status"] = "in_progress"
|
||||
if not candidate.get("startedAt"):
|
||||
candidate["startedAt"] = now_iso()
|
||||
candidate["completedAt"] = None
|
||||
candidate["updatedAt"] = now_iso()
|
||||
meta.update({
|
||||
"id": candidate.get("id", ""),
|
||||
"title": candidate.get("title", ""),
|
||||
})
|
||||
|
||||
depends = candidate.get("dependsOn") or []
|
||||
if not isinstance(depends, list):
|
||||
depends = []
|
||||
acceptance = candidate.get("acceptanceCriteria") or []
|
||||
if not isinstance(acceptance, list):
|
||||
acceptance = []
|
||||
|
||||
description = candidate.get("description") or ""
|
||||
block_lines = []
|
||||
block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}")
|
||||
block_lines.append(f"Status: {candidate.get('status', 'open')}")
|
||||
block_lines.append(
|
||||
f"Depends on: {', '.join(depends) if depends else 'None'}"
|
||||
)
|
||||
block_lines.append("")
|
||||
block_lines.append("Description:")
|
||||
block_lines.append(description if description else "(none)")
|
||||
block_lines.append("")
|
||||
block_lines.append("Acceptance Criteria:")
|
||||
if acceptance:
|
||||
block_lines.extend([f"- [ ] {item}" for item in acceptance])
|
||||
else:
|
||||
block_lines.append("- (none)")
|
||||
block_out.write_text("\n".join(block_lines).rstrip() + "\n")
|
||||
else:
|
||||
block_out.write_text("")
|
||||
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
json.dump(data, fh, indent=2)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
finally:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
meta_out.write_text(json.dumps(meta, indent=2) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
remaining_stories() {
|
||||
local meta_file="$1"
|
||||
python3 - "$meta_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path(sys.argv[1]).read_text())
|
||||
print(data.get("remaining", "unknown"))
|
||||
PY
|
||||
}
|
||||
|
||||
remaining_from_prd() {
|
||||
python3 - "$PRD_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
prd_path = Path(sys.argv[1])
|
||||
if not prd_path.exists():
|
||||
print("unknown")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
data = json.loads(prd_path.read_text())
|
||||
except Exception:
|
||||
print("unknown")
|
||||
sys.exit(0)
|
||||
|
||||
stories = data.get("stories") if isinstance(data, dict) else None
|
||||
if not isinstance(stories, list):
|
||||
print("unknown")
|
||||
sys.exit(0)
|
||||
|
||||
def normalize_status(value):
|
||||
if value is None:
|
||||
return "open"
|
||||
return str(value).strip().lower()
|
||||
|
||||
remaining = sum(
|
||||
1 for story in stories
|
||||
if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
|
||||
)
|
||||
print(remaining)
|
||||
PY
|
||||
}
|
||||
|
||||
story_field() {
|
||||
local meta_file="$1"
|
||||
local field="$2"
|
||||
python3 - "$meta_file" "$field" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path(sys.argv[1]).read_text())
|
||||
field = sys.argv[2]
|
||||
print(data.get(field, ""))
|
||||
PY
|
||||
}
|
||||
|
||||
update_story_status() {
|
||||
local story_id="$1"
|
||||
local new_status="$2"
|
||||
python3 - "$PRD_PATH" "$story_id" "$new_status" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
import fcntl
|
||||
except Exception:
|
||||
fcntl = None
|
||||
|
||||
prd_path = Path(sys.argv[1])
|
||||
story_id = sys.argv[2]
|
||||
new_status = sys.argv[3]
|
||||
|
||||
if not story_id:
|
||||
sys.exit(0)
|
||||
|
||||
if not prd_path.exists():
|
||||
sys.exit(0)
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with prd_path.open("r+", encoding="utf-8") as fh:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
data = json.load(fh)
|
||||
stories = data.get("stories") if isinstance(data, dict) else None
|
||||
if not isinstance(stories, list):
|
||||
sys.exit(0)
|
||||
for story in stories:
|
||||
if isinstance(story, dict) and story.get("id") == story_id:
|
||||
story["status"] = new_status
|
||||
story["updatedAt"] = now_iso()
|
||||
if new_status == "in_progress":
|
||||
if not story.get("startedAt"):
|
||||
story["startedAt"] = now_iso()
|
||||
story["completedAt"] = None
|
||||
elif new_status == "done":
|
||||
story["completedAt"] = now_iso()
|
||||
if not story.get("startedAt"):
|
||||
story["startedAt"] = now_iso()
|
||||
elif new_status == "open":
|
||||
story["startedAt"] = None
|
||||
story["completedAt"] = None
|
||||
break
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
json.dump(data, fh, indent=2)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
finally:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
PY
|
||||
}
|
||||
|
||||
log_activity() {
|
||||
local message="$1"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
local message="$1"
|
||||
local timestamp
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH"
|
||||
}
|
||||
|
||||
append_run_summary() {
|
||||
local line="$1"
|
||||
python3 - "$ACTIVITY_LOG_PATH" "$line" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
line = sys.argv[2]
|
||||
text = path.read_text().splitlines()
|
||||
out = []
|
||||
inserted = False
|
||||
for l in text:
|
||||
out.append(l)
|
||||
if not inserted and l.strip() == "## Run Summary":
|
||||
out.append(f"- {line}")
|
||||
inserted = True
|
||||
if not inserted:
|
||||
out = [
|
||||
"# Activity Log",
|
||||
"",
|
||||
"## Run Summary",
|
||||
f"- {line}",
|
||||
"",
|
||||
"## Events",
|
||||
"",
|
||||
] + text
|
||||
Path(path).write_text("\n".join(out).rstrip() + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
write_run_meta() {
|
||||
local path="$1"
|
||||
local mode="$2"
|
||||
local iter="$3"
|
||||
local run_id="$4"
|
||||
local story_id="$5"
|
||||
local story_title="$6"
|
||||
local started="$7"
|
||||
local ended="$8"
|
||||
local duration="$9"
|
||||
local status="${10}"
|
||||
local log_file="${11}"
|
||||
local head_before="${12}"
|
||||
local head_after="${13}"
|
||||
local commit_list="${14}"
|
||||
local changed_files="${15}"
|
||||
local dirty_files="${16}"
|
||||
{
|
||||
echo "# Ralph Run Summary"
|
||||
echo ""
|
||||
echo "- Run ID: $run_id"
|
||||
echo "- Iteration: $iter"
|
||||
echo "- Mode: $mode"
|
||||
if [ -n "$story_id" ]; then
|
||||
echo "- Story: $story_id: $story_title"
|
||||
fi
|
||||
echo "- Started: $started"
|
||||
echo "- Ended: $ended"
|
||||
echo "- Duration: ${duration}s"
|
||||
echo "- Status: $status"
|
||||
echo "- Log: $log_file"
|
||||
echo ""
|
||||
echo "## Git"
|
||||
echo "- Head (before): ${head_before:-unknown}"
|
||||
echo "- Head (after): ${head_after:-unknown}"
|
||||
echo ""
|
||||
echo "### Commits"
|
||||
if [ -n "$commit_list" ]; then
|
||||
echo "$commit_list"
|
||||
else
|
||||
echo "- (none)"
|
||||
fi
|
||||
echo ""
|
||||
echo "### Changed Files (commits)"
|
||||
if [ -n "$changed_files" ]; then
|
||||
echo "$changed_files"
|
||||
else
|
||||
echo "- (none)"
|
||||
fi
|
||||
echo ""
|
||||
echo "### Uncommitted Changes"
|
||||
if [ -n "$dirty_files" ]; then
|
||||
echo "$dirty_files"
|
||||
else
|
||||
echo "- (clean)"
|
||||
fi
|
||||
echo ""
|
||||
} > "$path"
|
||||
}
|
||||
|
||||
git_head() {
|
||||
if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
git_commit_list() {
|
||||
local before="$1"
|
||||
local after="$2"
|
||||
if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
|
||||
git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
git_changed_files() {
|
||||
local before="$1"
|
||||
local after="$2"
|
||||
if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
|
||||
git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
git_dirty_files() {
|
||||
if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Ralph mode: $MODE"
|
||||
echo "Max iterations: $MAX_ITERATIONS"
|
||||
echo "PRD: $PRD_PATH"
|
||||
HAS_ERROR="false"
|
||||
|
||||
for i in $(seq 1 "$MAX_ITERATIONS"); do
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " Ralph Iteration $i of $MAX_ITERATIONS"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
|
||||
STORY_META=""
|
||||
STORY_BLOCK=""
|
||||
ITER_START=$(date +%s)
|
||||
ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
if [ "$MODE" = "build" ]; then
|
||||
STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json"
|
||||
STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md"
|
||||
select_story "$STORY_META" "$STORY_BLOCK"
|
||||
REMAINING="$(remaining_stories "$STORY_META")"
|
||||
if [ "$REMAINING" = "unknown" ]; then
|
||||
echo "Could not parse stories from PRD: $PRD_PATH"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$REMAINING" = "0" ]; then
|
||||
echo "No remaining stories."
|
||||
exit 0
|
||||
fi
|
||||
STORY_ID="$(story_field "$STORY_META" "id")"
|
||||
STORY_TITLE="$(story_field "$STORY_META" "title")"
|
||||
if [ -z "$STORY_ID" ]; then
|
||||
echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
HEAD_BEFORE="$(git_head)"
|
||||
PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md"
|
||||
LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log"
|
||||
RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md"
|
||||
render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META"
|
||||
|
||||
if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
|
||||
log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)"
|
||||
else
|
||||
log_activity "ITERATION $i start (mode=$MODE)"
|
||||
fi
|
||||
set +e
|
||||
if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
|
||||
echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE"
|
||||
CMD_STATUS=0
|
||||
else
|
||||
run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE"
|
||||
CMD_STATUS=$?
|
||||
fi
|
||||
set -e
|
||||
if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then
|
||||
echo "Interrupted."
|
||||
exit "$CMD_STATUS"
|
||||
fi
|
||||
ITER_END=$(date +%s)
|
||||
ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
ITER_DURATION=$((ITER_END - ITER_START))
|
||||
HEAD_AFTER="$(git_head)"
|
||||
log_activity "ITERATION $i end (duration=${ITER_DURATION}s)"
|
||||
if [ "$CMD_STATUS" -ne 0 ]; then
|
||||
log_error "ITERATION $i command failed (status=$CMD_STATUS)"
|
||||
HAS_ERROR="true"
|
||||
fi
|
||||
COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")"
|
||||
CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")"
|
||||
DIRTY_FILES="$(git_dirty_files)"
|
||||
STATUS_LABEL="success"
|
||||
if [ "$CMD_STATUS" -ne 0 ]; then
|
||||
STATUS_LABEL="error"
|
||||
fi
|
||||
if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then
|
||||
log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META"
|
||||
fi
|
||||
write_run_meta "$RUN_META" "$MODE" "$i" "$RUN_TAG" "${STORY_ID:-}" "${STORY_TITLE:-}" "$ITER_START_FMT" "$ITER_END_FMT" "$ITER_DURATION" "$STATUS_LABEL" "$LOG_FILE" "$HEAD_BEFORE" "$HEAD_AFTER" "$COMMIT_LIST" "$CHANGED_FILES" "$DIRTY_FILES"
|
||||
if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
|
||||
append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | story=$STORY_ID | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
|
||||
else
|
||||
append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "build" ]; then
|
||||
if [ "$CMD_STATUS" -ne 0 ]; then
|
||||
log_error "ITERATION $i exited non-zero; review $LOG_FILE"
|
||||
update_story_status "$STORY_ID" "open"
|
||||
echo "Iteration failed; story reset to open."
|
||||
elif grep -q "<promise>COMPLETE</promise>" "$LOG_FILE"; then
|
||||
update_story_status "$STORY_ID" "done"
|
||||
echo "Completion signal received; story marked done."
|
||||
else
|
||||
update_story_status "$STORY_ID" "open"
|
||||
echo "No completion signal; story reset to open."
|
||||
fi
|
||||
REMAINING="$(remaining_from_prd)"
|
||||
echo "Iteration $i complete. Remaining stories: $REMAINING"
|
||||
if [ "$REMAINING" = "0" ]; then
|
||||
echo "No remaining stories."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "Iteration $i complete."
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
done
|
||||
|
||||
echo "Reached max iterations ($MAX_ITERATIONS)."
|
||||
if [ "$HAS_ERROR" = "true" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
@@ -0,0 +1,126 @@
|
||||
# Context Engineering Reference
|
||||
|
||||
This document explains the malloc/free metaphor for LLM context management that underlies the Ralph technique.
|
||||
|
||||
## The malloc() Metaphor
|
||||
|
||||
In traditional programming:
|
||||
- `malloc()` allocates memory
|
||||
- `free()` releases memory
|
||||
- Memory leaks occur when you allocate without freeing
|
||||
|
||||
In LLM context:
|
||||
- Reading files, receiving responses, tool outputs = `malloc()`
|
||||
- **There is no `free()`** - context cannot be released
|
||||
- The only way to "free" is to start a new conversation
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### Context Pollution
|
||||
|
||||
When you work on multiple unrelated tasks in the same context:
|
||||
|
||||
```
|
||||
Task 1: Build authentication → context contains auth code, JWT docs, security patterns
|
||||
Task 2: Build UI components → context now ALSO contains auth stuff
|
||||
|
||||
Result: LLM might suggest auth-related patterns when building UI
|
||||
or mix concerns inappropriately
|
||||
```
|
||||
|
||||
### Autoregressive Failure
|
||||
|
||||
LLMs predict the next token based on ALL context. When context contains:
|
||||
- Unrelated information
|
||||
- Failed attempts
|
||||
- Mixed concerns
|
||||
|
||||
The model can "spiral" into wrong territory, generating increasingly off-base responses.
|
||||
|
||||
### The Gutter Metaphor
|
||||
|
||||
> "If the bowling ball is in the gutter, there's no saving it."
|
||||
|
||||
Once context is polluted with failed attempts or mixed concerns, the model will keep referencing that pollution. Starting fresh is often faster than trying to correct course.
|
||||
|
||||
## Context Health Indicators
|
||||
|
||||
### 🟢 Healthy Context
|
||||
- Single focused task
|
||||
- Relevant files only
|
||||
- Clear progress
|
||||
- Under 60% capacity
|
||||
|
||||
### 🟡 Warning Signs
|
||||
- Multiple unrelated topics discussed
|
||||
- Several failed attempts in history
|
||||
- Approaching 80% capacity
|
||||
- Repeated similar errors
|
||||
|
||||
### 🔴 Critical / Gutter
|
||||
- Mixed concerns throughout
|
||||
- Circular failure patterns
|
||||
- Over 90% capacity
|
||||
- Model suggesting irrelevant solutions
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. One Task Per Context
|
||||
|
||||
Don't ask "fix the auth bug AND add the new feature". Do them in separate conversations.
|
||||
|
||||
### 2. Fresh Start on Topic Change
|
||||
|
||||
Finished auth? Start a new conversation for the next feature.
|
||||
|
||||
### 3. Don't Redline
|
||||
|
||||
Stay under 80% of context capacity. Quality degrades as you approach limits.
|
||||
|
||||
### 4. Recognize the Gutter
|
||||
|
||||
If you're seeing:
|
||||
- Same error 3+ times
|
||||
- Solutions that don't match the problem
|
||||
- Circular suggestions
|
||||
|
||||
Start fresh. Your progress is in the files.
|
||||
|
||||
### 5. State in Files, Not Context
|
||||
|
||||
Write progress to files. The next conversation can read them. Context is ephemeral; files are permanent.
|
||||
|
||||
## Ralph's Approach
|
||||
|
||||
The original Ralph technique (`while :; do cat PROMPT.md | agent ; done`) naturally implements these principles:
|
||||
|
||||
1. **Each iteration is a fresh process** - Context is freed
|
||||
2. **State persists in files** - Progress survives context resets
|
||||
3. **Same prompt each time** - Focused, single-task context
|
||||
4. **Failures inform guardrails** - Learning without context pollution
|
||||
|
||||
This Cursor implementation aims to bring these benefits while working within Cursor's session model.
|
||||
|
||||
## Measuring Context
|
||||
|
||||
Rough estimates:
|
||||
- 1 token ≈ 4 characters
|
||||
- Average code file: 500-2000 tokens
|
||||
- Large file: 5000+ tokens
|
||||
- Conversation history: 100-500 tokens per exchange
|
||||
|
||||
Track allocations in `.ralph/context-log.md` to stay aware.
|
||||
|
||||
## When to Start Fresh
|
||||
|
||||
**Definitely start fresh when:**
|
||||
- Switching to unrelated task
|
||||
- Context over 90% full
|
||||
- Same error 3+ times
|
||||
- Model suggestions are off-topic
|
||||
|
||||
**Consider starting fresh when:**
|
||||
- Context over 70% full
|
||||
- Significant topic shift within task
|
||||
- Feeling "stuck"
|
||||
- Multiple failed approaches in history
|
||||
@@ -0,0 +1,174 @@
|
||||
# Guardrails Reference ("Signs")
|
||||
|
||||
This document explains how to create and use guardrails in Ralph.
|
||||
|
||||
## The Signs Metaphor
|
||||
|
||||
From Geoffrey Huntley:
|
||||
|
||||
> "Ralph is very good at making playgrounds, but he comes home bruised because he fell off the slide, so one then tunes Ralph by adding a sign next to the slide saying 'SLIDE DOWN, DON'T JUMP, LOOK AROUND,' and Ralph is more likely to look and see the sign."
|
||||
|
||||
Signs are explicit instructions added to prevent known failure modes.
|
||||
|
||||
## Anatomy of a Sign
|
||||
|
||||
```markdown
|
||||
### Sign: [Descriptive Name]
|
||||
- **Trigger**: When this situation occurs
|
||||
- **Instruction**: What to do instead
|
||||
- **Added after**: When/why this was added
|
||||
- **Example**: Concrete example if helpful
|
||||
```
|
||||
|
||||
## Types of Signs
|
||||
|
||||
### 1. Preventive Signs
|
||||
|
||||
Stop problems before they happen:
|
||||
|
||||
```markdown
|
||||
### Sign: Validate Before Trust
|
||||
- **Trigger**: When receiving external input
|
||||
- **Instruction**: Always validate and sanitize input before using it
|
||||
- **Added after**: Iteration 3 - SQL injection vulnerability
|
||||
```
|
||||
|
||||
### 2. Corrective Signs
|
||||
|
||||
Fix recurring mistakes:
|
||||
|
||||
```markdown
|
||||
### Sign: Check Return Values
|
||||
- **Trigger**: When calling functions that can fail
|
||||
- **Instruction**: Always check return values and handle errors
|
||||
- **Added after**: Iteration 7 - Null pointer exception
|
||||
```
|
||||
|
||||
### 3. Process Signs
|
||||
|
||||
Enforce good practices:
|
||||
|
||||
```markdown
|
||||
### Sign: Test Before Commit
|
||||
- **Trigger**: Before committing changes
|
||||
- **Instruction**: Run the test suite and ensure all tests pass
|
||||
- **Added after**: Iteration 2 - Broken tests committed
|
||||
```
|
||||
|
||||
### 4. Architecture Signs
|
||||
|
||||
Guide design decisions:
|
||||
|
||||
```markdown
|
||||
### Sign: Single Responsibility
|
||||
- **Trigger**: When a function grows beyond 50 lines
|
||||
- **Instruction**: Consider splitting into smaller, focused functions
|
||||
- **Added after**: Iteration 12 - Unmaintainable god function
|
||||
```
|
||||
|
||||
## When to Add Signs
|
||||
|
||||
Add a sign when:
|
||||
|
||||
1. **The same mistake happens twice** - Once is learning, twice is a pattern
|
||||
2. **A subtle bug is found** - Prevent future occurrences
|
||||
3. **A best practice is violated** - Reinforce good habits
|
||||
4. **Context-specific knowledge is needed** - Project-specific conventions
|
||||
|
||||
## Sign Lifecycle
|
||||
|
||||
### Creation
|
||||
|
||||
```markdown
|
||||
### Sign: [New Sign]
|
||||
- **Trigger**: [When it applies]
|
||||
- **Instruction**: [What to do]
|
||||
- **Added after**: Iteration N - [What happened]
|
||||
```
|
||||
|
||||
### Refinement
|
||||
|
||||
If a sign isn't working:
|
||||
- Make the trigger more specific
|
||||
- Make the instruction clearer
|
||||
- Add examples
|
||||
|
||||
### Retirement
|
||||
|
||||
Signs can be removed when:
|
||||
- The underlying issue is fixed at a deeper level
|
||||
- The sign is no longer relevant
|
||||
- The sign is causing more problems than it solves
|
||||
|
||||
## Example Signs Library
|
||||
|
||||
### Security
|
||||
|
||||
```markdown
|
||||
### Sign: Sanitize All Input
|
||||
- **Trigger**: Any user-provided data
|
||||
- **Instruction**: Use parameterized queries, escape HTML, validate types
|
||||
- **Example**: `db.query("SELECT * FROM users WHERE id = ?", [userId])`
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```markdown
|
||||
### Sign: Graceful Degradation
|
||||
- **Trigger**: External service calls
|
||||
- **Instruction**: Always have a fallback for when services are unavailable
|
||||
- **Example**: Cache results, provide default values, show friendly errors
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```markdown
|
||||
### Sign: Test the Unhappy Path
|
||||
- **Trigger**: Writing tests for new functionality
|
||||
- **Instruction**: Include tests for error cases, edge cases, and invalid input
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```markdown
|
||||
### Sign: Explain Why, Not What
|
||||
- **Trigger**: Writing comments
|
||||
- **Instruction**: Comments should explain reasoning, not describe obvious code
|
||||
- **Example**: `// Using retry because API is flaky under load` not `// Call the API`
|
||||
```
|
||||
|
||||
## Automatic Sign Detection
|
||||
|
||||
The Ralph hooks can automatically detect some patterns and suggest signs:
|
||||
|
||||
- **Thrashing**: Same file edited many times → "Step back and reconsider"
|
||||
- **Repeated errors**: Same test failing → "Check the test assumptions"
|
||||
- **Large changes**: Big diffs → "Consider smaller increments"
|
||||
|
||||
These are logged in `.ralph/failures.md` and can be promoted to guardrails.
|
||||
|
||||
## Using Signs Effectively
|
||||
|
||||
### Do
|
||||
|
||||
- Keep signs concise and actionable
|
||||
- Include concrete examples
|
||||
- Update signs when they're not working
|
||||
- Remove outdated signs
|
||||
|
||||
### Don't
|
||||
|
||||
- Add signs for every minor issue
|
||||
- Make signs too vague ("be careful")
|
||||
- Ignore signs that keep triggering
|
||||
- Let the guardrails file become overwhelming
|
||||
|
||||
## Integration with Ralph
|
||||
|
||||
Signs are:
|
||||
1. Stored in `.ralph/guardrails.md`
|
||||
2. Injected into context at the start of each iteration
|
||||
3. Referenced when relevant situations arise
|
||||
4. Updated based on observed failures
|
||||
|
||||
The goal is a self-improving system where each failure makes future iterations smarter.
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"version": 1,
|
||||
"project": "Mobile App File Size Refactor",
|
||||
"overview": "Refactor the existing React Native (Expo) mobile app to keep most files within ~300-400 lines by splitting into smaller modules without changing functionality.",
|
||||
"goals": [
|
||||
"Reduce oversized files to a target range of ~300-400 lines where practical",
|
||||
"Improve maintainability by splitting files into clearer layers (ui/components, hooks, services, utils)",
|
||||
"Preserve existing behavior and UI"
|
||||
],
|
||||
"nonGoals": [
|
||||
"No functional changes",
|
||||
"No UI changes",
|
||||
"No new product features"
|
||||
],
|
||||
"successMetrics": [
|
||||
"All files are within target size or documented as justified exceptions",
|
||||
"Refactor passes existing tests, lint, and typecheck"
|
||||
],
|
||||
"openQuestions": [
|
||||
"Which specific files currently exceed 400 lines and need priority refactors?",
|
||||
"Are there any existing architectural constraints that limit splitting (e.g., circular deps)?"
|
||||
],
|
||||
"stack": {
|
||||
"framework": "React Native (Expo)",
|
||||
"hosting": "App stores via Expo/EAS",
|
||||
"database": "Existing app data layer (no changes)",
|
||||
"auth": "Existing app auth (no changes)"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"path": "(existing)",
|
||||
"name": "App Routes",
|
||||
"purpose": "No route changes; refactor only"
|
||||
}
|
||||
],
|
||||
"uiNotes": [
|
||||
"No UI changes; keep visual output identical",
|
||||
"Split view logic into smaller components without altering layout"
|
||||
],
|
||||
"dataModel": [
|
||||
{
|
||||
"entity": "N/A",
|
||||
"fields": []
|
||||
}
|
||||
],
|
||||
"importFormat": {
|
||||
"description": "Not applicable",
|
||||
"example": {}
|
||||
},
|
||||
"rules": [
|
||||
"Target file size is ~300-400 lines; exceptions must include justification",
|
||||
"Refactor should follow layer-based split: ui/components, hooks, services, utils",
|
||||
"No behavior changes; identical inputs must yield identical outputs"
|
||||
],
|
||||
"qualityGates": [
|
||||
"npm test",
|
||||
"npm run lint",
|
||||
"npm run typecheck"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "US-001",
|
||||
"title": "Inventory oversized files and refactor plan",
|
||||
"status": "done",
|
||||
"dependsOn": [],
|
||||
"description": "As a developer, I want an inventory of oversized files and a planned split strategy so refactors are systematic and safe.",
|
||||
"acceptanceCriteria": [
|
||||
"Produce a list of files > 400 lines with line counts",
|
||||
"For each file, define target split into layers (ui/components, hooks, services, utils)",
|
||||
"Example: a 900-line screen file -> screen container + UI subcomponents + hooks + service module",
|
||||
"Negative case: a file under 300 lines is not split unless a clear modularization need is documented"
|
||||
],
|
||||
"startedAt": "2026-01-25T20:40:12.238126+00:00",
|
||||
"completedAt": "2026-01-25T20:47:05.362525+00:00",
|
||||
"updatedAt": "2026-01-25T20:47:05.362425+00:00"
|
||||
},
|
||||
{
|
||||
"id": "US-002",
|
||||
"title": "Refactor screen-level files into layer-based modules",
|
||||
"status": "done",
|
||||
"dependsOn": [
|
||||
"US-001"
|
||||
],
|
||||
"description": "As a developer, I want oversized screen files split into smaller modules so each file stays around 300-400 lines.",
|
||||
"acceptanceCriteria": [
|
||||
"Split screen files into container + UI components + hooks + services as needed",
|
||||
"Example: screen logic moves into `hooks/use<Screen>.ts` and data calls into `services/<screen>.ts`",
|
||||
"Negative case: moving a function that creates circular dependencies is rejected and alternative split is used",
|
||||
"All screens render identically before and after refactor"
|
||||
],
|
||||
"startedAt": "2026-01-25T20:47:07.436262+00:00",
|
||||
"completedAt": "2026-01-25T21:07:57.526689+00:00",
|
||||
"updatedAt": "2026-01-25T21:07:57.526580+00:00"
|
||||
},
|
||||
{
|
||||
"id": "US-003",
|
||||
"title": "Refactor oversized shared components",
|
||||
"status": "done",
|
||||
"dependsOn": [
|
||||
"US-001"
|
||||
],
|
||||
"description": "As a developer, I want oversized shared components split into smaller pieces to improve clarity and reusability.",
|
||||
"acceptanceCriteria": [
|
||||
"Split shared UI components into smaller subcomponents and helper modules",
|
||||
"Example: extract nested UI blocks into `components/<Component>/SubComponent.tsx`",
|
||||
"Negative case: do not extract one-off UI fragments that would reduce cohesion",
|
||||
"No change to public component API unless required for split"
|
||||
],
|
||||
"startedAt": "2026-01-26T14:09:11.801117+00:00",
|
||||
"completedAt": "2026-01-26T14:22:30.296005+00:00",
|
||||
"updatedAt": "2026-01-26T14:22:30.295815+00:00"
|
||||
},
|
||||
{
|
||||
"id": "US-004",
|
||||
"title": "Refactor oversized hooks/services/utils",
|
||||
"status": "done",
|
||||
"dependsOn": [
|
||||
"US-001"
|
||||
],
|
||||
"description": "As a developer, I want oversized logic files split into smaller, focused modules for readability.",
|
||||
"acceptanceCriteria": [
|
||||
"Split large hooks into smaller hooks or helper functions in utils",
|
||||
"Example: a 500-line hook becomes a main hook + `utils/<hook>-helpers.ts`",
|
||||
"Negative case: do not split logic in a way that increases public API surface without justification",
|
||||
"All previous exports remain available or are re-exported from the original entry point"
|
||||
],
|
||||
"startedAt": "2026-01-26T14:22:32.363863+00:00",
|
||||
"completedAt": "2026-01-26T14:28:08.569075+00:00",
|
||||
"updatedAt": "2026-01-26T14:28:08.568972+00:00"
|
||||
},
|
||||
{
|
||||
"id": "US-005",
|
||||
"title": "Validate refactor quality gates",
|
||||
"status": "done",
|
||||
"dependsOn": [
|
||||
"US-002",
|
||||
"US-003",
|
||||
"US-004"
|
||||
],
|
||||
"description": "As a developer, I want automated checks to pass to ensure the refactor preserved behavior.",
|
||||
"acceptanceCriteria": [
|
||||
"Run `npm test`, `npm run lint`, and `npm run typecheck` successfully",
|
||||
"Example: all tests pass without modifications to test expectations",
|
||||
"Negative case: if a check fails, refactor is incomplete and must be fixed before completion"
|
||||
],
|
||||
"startedAt": "2026-01-26T14:28:10.644821+00:00",
|
||||
"completedAt": "2026-01-26T14:33:54.447192+00:00",
|
||||
"updatedAt": "2026-01-26T14:33:54.446892+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
# Agents Documentation
|
||||
|
||||
This file contains important project-specific context for Claude Code agents working on this repository.
|
||||
|
||||
## Redemption Code System
|
||||
|
||||
**IMPORTANT: Codes can only be redeemed on the website, NOT in the mobile app.**
|
||||
|
||||
Apple's App Store guidelines require that any premium features purchased within an iOS app must go through In-App Purchase. Adding a redemption code feature to the mobile app would violate these guidelines and risk app rejection.
|
||||
|
||||
### Overview
|
||||
Promotional codes that grant users premium access without going through RevenueCat/App Store subscription flow.
|
||||
|
||||
### Database Schema
|
||||
|
||||
**`redemption_codes` table:**
|
||||
- `id` (UUID) - Primary key
|
||||
- `code` (TEXT, UNIQUE) - The redemption code
|
||||
- `description` (TEXT) - Optional description
|
||||
- `max_uses` (INTEGER) - Maximum number of redemptions allowed
|
||||
- `current_uses` (INTEGER) - Current number of redemptions
|
||||
- `expires_at` (TIMESTAMPTZ) - Optional expiration date
|
||||
- `is_active` (BOOLEAN) - Whether the code can be redeemed
|
||||
- `created_by` (UUID) - Admin who created the code
|
||||
- `created_at` (TIMESTAMPTZ)
|
||||
- `updated_at` (TIMESTAMPTZ)
|
||||
|
||||
**`code_redemptions` table:**
|
||||
- `id` (UUID) - Primary key
|
||||
- `code_id` (UUID) - Reference to redemption_codes
|
||||
- `user_id` (UUID) - User who redeemed the code
|
||||
- `redeemed_at` (TIMESTAMPTZ)
|
||||
- UNIQUE constraint on (code_id, user_id) - Each user can only redeem a code once
|
||||
|
||||
### Components
|
||||
|
||||
1. **Admin UI** (`apps/admin/src/pages/RedemptionCodesPage.tsx`)
|
||||
- Super admins can generate codes
|
||||
- View all codes with usage stats
|
||||
- Toggle active/inactive
|
||||
- View who redeemed each code
|
||||
- Delete codes
|
||||
|
||||
2. **Edge Function** (`apps/supabase/functions/redeem-code/index.ts`)
|
||||
- **Unauthenticated** - accepts email + code in request body
|
||||
- Calls the `redeem_code_by_email()` database function
|
||||
- Returns success/error response
|
||||
|
||||
3. **Database Function** (`redeem_code_by_email(p_email TEXT, p_code TEXT)`)
|
||||
- Atomic operation with row locking
|
||||
- Looks up user by email (must have signed up in app first)
|
||||
- Validates: code exists, is active, not expired, has uses remaining
|
||||
- Checks user hasn't already redeemed
|
||||
- Records redemption in `code_redemptions`
|
||||
- Increments `current_uses`
|
||||
- Sets `profiles.is_premium = true`
|
||||
|
||||
4. **Web UI** (`apps/web/app/redeem/page.tsx`)
|
||||
- Public page at `/redeem`
|
||||
- User enters email + redemption code
|
||||
- Calls edge function via fetch
|
||||
- Shows success or error message
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The web app requires:
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` - Supabase project URL for API calls
|
||||
|
||||
### DO NOT
|
||||
|
||||
- **DO NOT** add redemption code UI to the mobile app
|
||||
- **DO NOT** create any in-app mechanism to bypass App Store purchases
|
||||
- **DO NOT** expose the redeem endpoint in mobile app Supabase calls
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: architecture-reviewer
|
||||
description: Review cross-cutting or hard-to-reverse architecture decisions. Use only when multiple system boundaries or long-lived contracts change.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: opus
|
||||
---
|
||||
|
||||
Evaluate the proposed decision against current boundaries, migration path,
|
||||
operability, reversibility, and verification. Prefer the smallest coherent change.
|
||||
Return trade-offs and a recommendation; do not implement.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: database-reviewer
|
||||
description: Review migrations, RLS, Edge Functions, and query boundaries. Use for database-affecting diffs, not ordinary UI changes.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Read `apps/supabase/AGENTS.md` and the migration safety document. Review local
|
||||
migration tracking, idempotency, authorization/RLS, environment portability,
|
||||
rollback implications, types, and integration coverage. Never mutate a remote
|
||||
database. Return prioritized, line-specific findings.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: implementation
|
||||
description: Implement a bounded Sauci product or harness task. Default build agent for ordinary changes after scope and acceptance criteria are known.
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Read root and nearest scoped `AGENTS.md`. Implement only the requested behavior,
|
||||
update the maintained contract documentation, and add focused tests. Use the
|
||||
repository skills for database, local-stack, simulator, and verification
|
||||
procedures. Stop for missing authority or a materially ambiguous product choice.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: repo-scout
|
||||
description: Locate ownership, callers, contracts, tests, and existing patterns before implementation. Use for bounded read-only discovery, not edits or design decisions.
|
||||
tools: Read, Glob, Grep, Bash
|
||||
model: haiku
|
||||
---
|
||||
|
||||
Map only the requested surface. Read `AGENTS.md` and relevant scoped context.
|
||||
Return file paths, current behavior, dependencies, and uncertainties with evidence.
|
||||
Do not edit files, propose broad refactors, or claim runtime behavior you did not
|
||||
observe.
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
name: security-architect
|
||||
description: Use this agent when you need to review code, architecture, or configurations for security vulnerabilities, threats, and best practices. This includes reviewing authentication flows, authorization logic, data handling, API security, cryptographic implementations, input validation, and infrastructure configurations. Examples:\n\n<example>\nContext: User has just written an authentication function.\nuser: "Please implement a login function that validates user credentials"\nassistant: "Here is the login function implementation:"\n<function implementation provided>\nassistant: "Now let me use the security-architect agent to review this authentication code for security vulnerabilities."\n</example>\n\n<example>\nContext: User is working on API endpoints that handle sensitive data.\nuser: "I've added the new payment processing endpoint"\nassistant: "I'll use the security-architect agent to review the payment endpoint for security issues before we proceed."\n</example>\n\n<example>\nContext: User asks for a security review of recent changes.\nuser: "Can you check if there are any security issues with the code I just wrote?"\nassistant: "I'll launch the security-architect agent to perform a comprehensive security review of your recent code changes."\n</example>\n\n<example>\nContext: User has implemented data storage logic.\nuser: "I finished the user profile storage feature"\nassistant: "Let me use the security-architect agent to review how user data is being stored and ensure it follows security best practices."\n</example>
|
||||
model: opus
|
||||
color: red
|
||||
---
|
||||
|
||||
You are an elite Security Architect with 20+ years of experience in application security, penetration testing, and secure system design. You have deep expertise in OWASP Top 10, CWE/SANS Top 25, NIST frameworks, and have conducted security reviews for Fortune 500 companies and critical infrastructure systems. You think like an attacker while designing like a defender.
|
||||
|
||||
## Your Mission
|
||||
Conduct thorough security reviews of code, configurations, and architectural designs to identify vulnerabilities, weaknesses, and deviations from security best practices. Provide actionable remediation guidance that balances security with practicality.
|
||||
|
||||
## Review Methodology
|
||||
|
||||
### 1. Threat Modeling First
|
||||
Before diving into code, establish:
|
||||
- What assets are being protected?
|
||||
- Who are the potential threat actors?
|
||||
- What is the attack surface?
|
||||
- What are the trust boundaries?
|
||||
|
||||
### 2. Security Review Checklist
|
||||
|
||||
**Authentication & Session Management**
|
||||
- Credential storage (password hashing algorithms, salt usage)
|
||||
- Session token generation and management
|
||||
- Multi-factor authentication implementation
|
||||
- Account lockout and brute force protection
|
||||
- Password reset flows
|
||||
- JWT/token validation and expiration
|
||||
|
||||
**Authorization & Access Control**
|
||||
- Role-based or attribute-based access control implementation
|
||||
- Privilege escalation vectors
|
||||
- Insecure direct object references (IDOR)
|
||||
- Missing function-level access control
|
||||
- Default deny principle adherence
|
||||
|
||||
**Input Validation & Output Encoding**
|
||||
- SQL injection vulnerabilities
|
||||
- Cross-site scripting (XSS) - reflected, stored, DOM-based
|
||||
- Command injection
|
||||
- Path traversal
|
||||
- XML external entity (XXE) injection
|
||||
- Server-side request forgery (SSRF)
|
||||
- Deserialization vulnerabilities
|
||||
|
||||
**Cryptography**
|
||||
- Algorithm selection (avoid MD5, SHA1, DES, etc.)
|
||||
- Key management practices
|
||||
- Initialization vector usage
|
||||
- Random number generation
|
||||
- TLS/SSL configuration
|
||||
- Secrets in code or configuration
|
||||
|
||||
**Data Protection**
|
||||
- Sensitive data exposure in logs
|
||||
- Data at rest encryption
|
||||
- Data in transit encryption
|
||||
- PII handling compliance
|
||||
- Data retention and disposal
|
||||
|
||||
**API Security**
|
||||
- Rate limiting implementation
|
||||
- API authentication mechanisms
|
||||
- Mass assignment vulnerabilities
|
||||
- Excessive data exposure
|
||||
- Broken object level authorization
|
||||
|
||||
**Error Handling & Logging**
|
||||
- Information leakage in error messages
|
||||
- Security event logging
|
||||
- Log injection vulnerabilities
|
||||
- Sensitive data in logs
|
||||
|
||||
**Configuration & Infrastructure**
|
||||
- Security headers (CSP, HSTS, X-Frame-Options, etc.)
|
||||
- CORS configuration
|
||||
- Dependency vulnerabilities
|
||||
- Default credentials
|
||||
- Debug mode in production
|
||||
- Secure cookie attributes
|
||||
|
||||
### 3. Risk Classification
|
||||
Classify each finding using:
|
||||
- **Critical**: Immediate exploitation possible, severe impact (RCE, auth bypass, data breach)
|
||||
- **High**: Significant vulnerability requiring prompt attention
|
||||
- **Medium**: Notable weakness that should be addressed
|
||||
- **Low**: Minor issue or defense-in-depth improvement
|
||||
- **Informational**: Best practice recommendation
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your security review as follows:
|
||||
|
||||
```
|
||||
## Security Review Summary
|
||||
- **Scope**: What was reviewed
|
||||
- **Risk Level**: Overall assessment (Critical/High/Medium/Low)
|
||||
- **Critical Findings**: Count
|
||||
- **Total Findings**: Count
|
||||
|
||||
## Findings
|
||||
|
||||
### [SEVERITY] Finding Title
|
||||
**Location**: File/line or component
|
||||
**Vulnerability Type**: CWE ID if applicable
|
||||
**Description**: Clear explanation of the vulnerability
|
||||
**Attack Scenario**: How this could be exploited
|
||||
**Remediation**: Specific fix with code example
|
||||
**References**: Relevant documentation or standards
|
||||
|
||||
## Positive Observations
|
||||
Note security controls that are properly implemented.
|
||||
|
||||
## Recommendations
|
||||
Prioritized list of security improvements.
|
||||
```
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
1. **Be Thorough**: Check every input, output, and trust boundary. Attackers only need one weakness.
|
||||
|
||||
2. **Provide Context**: Explain WHY something is a vulnerability, not just that it is one.
|
||||
|
||||
3. **Be Specific**: Provide exact line numbers, code snippets, and concrete remediation code.
|
||||
|
||||
4. **Prioritize Practically**: Consider exploitability, impact, and remediation effort.
|
||||
|
||||
5. **Avoid False Positives**: Verify findings before reporting. If uncertain, clearly state your confidence level.
|
||||
|
||||
6. **Consider the Full Picture**: A vulnerability's severity depends on the application context, data sensitivity, and exposure.
|
||||
|
||||
7. **Stay Current**: Reference modern attack techniques and defenses. Security evolves rapidly.
|
||||
|
||||
8. **Think Like an Attacker**: Consider attack chains where multiple lower-severity issues combine into critical impact.
|
||||
|
||||
9. **Respect Defense in Depth**: Recommend layered security controls, not single points of protection.
|
||||
|
||||
10. **Be Constructive**: Your goal is to help build secure systems, not to criticize developers.
|
||||
|
||||
## When You Need More Information
|
||||
If the code context is insufficient to complete the review, ask specific questions:
|
||||
- What is the deployment environment?
|
||||
- What is the sensitivity of the data being handled?
|
||||
- Are there compensating controls not visible in this code?
|
||||
- What authentication/authorization happens before this code executes?
|
||||
|
||||
## Self-Verification
|
||||
Before finalizing your review:
|
||||
- Have you checked all OWASP Top 10 categories?
|
||||
- Have you considered the business context?
|
||||
- Are your remediation suggestions practical and complete?
|
||||
- Have you prioritized findings appropriately?
|
||||
- Have you provided enough detail for developers to fix issues?
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: security-reviewer
|
||||
description: Review auth, RLS, cryptography, secrets, payments, privileged MCP, or destructive operations. Use only when these risk triggers are present.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: opus
|
||||
---
|
||||
|
||||
Perform a scoped threat-boundary review of the changed surface. Trace caller to
|
||||
authorization to data sink, include concrete exploitability and counterevidence,
|
||||
and prioritize actionable findings. Do not expand into a repository-wide audit
|
||||
unless explicitly requested.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: test-writer
|
||||
description: Add focused regression or contract tests for a known behavior. Use when test ownership and expected behavior are already clear.
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Test the intended observable contract at the lowest useful layer. Preserve real
|
||||
auth and authorization boundaries. Do not weaken assertions to accommodate a
|
||||
failure. Run the focused test and report exact evidence.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: verifier
|
||||
description: Independently drive already-implemented behavior and return an evidence-backed verdict. Use before a PR or when asked whether a change actually works.
|
||||
tools: Read, Bash, Glob, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Remain read-only. Follow `.codex/skills/verify/SKILL.md`, exercise the running
|
||||
product, and capture evidence under `evidence/`. Return expected versus observed,
|
||||
artifact paths, and `works` or `broken`. Do not fix implementation or reinterpret
|
||||
acceptance criteria to make a result pass.
|
||||
@@ -0,0 +1,310 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <category name> [--all] [--explicit] [--apply] [--limit=100]
|
||||
description: Review and automatically fix issues with questions (text, targeting, intensity, props, duplicates)
|
||||
---
|
||||
|
||||
# Question Fix Agent
|
||||
|
||||
Fix questions in category: $ARGUMENTS
|
||||
|
||||
## Task Overview
|
||||
|
||||
Analyze questions across all packs in a category and apply fixes for issues across 6 dimensions:
|
||||
1. **Text Phrasing** - Improve wording, remove wishy-washy language
|
||||
2. **Audience Targeting** - Fix couple/initiator gender targeting
|
||||
3. **Intensity Levels** - Correct misassigned intensity ratings
|
||||
4. **Required Props** - Identify missing physical props/accessories
|
||||
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
|
||||
6. **Deletions** - Remove true duplicates, unsafe, or broken questions
|
||||
|
||||
## Step 1: Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- **Category identifier**: Category name (partial match) OR category ID (UUID)
|
||||
- **--all**: Flag indicating the agent should work through all categories sequentially
|
||||
- **--explicit**: Flag indicating this is an explicit/adult category (auto-detected from category.is_explicit if not specified)
|
||||
- **--apply**: If set, apply fixes automatically after review. If not set, just show suggestions.
|
||||
- **--limit**: Max questions to analyze per category (default: 100)
|
||||
|
||||
## Step 2: Fetch Data
|
||||
|
||||
Query the database to get the category and its questions:
|
||||
|
||||
```sql
|
||||
-- Get category info
|
||||
SELECT id, name
|
||||
FROM categories
|
||||
WHERE id::text = '<category_id>' OR name ILIKE '%<category_name>%'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get all packs in the category
|
||||
SELECT p.id, p.name, p.is_explicit
|
||||
FROM question_packs p
|
||||
WHERE p.category_id = '<category_id>'
|
||||
ORDER BY p.name;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get questions for all packs in the category (include inverse_of for pair detection)
|
||||
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of, p.name as pack_name
|
||||
FROM questions q
|
||||
JOIN question_packs p ON q.pack_id = p.id
|
||||
WHERE p.category_id = '<category_id>' AND q.deleted_at IS NULL
|
||||
ORDER BY p.name, q.created_at
|
||||
LIMIT <limit>;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Get existing props catalog for consistency
|
||||
SELECT DISTINCT unnest(required_props) as prop
|
||||
FROM questions
|
||||
WHERE required_props IS NOT NULL
|
||||
ORDER BY prop;
|
||||
```
|
||||
|
||||
If **--all** flag is set, first list all categories:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories ORDER BY name;
|
||||
```
|
||||
Then process each category sequentially.
|
||||
|
||||
## Step 3: Analyze Each Question
|
||||
|
||||
For each question, evaluate against these criteria:
|
||||
|
||||
### 3.1 Text Analysis
|
||||
|
||||
**Language Rules:**
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise - ideal length is 5-12 words
|
||||
|
||||
**Tone Rules (based on is_explicit):**
|
||||
|
||||
For EXPLICIT packs:
|
||||
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
|
||||
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
For NON-EXPLICIT packs:
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- Flag explicit content for removal
|
||||
|
||||
**Partner Text Rules:**
|
||||
- NULL for symmetric activities (both partners do the same thing together)
|
||||
- FILLED for asymmetric activities (one does something to/for the other)
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER experiences
|
||||
- Make partner_text APPEALING - frame receiver's experience positively
|
||||
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
|
||||
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
||||
- NOT: "Moan for your partner" (sounds forced)
|
||||
|
||||
### 3.2 Intensity Analysis
|
||||
|
||||
Check if current intensity matches the activity:
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
|
||||
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
|
||||
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
|
||||
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
|
||||
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
|
||||
|
||||
### 3.3 Targeting Analysis
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
|
||||
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
|
||||
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
|
||||
### 3.4 Props Analysis
|
||||
|
||||
**Rules:**
|
||||
- Props are physical items/accessories, not body parts or acts
|
||||
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
|
||||
- Try to avoid generics such as "toy" (unless the activity can be done with any toy)
|
||||
- Reuse existing prop names from the catalog when possible
|
||||
- If no props are required, set to null
|
||||
|
||||
### 3.5 Inverse Linking Analysis
|
||||
|
||||
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
|
||||
|
||||
**Detection Algorithm:**
|
||||
1. For each asymmetric question (has partner_text), look for its inverse:
|
||||
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
|
||||
2. Check if the `inverse_of` column is correctly set:
|
||||
- One question in the pair should be the "primary" (inverse_of = NULL)
|
||||
- The other should be the "inverse" (inverse_of = primary's UUID)
|
||||
|
||||
**Fixing Missing Links:**
|
||||
- If inverse pair exists but neither has `inverse_of` set:
|
||||
- Set the first one (by created_at) as primary (inverse_of = NULL)
|
||||
- Set the second one's inverse_of = primary's UUID
|
||||
- If inverse pair exists but both have `inverse_of = NULL`:
|
||||
- Choose one as primary, link the other
|
||||
- If a question has `inverse_of` pointing to a non-existent question:
|
||||
- Find the actual inverse and fix the link, OR set to NULL if no inverse exists
|
||||
|
||||
**Important:**
|
||||
- Only asymmetric questions (with partner_text) should have inverse pairs
|
||||
- Symmetric questions (partner_text = NULL) should have inverse_of = NULL
|
||||
- This fixes the database structure, NOT the question content
|
||||
|
||||
### 3.6 Deletion Analysis
|
||||
|
||||
**Deletion Categories:**
|
||||
- `duplicate`: Same `text` AND same initiator as another question
|
||||
- `redundant`: Minor variation with no added value
|
||||
- `off-tone`: Violates explicit vs non-explicit rules
|
||||
- `unsafe`: Coercion, minors, illegal acts, or consent violations
|
||||
- `too-vague`: Not actionable or not a clear proposal
|
||||
- `broken`: Grammar too broken to repair without inventing content
|
||||
- `off-topic`: Not relevant to a couples intimacy app
|
||||
|
||||
**CRITICAL: Inverse Questions are NOT Duplicates**
|
||||
|
||||
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
|
||||
|
||||
```
|
||||
Question A:
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
|
||||
Question B (INVERSE - NOT A DUPLICATE):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
```
|
||||
|
||||
These are intentionally paired so both partners get asked about both roles. When reviewing:
|
||||
- If you see two questions where one's `text` matches the other's `partner_text`, they are a VALID INVERSE PAIR
|
||||
- Do NOT flag inverse pairs as duplicates or redundant
|
||||
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`) with the SAME initiator role
|
||||
|
||||
**Rules:**
|
||||
- Prefer edits over deletions when possible
|
||||
- For true duplicates: delete the weaker/less specific version
|
||||
- Do NOT delete for minor wording issues
|
||||
|
||||
## Step 4: Output Results
|
||||
|
||||
Present findings grouped by fix type:
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Issues Found | Will Fix |
|
||||
|----------|--------------|----------|
|
||||
| Text fixes | X | X |
|
||||
| Intensity fixes | X | X |
|
||||
| Targeting fixes | X | X |
|
||||
| Props fixes | X | X |
|
||||
| Inverse linking fixes | X | X |
|
||||
| Deletions | X | X |
|
||||
| No changes needed | X | - |
|
||||
|
||||
### Detailed Changes
|
||||
|
||||
For each question needing changes, show:
|
||||
|
||||
```
|
||||
**Question ID:** <id>
|
||||
**Current:** <text> / <partner_text or null>
|
||||
**Current inverse_of:** <uuid or null>
|
||||
**Issues:**
|
||||
- Text: <issue description>
|
||||
- Intensity: <current> -> <suggested> (<reason>)
|
||||
- Targeting: <issue description>
|
||||
- Props: <current> -> <suggested>
|
||||
- Inverse Link: <missing/broken> - <inverse question id if found>
|
||||
- DELETE: <category> - <reason>
|
||||
|
||||
**Fix:**
|
||||
- Text: "<new text>"
|
||||
- Partner Text: "<new partner_text>" or null
|
||||
- Intensity: <new intensity>
|
||||
- Couple Targets: <new allowed_couple_genders or null>
|
||||
- Initiator Targets: <new target_user_genders or null>
|
||||
- Required Props: <new required_props or null>
|
||||
- inverse_of: <uuid or null>
|
||||
```
|
||||
|
||||
## Step 5: Apply Fixes (if --apply flag)
|
||||
|
||||
If --apply flag is set, execute the SQL updates:
|
||||
|
||||
```sql
|
||||
-- Text/Partner Text/Intensity fixes
|
||||
UPDATE questions SET
|
||||
text = '<new_text>',
|
||||
partner_text = '<new_partner_text>',
|
||||
intensity = <new_intensity>
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Targeting fixes
|
||||
UPDATE questions SET
|
||||
allowed_couple_genders = ARRAY['...'] or NULL,
|
||||
target_user_genders = ARRAY['...'] or NULL
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Props fixes
|
||||
UPDATE questions SET
|
||||
required_props = ARRAY['...'] or NULL
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Inverse linking fixes
|
||||
-- Link inverse question to its primary
|
||||
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
|
||||
-- Or clear a broken inverse_of link
|
||||
UPDATE questions SET inverse_of = NULL WHERE id = '<question_id>';
|
||||
|
||||
-- Deletions (soft delete)
|
||||
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
|
||||
```
|
||||
|
||||
After applying, report:
|
||||
- X questions updated
|
||||
- X questions deleted
|
||||
- Any errors encountered
|
||||
|
||||
If --apply is NOT set, output the SQL statements for manual review.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/fix-questions "Kink" # Review all questions in "Kink" category
|
||||
/fix-questions "Romantic Classics" --apply # Review and apply fixes to category
|
||||
/fix-questions --all # Process all categories sequentially
|
||||
/fix-questions --all --apply # Process and fix all categories
|
||||
/fix-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask for clarification if category name matches multiple categories
|
||||
- DO group similar issues together for batch fixes
|
||||
- DO prioritize safety issues (delete unsafe content first)
|
||||
- DO preserve the core intent of questions when suggesting text fixes
|
||||
- DO verify inverse pairs exist before flagging duplicates
|
||||
- DO detect and link inverse pairs using the inverse_of column
|
||||
- DON'T change questions that are already well-formed
|
||||
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
|
||||
- DON'T create new prop names when existing ones match
|
||||
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
|
||||
- DON'T forget to set inverse_of for detected inverse pairs - this ensures accurate unique question counts
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo,text_answer]
|
||||
description: Generate a question pack for Sauci following established content guidelines
|
||||
---
|
||||
|
||||
# Question Pack Generator
|
||||
|
||||
Generate a question pack: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, read the pack generation guidelines:
|
||||
@PACK_GENERATION_AGENT.md
|
||||
|
||||
## Current State
|
||||
|
||||
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
|
||||
- Existing packs in target category: Will query once category is identified
|
||||
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
|
||||
- Current question type distribution in target pack (if adding to existing): SELECT question_type, count(*) FROM questions WHERE pack_id = '[pack_id]' AND deleted_at IS NULL GROUP BY question_type
|
||||
|
||||
## Task
|
||||
|
||||
Generate a complete question pack following all Sauci content guidelines.
|
||||
|
||||
### Step 1: Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- **Pack name**: Required - the name for this pack
|
||||
- **Category**: Required - which category this belongs to (or "new" to create)
|
||||
- **Question count**: Optional - default 30, can be 25-50
|
||||
- **Premium**: Optional flag - if pack should be premium
|
||||
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo,text_answer`. Options: `swipe`, `who_likely`, `audio`, `photo`, `text_answer`
|
||||
|
||||
### Step 2: Understand Context
|
||||
|
||||
1. If category exists, query existing packs to understand what's there:
|
||||
- What packs already exist?
|
||||
- What question types are represented?
|
||||
- Where does this new pack fit?
|
||||
|
||||
2. Check if category is explicit:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
|
||||
```
|
||||
|
||||
3. Check the current type distribution if adding to an existing pack:
|
||||
```sql
|
||||
SELECT question_type, count(*) FROM questions
|
||||
WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
|
||||
GROUP BY question_type ORDER BY count DESC;
|
||||
```
|
||||
|
||||
### Step 3: Generate Questions
|
||||
|
||||
Follow these rules strictly:
|
||||
|
||||
#### 3.1 Core Language Rules
|
||||
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Questions are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise: 5-12 words ideal
|
||||
- Modern language for 25-40 year olds
|
||||
- Avoid cheesy/cliche phrasing (candlelit dinner, rose petals, bubble bath, Netflix and chill)
|
||||
|
||||
#### 3.2 Tone Rules (based on category's is_explicit flag)
|
||||
|
||||
**For EXPLICIT categories (is_explicit = true):**
|
||||
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
|
||||
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name), "Edge until they beg"
|
||||
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
**For NON-EXPLICIT categories (is_explicit = false):**
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
|
||||
#### 3.3 Question Types
|
||||
|
||||
Each question MUST have a `question_type`. The default mix should include variety across types.
|
||||
|
||||
**`swipe`** (default) - Standard yes/no/maybe proposal
|
||||
- The classic Sauci format
|
||||
- Can be symmetric or asymmetric (with partner_text + inverse)
|
||||
- Example: "Give your partner a back massage"
|
||||
|
||||
**`who_likely`** - "Who's more likely to..." format
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Both partners pick who they think is more likely
|
||||
- Match created when both answer
|
||||
- Format: "Who is more likely to [activity/trait]?"
|
||||
- Keep fun and lighthearted — these are conversation starters
|
||||
- Examples:
|
||||
- "Who is more likely to cry during a movie?"
|
||||
- "Who is more likely to plan a surprise date?"
|
||||
- "Who is more likely to fall asleep first?"
|
||||
|
||||
**`audio`** - Voice recording prompt
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Partners record a voice message in response
|
||||
- `config`: `{"max_duration_seconds": 60}` (default 60)
|
||||
- Great for: confessions, storytelling, impressions, compliments, singing
|
||||
- Format: prompts that invite a spoken response
|
||||
- Examples:
|
||||
- "Record your best impression of your partner"
|
||||
- "Describe your favourite memory together"
|
||||
- "Say something you've never told your partner"
|
||||
|
||||
**`photo`** - Photo prompt
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Partners take or share a photo in response
|
||||
- No special config needed
|
||||
- Great for: selfies, throwbacks, showing something, creative challenges
|
||||
- Format: prompts that invite a photo response
|
||||
- Examples:
|
||||
- "Share a photo from your camera roll that makes you think of your partner"
|
||||
- "Take a selfie showing how you feel right now"
|
||||
- "Share a screenshot of your last text about your partner"
|
||||
|
||||
**`text_answer`** - Written response prompt
|
||||
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
|
||||
- Partners type a written response
|
||||
- `config`: `{"max_length": 500}` (default 500)
|
||||
- Great for: reflections, love notes, confessions, creative writing, finish-the-sentence prompts
|
||||
- Writing can feel safer than speaking for vulnerable topics
|
||||
- Format: prompts that invite a thoughtful written response
|
||||
- Examples:
|
||||
- "Describe your partner in three words"
|
||||
- "Write a short love letter to your partner"
|
||||
- "Finish this sentence: When I'm with you I feel..."
|
||||
|
||||
#### 3.4 Type Distribution Guidelines
|
||||
|
||||
For a standard 30-question pack, aim for roughly:
|
||||
- **18-20 swipe** questions (the core experience)
|
||||
- **4-5 who_likely** questions
|
||||
- **2-3 audio** questions
|
||||
- **2-3 photo** questions
|
||||
- **2-3 text_answer** questions
|
||||
|
||||
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
|
||||
|
||||
#### 3.5 Partner Text Rules (swipe questions only)
|
||||
|
||||
- **NULL** for symmetric activities (both partners do the same thing together)
|
||||
- **Filled** for asymmetric activities (one does something to/for the other)
|
||||
- `text` = what the INITIATOR does
|
||||
- `partner_text` = what the RECEIVER experiences
|
||||
|
||||
**Partner Text Quality:**
|
||||
- Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
|
||||
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
|
||||
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
||||
- NOT: "Moan for your partner" (sounds forced, not natural)
|
||||
- BAD: "Receive oral from your partner" (clinical)
|
||||
- GOOD: "Let your partner pleasure you with their mouth" (enticing)
|
||||
|
||||
#### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
|
||||
|
||||
Non-swipe types (who_likely, audio, photo, text_answer) are ALWAYS symmetric — no inverse needed.
|
||||
|
||||
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
|
||||
|
||||
```
|
||||
Original (PRIMARY - inverse_of = NULL):
|
||||
id: <generated_uuid_1>
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
inverse_of: NULL
|
||||
|
||||
Inverse (SECONDARY - points to primary):
|
||||
id: <generated_uuid_2>
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
inverse_of: <generated_uuid_1>
|
||||
```
|
||||
|
||||
This ensures:
|
||||
1. Both partners are asked about both roles
|
||||
2. The database tracks which questions are inverses of each other
|
||||
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
|
||||
|
||||
#### 3.7 Intensity Guidelines
|
||||
|
||||
Each question still needs an `intensity` value (1-5) for backend filtering, even though it's not shown in the UI.
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
|
||||
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
|
||||
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
|
||||
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
|
||||
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
|
||||
|
||||
#### 3.8 Targeting Rules
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
- Options: `['male+male', 'female+male', 'female+female']` or subset
|
||||
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis (blowjob, handjob)
|
||||
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina (cunnilingus)
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone) -> null
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric swipe questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field
|
||||
- When in doubt, use null
|
||||
|
||||
**Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
|
||||
|
||||
#### 3.9 Required Props
|
||||
|
||||
Identify physical items/accessories needed:
|
||||
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
|
||||
- Props are NOT body parts or acts
|
||||
- Use lowercase, short, singular names
|
||||
- Reuse existing prop names from catalog when possible
|
||||
- Set to null if no props are required
|
||||
|
||||
### Step 4: Review & Validate
|
||||
|
||||
Before outputting, verify each question against this checklist:
|
||||
|
||||
**Language & Format:**
|
||||
- [ ] Uses "your partner" (no gendered pronouns)
|
||||
- [ ] Swipe questions are proposals, not interview questions
|
||||
- [ ] No wishy-washy language
|
||||
- [ ] No time-specific words
|
||||
- [ ] 5-12 words (concise)
|
||||
- [ ] No cheesy/cliche language
|
||||
- [ ] Varied sentence structures
|
||||
|
||||
**Question Types:**
|
||||
- [ ] who_likely questions use "Who is more likely to..." format
|
||||
- [ ] audio questions prompt a natural spoken response
|
||||
- [ ] photo questions prompt something visual and shareable
|
||||
- [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and no inverse
|
||||
- [ ] Type distribution is balanced (see 3.4)
|
||||
|
||||
**Structure:**
|
||||
- [ ] All asymmetric swipe questions have inverses (these are NOT duplicates - they're required pairs)
|
||||
- [ ] Partner text is appealing (not clinical)
|
||||
- [ ] Partner text frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
|
||||
**Accuracy:**
|
||||
- [ ] Intensity level matches the activity
|
||||
- [ ] Couple targeting is correct (or null for universal)
|
||||
- [ ] Initiator targeting is correct for asymmetric questions
|
||||
- [ ] Required props are identified
|
||||
- [ ] Considers same-sex couples
|
||||
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Fits within the category
|
||||
|
||||
### Step 5: Output
|
||||
|
||||
Provide the pack in SQL format ready for insertion:
|
||||
|
||||
```sql
|
||||
-- First, create the pack (get category_id from earlier query)
|
||||
INSERT INTO question_packs (name, description, category_id, is_premium)
|
||||
VALUES (
|
||||
'[Pack Name]',
|
||||
'[Description]',
|
||||
'[category_id]',
|
||||
false
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- Then insert questions
|
||||
-- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
|
||||
-- Swipe pair example
|
||||
WITH pair1_primary AS (
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
|
||||
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL)
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
|
||||
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', id
|
||||
FROM pair1_primary;
|
||||
|
||||
-- Symmetric swipe (no inverse needed)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
|
||||
|
||||
-- who_likely questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
|
||||
('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
|
||||
|
||||
-- audio questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
|
||||
('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL);
|
||||
|
||||
-- photo questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
|
||||
|
||||
-- text_answer questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL),
|
||||
('[pack_id]', 'Write a short love letter to your partner', NULL, 1, 'text_answer', '{"max_length": 500}', NULL);
|
||||
```
|
||||
|
||||
Also provide a summary table:
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total questions | X |
|
||||
| Unique question concepts | X (total - inverse count) |
|
||||
| Swipe questions | X |
|
||||
| who_likely questions | X |
|
||||
| audio questions | X |
|
||||
| photo questions | X |
|
||||
| text_answer questions | X |
|
||||
| Symmetric (null partner_text) | X |
|
||||
| Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
|
||||
| With couple targeting | X |
|
||||
| With initiator targeting | X |
|
||||
| With required props | X |
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/generate-pack "Testing the Waters" --category="Public Thrills" --count=30
|
||||
/generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
|
||||
/generate-pack "Staying Close" --category=new:"Long Distance" --count=30
|
||||
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask clarifying questions if the pack concept is unclear
|
||||
- DO check existing packs in the category first
|
||||
- DO create inverse questions for ALL asymmetric swipe content AND link them with inverse_of
|
||||
- DO include a mix of question types (swipe, who_likely, audio, photo)
|
||||
- DO vary sentence structure and openers
|
||||
- DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
|
||||
- DON'T use cheesy or cliche language
|
||||
- DON'T assume heterosexual couples
|
||||
- DON'T skip the inverse question requirement for asymmetric swipe questions
|
||||
- DON'T create inverses for who_likely, audio, photo, or text_answer questions (they're always symmetric)
|
||||
- DON'T forget to set question_type - every question needs one
|
||||
- DON'T use interview-style questions ("Would you like to...")
|
||||
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
allowed-tools: Read, Write, Edit, Bash
|
||||
argument-hint: [file-path] | [component-name]
|
||||
description: Generate comprehensive test suite with unit, integration, and edge case coverage
|
||||
---
|
||||
|
||||
# Generate Tests
|
||||
|
||||
Generate comprehensive test suite for: $ARGUMENTS
|
||||
|
||||
## Current Testing Setup
|
||||
|
||||
- Test framework: @package.json or @jest.config.js or @vitest.config.js (detect framework)
|
||||
- Existing tests: !`find . -name "*.test.*" -o -name "*.spec.*" | head -5`
|
||||
- Test coverage: !`npm run test:coverage 2>/dev/null || echo "No coverage script"`
|
||||
- Target file: @$ARGUMENTS (if file path provided)
|
||||
|
||||
## Task
|
||||
|
||||
I'll analyze the target code and create complete test coverage including:
|
||||
|
||||
1. Unit tests for individual functions and methods
|
||||
2. Integration tests for component interactions
|
||||
3. Edge case and error handling tests
|
||||
4. Mock implementations for external dependencies
|
||||
5. Test utilities and helpers as needed
|
||||
6. Performance and snapshot tests where appropriate
|
||||
|
||||
## Process
|
||||
|
||||
I'll follow these steps:
|
||||
|
||||
1. Analyze the target file/component structure
|
||||
2. Identify all testable functions, methods, and behaviors
|
||||
3. Examine existing test patterns in the project
|
||||
4. Create test files following project naming conventions
|
||||
5. Implement comprehensive test cases with proper setup/teardown
|
||||
6. Add necessary mocks and test utilities
|
||||
7. Verify test coverage and add missing test cases
|
||||
|
||||
## Test Types
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Individual function testing with various inputs
|
||||
- Component rendering and prop handling
|
||||
- State management and lifecycle methods
|
||||
- Utility function edge cases and error conditions
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Component interaction testing
|
||||
- API integration with mocked responses
|
||||
- Service layer integration
|
||||
- End-to-end user workflows
|
||||
|
||||
### Framework-Specific Tests
|
||||
|
||||
- **React**: Component testing with React Testing Library
|
||||
- **Vue**: Component testing with Vue Test Utils
|
||||
- **Angular**: Component and service testing with TestBed
|
||||
- **Node.js**: API endpoint and middleware testing
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Use descriptive test names that explain the behavior
|
||||
- Follow AAA pattern (Arrange, Act, Assert)
|
||||
- Group related tests with describe blocks
|
||||
- Use proper setup and teardown for test isolation
|
||||
|
||||
### Mock Strategy
|
||||
|
||||
- Mock external dependencies and API calls
|
||||
- Use factories for test data generation
|
||||
- Implement proper cleanup for async operations
|
||||
- Mock timers and dates for deterministic tests
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- Aim for 80%+ code coverage
|
||||
- Focus on critical business logic paths
|
||||
- Test both happy path and error scenarios
|
||||
- Include boundary value testing
|
||||
|
||||
I'll adapt to your project's testing framework (Jest, Vitest, Cypress, etc.) and follow established patterns.
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
|
||||
argument-hint: <pack-name-or-id> [--explicit] [--limit=100]
|
||||
description: Review and fix questions for text, targeting, intensity, props, and duplicates
|
||||
---
|
||||
|
||||
# Question Review Command
|
||||
|
||||
Review and validate questions: $ARGUMENTS
|
||||
|
||||
## Task Overview
|
||||
|
||||
Analyze questions for issues and suggest fixes across 6 dimensions:
|
||||
1. **Text Phrasing** - Improve wording, remove wishy-washy language
|
||||
2. **Audience Targeting** - Fix couple/initiator gender targeting
|
||||
3. **Intensity Levels** - Correct misassigned intensity ratings
|
||||
4. **Required Props** - Identify missing physical props/accessories
|
||||
5. **Inverse Linking** - Detect and link inverse question pairs using inverse_of column
|
||||
6. **Deletions** - Flag duplicates, redundant, or broken questions
|
||||
|
||||
## Step 1: Parse Arguments
|
||||
|
||||
Extract from $ARGUMENTS:
|
||||
- **Pack identifier**: Pack name (partial match) OR pack ID (UUID)
|
||||
- **--explicit**: Flag indicating this is an explicit/adult pack (default: false if not specified)
|
||||
- **--limit**: Max questions to analyze (default: 100)
|
||||
|
||||
## Step 2: Fetch Questions
|
||||
|
||||
Query the database:
|
||||
|
||||
```sql
|
||||
-- If pack name provided (include inverse_of for pair detection):
|
||||
SELECT q.id, q.text, q.partner_text, q.intensity, q.allowed_couple_genders, q.target_user_genders, q.required_props, q.inverse_of
|
||||
FROM questions q
|
||||
JOIN question_packs p ON q.pack_id = p.id
|
||||
WHERE p.name ILIKE '%<pack_name>%' AND q.deleted_at IS NULL
|
||||
ORDER BY q.created_at
|
||||
LIMIT <limit>;
|
||||
|
||||
-- If pack ID provided:
|
||||
SELECT id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of
|
||||
FROM questions
|
||||
WHERE pack_id = '<pack_id>' AND deleted_at IS NULL
|
||||
ORDER BY created_at
|
||||
LIMIT <limit>;
|
||||
```
|
||||
|
||||
Also fetch existing props for consistency:
|
||||
```sql
|
||||
SELECT DISTINCT unnest(required_props) as prop
|
||||
FROM questions
|
||||
WHERE required_props IS NOT NULL
|
||||
ORDER BY prop;
|
||||
```
|
||||
|
||||
## Step 3: Determine Content Type
|
||||
|
||||
Check if the pack is explicit:
|
||||
```sql
|
||||
SELECT p.name, c.is_explicit
|
||||
FROM question_packs p
|
||||
JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = '<pack_id>' OR p.name ILIKE '%<pack_name>%'
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
Use this to set the content type rules below.
|
||||
|
||||
## Step 4: Analyze Questions
|
||||
|
||||
For each question, evaluate against these criteria:
|
||||
|
||||
### 4.1 Text Analysis
|
||||
|
||||
**Language Rules:**
|
||||
- ALWAYS use "your partner" - NEVER use "me", "I", "you" (as receiver), "him", "her", or gendered pronouns
|
||||
- Cards are PROPOSALS, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||
- Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||
- No time-specific words - NO "tonight", "now", "today", "right now"
|
||||
- Keep concise - ideal length is 5-12 words
|
||||
|
||||
**Tone Rules (based on explicit flag):**
|
||||
|
||||
For EXPLICIT packs:
|
||||
- Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms)
|
||||
- BUT keep crude/specific terms when they ARE the activity: "Cum on your partner's tits" (cum is the act), "Use a cock ring" (that's the name)
|
||||
- Rule: crude terms for specific acts/objects, tasteful terms for general sex/oral
|
||||
- NEVER sanitize "cum" to "come" - they have different meanings
|
||||
|
||||
For NON-EXPLICIT packs:
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- Keep language romantic, sensual, or playful without graphic terms
|
||||
- Flag explicit content for removal
|
||||
|
||||
**Partner Text Rules:**
|
||||
- NULL for symmetric activities (both partners do the same thing together)
|
||||
- FILLED for asymmetric activities (one does something to/for the other)
|
||||
- text = what the INITIATOR does
|
||||
- partner_text = what the RECEIVER experiences
|
||||
- Make partner_text APPEALING - frame receiver's experience positively
|
||||
- When initiator causes a response (moan, cum, beg), frame as ALLOWING:
|
||||
- text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
||||
- NOT: "Moan for your partner" (sounds forced)
|
||||
|
||||
### 4.2 Intensity Analysis
|
||||
|
||||
Check if current intensity matches the activity:
|
||||
|
||||
| Level | Name | Description | Examples |
|
||||
|-------|------|-------------|----------|
|
||||
| 1 | Gentle | Emotional bonding, non-sexual | cooking, cuddling, foot massage |
|
||||
| 2 | Warm | Romantic, affectionate touch | slow dance, sensual massage, kissing |
|
||||
| 3 | Playful | Light sexual exploration | oral, mutual masturbation, light roleplay |
|
||||
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
|
||||
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
|
||||
|
||||
### 4.3 Targeting Analysis
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
- Restrict to `['male+male', 'female+male']` ONLY if activity requires penis
|
||||
- Restrict to `['female+male', 'female+female']` ONLY if activity requires vagina
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field:
|
||||
- "Swallow your partner's cum" -> partner has penis -> initiator: ['female'] in M+F
|
||||
- "Deep throat your partner" -> partner has penis -> ['female'] in M+F
|
||||
- "Give your partner a massage" -> gender-neutral -> null
|
||||
|
||||
### 4.4 Props Analysis
|
||||
|
||||
**Rules:**
|
||||
- Props are physical items/accessories, not body parts or acts
|
||||
- Use lowercase, short, singular names (e.g., "blindfold", "remote vibrator")
|
||||
- Reuse existing prop names from the catalog when possible
|
||||
- If no props are required, set to null
|
||||
|
||||
### 4.5 Inverse Linking Analysis
|
||||
|
||||
**Purpose:** Detect inverse question pairs and link them using the `inverse_of` database column.
|
||||
|
||||
**Detection:**
|
||||
1. For each asymmetric question (has partner_text), look for its inverse:
|
||||
- Inverse exists if another question has: `text` = this question's `partner_text` AND `partner_text` = this question's `text`
|
||||
2. Check if the `inverse_of` column is correctly set
|
||||
|
||||
**Fixes:**
|
||||
- If pair exists but not linked: set inverse_of on the second question to point to the first
|
||||
- Symmetric questions should have inverse_of = NULL
|
||||
|
||||
### 4.6 Deletion Analysis
|
||||
|
||||
**Deletion Categories:**
|
||||
- `duplicate`: Same core action as another question
|
||||
- `redundant`: Minor variation with no added value
|
||||
- `off-tone`: Violates explicit vs non-explicit rules
|
||||
- `unsafe`: Coercion, minors, illegal acts, or consent violations
|
||||
- `too-vague`: Not actionable or not a clear proposal
|
||||
- `broken`: Grammar too broken to repair without inventing content
|
||||
- `off-topic`: Not relevant to a couples intimacy app
|
||||
|
||||
**Rules:**
|
||||
- Prefer edits over deletions when possible
|
||||
- For duplicates: delete the weaker/less specific version
|
||||
- Do NOT delete for minor wording issues
|
||||
|
||||
**CRITICAL: Inverse Questions are NOT Duplicates**
|
||||
|
||||
Asymmetric questions MUST exist in pairs (original + inverse). These are NOT duplicates:
|
||||
|
||||
```
|
||||
Question A:
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
|
||||
Question B (INVERSE - NOT A DUPLICATE):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
```
|
||||
|
||||
These are intentionally paired so both partners get asked about both roles. When reviewing:
|
||||
- If you see two questions where one's `text` matches the other's `partner_text`, they are a valid inverse pair
|
||||
- Do NOT flag inverse pairs as duplicates or redundant
|
||||
- Only flag as duplicate if TWO questions have the SAME `text` (or nearly identical `text`)
|
||||
|
||||
## Step 5: Output Results
|
||||
|
||||
Present findings in a structured format:
|
||||
|
||||
### Summary
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Text fixes | X |
|
||||
| Intensity fixes | X |
|
||||
| Targeting fixes | X |
|
||||
| Props fixes | X |
|
||||
| Inverse linking fixes | X |
|
||||
| Deletions suggested | X |
|
||||
| No changes needed | X |
|
||||
|
||||
### Suggested Changes
|
||||
|
||||
For each question needing changes:
|
||||
|
||||
```
|
||||
**Question ID:** <id>
|
||||
**Current Text:** <text>
|
||||
**Current Partner Text:** <partner_text or null>
|
||||
**Current Intensity:** <intensity>
|
||||
**Current inverse_of:** <uuid or null>
|
||||
|
||||
Issues Found:
|
||||
- [ ] Text: <description of issue>
|
||||
- [ ] Intensity: <current> -> <suggested> (<reason>)
|
||||
- [ ] Targeting: <description>
|
||||
- [ ] Props: <current> -> <suggested>
|
||||
- [ ] Inverse Link: <missing/broken> - <inverse question id if found>
|
||||
- [ ] DELETE: <category> - <reason>
|
||||
|
||||
Suggested Fix:
|
||||
- Text: "<new text>"
|
||||
- Partner Text: "<new partner_text>" or null
|
||||
- Intensity: <new intensity>
|
||||
- Couple Targets: <new allowed_couple_genders or null>
|
||||
- Initiator Targets: <new target_user_genders or null>
|
||||
- Required Props: <new required_props or null>
|
||||
- inverse_of: <uuid or null>
|
||||
```
|
||||
|
||||
### SQL to Apply Fixes
|
||||
|
||||
After user approval, provide UPDATE statements:
|
||||
|
||||
```sql
|
||||
-- Text/Partner Text/Intensity fixes
|
||||
UPDATE questions SET
|
||||
text = '<new_text>',
|
||||
partner_text = '<new_partner_text>',
|
||||
intensity = <new_intensity>
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Targeting fixes
|
||||
UPDATE questions SET
|
||||
allowed_couple_genders = '<new_targets>',
|
||||
target_user_genders = '<new_initiator>'
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Props fixes
|
||||
UPDATE questions SET
|
||||
required_props = ARRAY[<new_props>]
|
||||
WHERE id = '<question_id>';
|
||||
|
||||
-- Inverse linking fixes
|
||||
UPDATE questions SET inverse_of = '<primary_question_id>' WHERE id = '<inverse_question_id>';
|
||||
|
||||
-- Deletions (soft delete)
|
||||
UPDATE questions SET deleted_at = NOW() WHERE id IN ('<id1>', '<id2>', ...);
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/review-questions "Foreplay Favorites"
|
||||
/review-questions "Kink Discovery" --explicit
|
||||
/review-questions 123e4567-e89b-12d3-a456-426614174000 --limit=50
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask for clarification if pack name matches multiple packs
|
||||
- DO group similar issues together for batch fixes
|
||||
- DO prioritize safety issues (delete unsafe content first)
|
||||
- DO preserve the core intent of questions when suggesting text fixes
|
||||
- DO detect and link inverse pairs using the inverse_of column
|
||||
- DON'T change questions that are already well-formed
|
||||
- DON'T suggest overly restrictive targeting (prefer inclusive by default)
|
||||
- DON'T create new prop names when existing ones match
|
||||
- DON'T flag inverse pairs as duplicates (one's text = other's partner_text)
|
||||
- DON'T forget to set inverse_of for detected inverse pairs
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
allowed-tools: Read, Write, Edit, Bash
|
||||
argument-hint: [doc-type] | --implementation | --api | --architecture | --sync | --validate
|
||||
description: Systematically update project documentation with implementation status, API changes, and synchronized content
|
||||
---
|
||||
|
||||
# Documentation Update & Synchronization
|
||||
|
||||
Update project documentation systematically: $ARGUMENTS
|
||||
|
||||
## Current Documentation State
|
||||
|
||||
- Documentation structure: !`find . -name "*.md" | head -10`
|
||||
- Specs directory: @specs/ (if exists)
|
||||
- Implementation status: !`grep -r "✅\|❌\|⚠️" docs/ specs/ 2>/dev/null | wc -l` status indicators
|
||||
- Recent changes: !`git log --oneline --since="1 week ago" -- "*.md" | head -5`
|
||||
- Project progress: @CLAUDE.md or @README.md (if exists)
|
||||
|
||||
## Task
|
||||
|
||||
## Documentation Analysis
|
||||
|
||||
1. Review current documentation status:
|
||||
- Check `specs/implementation_status.md` for overall project status
|
||||
- Review implemented phase document (`specs/phase{N}_implementation_plan.md`)
|
||||
- Review `specs/flutter_structurizr_implementation_spec.md` and `specs/flutter_structurizr_implementation_spec_updated.md`
|
||||
- Review `specs/testing_plan.md` to ensure it is current given recent test passes, failures, and changes
|
||||
- Examine `CLAUDE.md` and `README.md` for project-wide documentation
|
||||
- Check for and document any new lessons learned or best practices in CLAUDE.md
|
||||
|
||||
2. Analyze implementation and testing results:
|
||||
- Review what was implemented in the last phase
|
||||
- Review testing results and coverage
|
||||
- Identify new best practices discovered during implementation
|
||||
- Note any implementation challenges and solutions
|
||||
- Cross-reference updated documentation with recent implementation and test results to ensure accuracy
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
1. Update phase implementation document:
|
||||
- Mark completed tasks with ✅ status
|
||||
- Update implementation percentages
|
||||
- Add detailed notes on implementation approach
|
||||
- Document any deviations from original plan with justification
|
||||
- Add new sections if needed (lessons learned, best practices)
|
||||
- Document specific implementation details for complex components
|
||||
- Include a summary of any new troubleshooting tips or workflow improvements discovered during the phase
|
||||
|
||||
2. Update implementation status document:
|
||||
- Update phase completion percentages
|
||||
- Add or update implementation status for components
|
||||
- Add notes on implementation approach and decisions
|
||||
- Document best practices discovered during implementation
|
||||
- Note any challenges overcome and solutions implemented
|
||||
|
||||
3. Update implementation specification documents:
|
||||
- Mark completed items with ✅ or strikethrough but preserve original requirements
|
||||
- Add notes on implementation details where appropriate
|
||||
- Add references to implemented files and classes
|
||||
- Update any implementation guidance based on experience
|
||||
|
||||
4. Update CLAUDE.md and README.md if necessary:
|
||||
- Add new best practices
|
||||
- Update project status
|
||||
- Add new implementation guidance
|
||||
- Document known issues or limitations
|
||||
- Update usage examples to include new functionality
|
||||
|
||||
5. Document new testing procedures:
|
||||
- Add details on test files created
|
||||
- Include test running instructions
|
||||
- Document test coverage
|
||||
- Explain testing approach for complex components
|
||||
|
||||
## Documentation Formatting and Structure
|
||||
|
||||
1. Maintain consistent documentation style:
|
||||
- Use clear headings and sections
|
||||
- Include code examples where helpful
|
||||
- Use status indicators (✅, ⚠️, ❌) consistently
|
||||
- Maintain proper Markdown formatting
|
||||
|
||||
2. Ensure documentation completeness:
|
||||
- Cover all implemented features
|
||||
- Include usage examples
|
||||
- Document API changes or additions
|
||||
- Include troubleshooting guidance for common issues
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO NOT CREATE new specification files
|
||||
- UPDATE existing files in the `specs/` directory
|
||||
- Maintain consistent documentation style
|
||||
- Include practical examples where appropriate
|
||||
- Cross-reference related documentation sections
|
||||
- Document best practices and lessons learned
|
||||
- Provide clear status updates on project progress
|
||||
- Update numerical completion percentages
|
||||
- Ensure documentation reflects actual implementation
|
||||
|
||||
Provide a summary of documentation updates after completion, including:
|
||||
1. Files updated
|
||||
2. Major changes to documentation
|
||||
3. Updated completion percentages
|
||||
4. New best practices documented
|
||||
5. Status of the overall project after this phase
|
||||
@@ -1,7 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(wc:*)"
|
||||
"Bash(wc:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(for f in /Users/jjbrunton/Projects/Sauci/apps/supabase/migrations/*.sql)",
|
||||
"Bash(do grep -q \"deleted_at\" \"$f\")",
|
||||
"Bash(echo:*)",
|
||||
"Bash(done)",
|
||||
"Bash(claude --version:*)",
|
||||
"Bash(strace:*)",
|
||||
"Bash(dtruss:*)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sauci-prod",
|
||||
"sauci-non-prod"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: db-migration
|
||||
description: Create, review, and verify Sauci Supabase schema migrations.
|
||||
---
|
||||
|
||||
Read `.codex/skills/db-migration/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: dev-local
|
||||
description: Start, inspect, reset, or stop the complete local Sauci stack.
|
||||
---
|
||||
|
||||
Read `.codex/skills/dev-local/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: incident-triage
|
||||
description: Diagnose Sauci development, CI, backend, or production symptoms with evidence.
|
||||
---
|
||||
|
||||
Read `.codex/skills/incident-triage/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: ios-simulator-skill
|
||||
description: Build, launch, and verify Sauci on an iOS simulator with local backend wiring and evidence.
|
||||
---
|
||||
|
||||
Read `.codex/skills/mobile-simulator/SKILL.md` completely and follow it. For this
|
||||
invocation, select the iOS path.
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Simulator Accessibility Audit
|
||||
|
||||
Scans the current simulator screen for accessibility compliance issues.
|
||||
Optimized for minimal token output while maintaining functionality.
|
||||
|
||||
Usage: python scripts/accessibility_audit.py [options]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from common import flatten_tree, get_accessibility_tree, resolve_udid
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Represents an accessibility issue."""
|
||||
|
||||
severity: str # critical, warning, info
|
||||
rule: str
|
||||
element_type: str
|
||||
issue: str
|
||||
fix: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class AccessibilityAuditor:
|
||||
"""Performs accessibility audits on iOS simulator screens."""
|
||||
|
||||
# Critical rules that block users
|
||||
CRITICAL_RULES = {
|
||||
"missing_label": lambda e: e.get("type") in ["Button", "Link"] and not e.get("AXLabel"),
|
||||
"empty_button": lambda e: e.get("type") == "Button"
|
||||
and not (e.get("AXLabel") or e.get("AXValue")),
|
||||
"image_no_alt": lambda e: e.get("type") == "Image" and not e.get("AXLabel"),
|
||||
}
|
||||
|
||||
# Warnings that degrade UX
|
||||
WARNING_RULES = {
|
||||
"missing_hint": lambda e: e.get("type") in ["Slider", "TextField"] and not e.get("help"),
|
||||
"missing_traits": lambda e: e.get("type") and not e.get("traits"),
|
||||
}
|
||||
|
||||
# Info level suggestions
|
||||
INFO_RULES = {
|
||||
"no_identifier": lambda e: not e.get("AXUniqueId"),
|
||||
"deep_nesting": lambda e: e.get("depth", 0) > 5,
|
||||
}
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize auditor with optional device UDID."""
|
||||
self.udid = udid
|
||||
|
||||
def get_accessibility_tree(self) -> dict:
|
||||
"""Fetch accessibility tree from simulator using shared utility."""
|
||||
return get_accessibility_tree(self.udid, nested=True)
|
||||
|
||||
@staticmethod
|
||||
def _is_small_target(element: dict) -> bool:
|
||||
"""Check if touch target is too small (< 44x44 points)."""
|
||||
frame = element.get("frame", {})
|
||||
width = frame.get("width", 0)
|
||||
height = frame.get("height", 0)
|
||||
return width < 44 or height < 44
|
||||
|
||||
def _flatten_tree(self, node: dict, depth: int = 0) -> list[dict]:
|
||||
"""Flatten nested accessibility tree for easier processing using shared utility."""
|
||||
return flatten_tree(node, depth)
|
||||
|
||||
def audit_element(self, element: dict) -> list[Issue]:
|
||||
"""Audit a single element for accessibility issues."""
|
||||
issues = []
|
||||
|
||||
# Check critical rules
|
||||
for rule_name, rule_func in self.CRITICAL_RULES.items():
|
||||
if rule_func(element):
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="critical",
|
||||
rule=rule_name,
|
||||
element_type=element.get("type", "Unknown"),
|
||||
issue=self._get_issue_description(rule_name),
|
||||
fix=self._get_fix_suggestion(rule_name),
|
||||
)
|
||||
)
|
||||
|
||||
# Check warnings (skip if critical issues found)
|
||||
if not issues:
|
||||
for rule_name, rule_func in self.WARNING_RULES.items():
|
||||
if rule_func(element):
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
rule=rule_name,
|
||||
element_type=element.get("type", "Unknown"),
|
||||
issue=self._get_issue_description(rule_name),
|
||||
fix=self._get_fix_suggestion(rule_name),
|
||||
)
|
||||
)
|
||||
|
||||
# Check info level (only if verbose or no other issues)
|
||||
if not issues:
|
||||
for rule_name, rule_func in self.INFO_RULES.items():
|
||||
if rule_func(element):
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="info",
|
||||
rule=rule_name,
|
||||
element_type=element.get("type", "Unknown"),
|
||||
issue=self._get_issue_description(rule_name),
|
||||
fix=self._get_fix_suggestion(rule_name),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
def _get_issue_description(self, rule: str) -> str:
|
||||
"""Get human-readable issue description."""
|
||||
descriptions = {
|
||||
"missing_label": "Interactive element missing accessibility label",
|
||||
"empty_button": "Button has no text or label",
|
||||
"image_no_alt": "Image missing alternative text",
|
||||
"missing_hint": "Complex control missing hint",
|
||||
"small_touch_target": "Touch target smaller than 44x44pt",
|
||||
"missing_traits": "Element missing accessibility traits",
|
||||
"no_identifier": "Missing accessibility identifier",
|
||||
"deep_nesting": "Deeply nested (>5 levels)",
|
||||
}
|
||||
return descriptions.get(rule, "Accessibility issue")
|
||||
|
||||
def _get_fix_suggestion(self, rule: str) -> str:
|
||||
"""Get fix suggestion for issue."""
|
||||
fixes = {
|
||||
"missing_label": "Add accessibilityLabel",
|
||||
"empty_button": "Set button title or accessibilityLabel",
|
||||
"image_no_alt": "Add accessibilityLabel with description",
|
||||
"missing_hint": "Add accessibilityHint",
|
||||
"small_touch_target": "Increase to minimum 44x44pt",
|
||||
"missing_traits": "Set appropriate accessibilityTraits",
|
||||
"no_identifier": "Add accessibilityIdentifier for testing",
|
||||
"deep_nesting": "Simplify view hierarchy",
|
||||
}
|
||||
return fixes.get(rule, "Review accessibility")
|
||||
|
||||
def audit(self, verbose: bool = False) -> dict[str, Any]:
|
||||
"""Perform full accessibility audit."""
|
||||
# Get accessibility tree
|
||||
tree = self.get_accessibility_tree()
|
||||
|
||||
# Flatten for processing
|
||||
elements = self._flatten_tree(tree)
|
||||
|
||||
# Audit each element
|
||||
all_issues = []
|
||||
for element in elements:
|
||||
issues = self.audit_element(element)
|
||||
for issue in issues:
|
||||
issue_dict = issue.to_dict()
|
||||
# Add minimal element info for context
|
||||
issue_dict["element"] = {
|
||||
"type": element.get("type", "Unknown"),
|
||||
"label": element.get("AXLabel", "")[:30] if element.get("AXLabel") else None,
|
||||
}
|
||||
all_issues.append(issue_dict)
|
||||
|
||||
# Count by severity
|
||||
critical = len([i for i in all_issues if i["severity"] == "critical"])
|
||||
warning = len([i for i in all_issues if i["severity"] == "warning"])
|
||||
info = len([i for i in all_issues if i["severity"] == "info"])
|
||||
|
||||
# Build result (token-optimized)
|
||||
result = {
|
||||
"summary": {
|
||||
"total": len(elements),
|
||||
"issues": len(all_issues),
|
||||
"critical": critical,
|
||||
"warning": warning,
|
||||
"info": info,
|
||||
}
|
||||
}
|
||||
|
||||
if verbose:
|
||||
# Full details only if requested
|
||||
result["issues"] = all_issues
|
||||
else:
|
||||
# Default: top issues only (token-efficient)
|
||||
result["top_issues"] = self._get_top_issues(all_issues)
|
||||
|
||||
return result
|
||||
|
||||
def _get_top_issues(self, issues: list[dict]) -> list[dict]:
|
||||
"""Get top 3 issues grouped by type (token-efficient)."""
|
||||
if not issues:
|
||||
return []
|
||||
|
||||
# Group by rule
|
||||
grouped = {}
|
||||
for issue in issues:
|
||||
rule = issue["rule"]
|
||||
if rule not in grouped:
|
||||
grouped[rule] = {
|
||||
"severity": issue["severity"],
|
||||
"rule": rule,
|
||||
"count": 0,
|
||||
"fix": issue["fix"],
|
||||
}
|
||||
grouped[rule]["count"] += 1
|
||||
|
||||
# Sort by severity and count
|
||||
severity_order = {"critical": 0, "warning": 1, "info": 2}
|
||||
sorted_issues = sorted(
|
||||
grouped.values(), key=lambda x: (severity_order[x["severity"]], -x["count"])
|
||||
)
|
||||
|
||||
return sorted_issues[:3]
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit iOS simulator screen for accessibility issues"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
parser.add_argument("--output", help="Save JSON report to file")
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="Include all issue details (increases output)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Perform audit
|
||||
auditor = AccessibilityAuditor(udid=udid)
|
||||
|
||||
try:
|
||||
result = auditor.audit(verbose=args.verbose)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Output results
|
||||
if args.output:
|
||||
# Save to file
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
# Print minimal summary
|
||||
summary = result["summary"]
|
||||
print(f"Audit complete: {summary['issues']} issues ({summary['critical']} critical)")
|
||||
print(f"Report saved to: {args.output}")
|
||||
# Print to stdout (token-optimized by default)
|
||||
elif args.verbose:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
# Ultra-compact output
|
||||
summary = result["summary"]
|
||||
print(f"Elements: {summary['total']}, Issues: {summary['issues']}")
|
||||
print(
|
||||
f"Critical: {summary['critical']}, Warning: {summary['warning']}, Info: {summary['info']}"
|
||||
)
|
||||
|
||||
if result.get("top_issues"):
|
||||
print("\nTop issues:")
|
||||
for issue in result["top_issues"]:
|
||||
print(
|
||||
f" [{issue['severity']}] {issue['rule']} ({issue['count']}x) - {issue['fix']}"
|
||||
)
|
||||
|
||||
# Exit with error if critical issues found
|
||||
if result["summary"]["critical"] > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS App Launcher - App Lifecycle Control
|
||||
|
||||
Launches, terminates, and manages iOS apps in the simulator.
|
||||
Handles deep links and app switching.
|
||||
|
||||
Usage: python scripts/app_launcher.py --launch com.example.app
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from common import build_simctl_command, resolve_udid
|
||||
|
||||
|
||||
class AppLauncher:
|
||||
"""Controls app lifecycle on iOS simulator."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize app launcher."""
|
||||
self.udid = udid
|
||||
|
||||
def launch(self, bundle_id: str, wait_for_debugger: bool = False) -> tuple[bool, int | None]:
|
||||
"""
|
||||
Launch an app.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle identifier
|
||||
wait_for_debugger: Wait for debugger attachment
|
||||
|
||||
Returns:
|
||||
(success, pid) tuple
|
||||
"""
|
||||
cmd = build_simctl_command("launch", self.udid, bundle_id)
|
||||
|
||||
if wait_for_debugger:
|
||||
cmd.insert(3, "--wait-for-debugger") # Insert after "launch" operation
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
# Parse PID from output if available
|
||||
pid = None
|
||||
if result.stdout:
|
||||
# Output format: "com.example.app: <PID>"
|
||||
parts = result.stdout.strip().split(":")
|
||||
if len(parts) > 1:
|
||||
with contextlib.suppress(ValueError):
|
||||
pid = int(parts[1].strip())
|
||||
return (True, pid)
|
||||
except subprocess.CalledProcessError:
|
||||
return (False, None)
|
||||
|
||||
def terminate(self, bundle_id: str) -> bool:
|
||||
"""
|
||||
Terminate an app.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle identifier
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = build_simctl_command("terminate", self.udid, bundle_id)
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def install(self, app_path: str) -> bool:
|
||||
"""
|
||||
Install an app.
|
||||
|
||||
Args:
|
||||
app_path: Path to .app bundle
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = build_simctl_command("install", self.udid, app_path)
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def uninstall(self, bundle_id: str) -> bool:
|
||||
"""
|
||||
Uninstall an app.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle identifier
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = build_simctl_command("uninstall", self.udid, bundle_id)
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def open_url(self, url: str) -> bool:
|
||||
"""
|
||||
Open URL (for deep linking).
|
||||
|
||||
Args:
|
||||
url: URL to open (http://, myapp://, etc.)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = build_simctl_command("openurl", self.udid, url)
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def list_apps(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
List installed apps.
|
||||
|
||||
Returns:
|
||||
List of app info dictionaries
|
||||
"""
|
||||
cmd = build_simctl_command("listapps", self.udid)
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Parse plist output using plutil to convert to JSON
|
||||
plist_data = result.stdout
|
||||
|
||||
# Use plutil to convert plist to JSON
|
||||
convert_cmd = ["plutil", "-convert", "json", "-o", "-", "-"]
|
||||
convert_result = subprocess.run(
|
||||
convert_cmd, check=False, input=plist_data, capture_output=True, text=True
|
||||
)
|
||||
|
||||
apps = []
|
||||
if convert_result.returncode == 0:
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(convert_result.stdout)
|
||||
for bundle_id, app_info in data.items():
|
||||
# Skip system internal apps that are hidden
|
||||
if app_info.get("ApplicationType") == "Hidden":
|
||||
continue
|
||||
|
||||
apps.append(
|
||||
{
|
||||
"bundle_id": bundle_id,
|
||||
"name": app_info.get(
|
||||
"CFBundleDisplayName", app_info.get("CFBundleName", bundle_id)
|
||||
),
|
||||
"path": app_info.get("Path", ""),
|
||||
"version": app_info.get("CFBundleVersion", "Unknown"),
|
||||
"type": app_info.get("ApplicationType", "User"),
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return apps
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
def get_app_state(self, bundle_id: str) -> str:
|
||||
"""
|
||||
Get app state (running, suspended, etc.).
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle identifier
|
||||
|
||||
Returns:
|
||||
State string or 'unknown'
|
||||
"""
|
||||
# Check if app is running by trying to get its PID
|
||||
cmd = build_simctl_command("spawn", self.udid, "launchctl", "list")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
if bundle_id in result.stdout:
|
||||
return "running"
|
||||
return "not running"
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def restart_app(self, bundle_id: str, delay: float = 1.0) -> bool:
|
||||
"""
|
||||
Restart an app (terminate then launch).
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle identifier
|
||||
delay: Delay between terminate and launch
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# Terminate
|
||||
self.terminate(bundle_id)
|
||||
time.sleep(delay)
|
||||
|
||||
# Launch
|
||||
success, _ = self.launch(bundle_id)
|
||||
return success
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Control iOS app lifecycle")
|
||||
|
||||
# Actions
|
||||
parser.add_argument("--launch", help="Launch app by bundle ID")
|
||||
parser.add_argument("--terminate", help="Terminate app by bundle ID")
|
||||
parser.add_argument("--restart", help="Restart app by bundle ID")
|
||||
parser.add_argument("--install", help="Install app from .app path")
|
||||
parser.add_argument("--uninstall", help="Uninstall app by bundle ID")
|
||||
parser.add_argument("--open-url", help="Open URL (deep link)")
|
||||
parser.add_argument("--list", action="store_true", help="List installed apps")
|
||||
parser.add_argument("--state", help="Get app state by bundle ID")
|
||||
|
||||
# Options
|
||||
parser.add_argument(
|
||||
"--wait-for-debugger", action="store_true", help="Wait for debugger when launching"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
launcher = AppLauncher(udid=udid)
|
||||
|
||||
# Execute requested action
|
||||
if args.launch:
|
||||
success, pid = launcher.launch(args.launch, args.wait_for_debugger)
|
||||
if success:
|
||||
if pid:
|
||||
print(f"Launched {args.launch} (PID: {pid})")
|
||||
else:
|
||||
print(f"Launched {args.launch}")
|
||||
else:
|
||||
print(f"Failed to launch {args.launch}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.terminate:
|
||||
if launcher.terminate(args.terminate):
|
||||
print(f"Terminated {args.terminate}")
|
||||
else:
|
||||
print(f"Failed to terminate {args.terminate}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.restart:
|
||||
if launcher.restart_app(args.restart):
|
||||
print(f"Restarted {args.restart}")
|
||||
else:
|
||||
print(f"Failed to restart {args.restart}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.install:
|
||||
if launcher.install(args.install):
|
||||
print(f"Installed {args.install}")
|
||||
else:
|
||||
print(f"Failed to install {args.install}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.uninstall:
|
||||
if launcher.uninstall(args.uninstall):
|
||||
print(f"Uninstalled {args.uninstall}")
|
||||
else:
|
||||
print(f"Failed to uninstall {args.uninstall}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.open_url:
|
||||
if launcher.open_url(args.open_url):
|
||||
print(f"Opened URL: {args.open_url}")
|
||||
else:
|
||||
print(f"Failed to open URL: {args.open_url}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.list:
|
||||
apps = launcher.list_apps()
|
||||
if apps:
|
||||
print(f"Installed apps ({len(apps)}):")
|
||||
for app in apps[:10]: # Limit for token efficiency
|
||||
print(f" {app['bundle_id']}: {app['name']} (v{app['version']})")
|
||||
if len(apps) > 10:
|
||||
print(f" ... and {len(apps) - 10} more")
|
||||
else:
|
||||
print("No apps found or failed to list")
|
||||
|
||||
elif args.state:
|
||||
state = launcher.get_app_state(args.state)
|
||||
print(f"{args.state}: {state}")
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
App State Capture for iOS Simulator
|
||||
|
||||
Captures complete app state including screenshot, accessibility tree, and logs.
|
||||
Optimized for minimal token output.
|
||||
|
||||
Usage: python scripts/app_state_capture.py [options]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common import (
|
||||
capture_screenshot,
|
||||
count_elements,
|
||||
get_accessibility_tree,
|
||||
resolve_udid,
|
||||
)
|
||||
|
||||
|
||||
class AppStateCapture:
|
||||
"""Captures comprehensive app state for debugging."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_bundle_id: str | None = None,
|
||||
udid: str | None = None,
|
||||
inline: bool = False,
|
||||
screenshot_size: str = "half",
|
||||
):
|
||||
"""
|
||||
Initialize state capture.
|
||||
|
||||
Args:
|
||||
app_bundle_id: Optional app bundle ID for log filtering
|
||||
udid: Optional device UDID (uses booted if not specified)
|
||||
inline: If True, return screenshots as base64 (for vision-based automation)
|
||||
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||
"""
|
||||
self.app_bundle_id = app_bundle_id
|
||||
self.udid = udid
|
||||
self.inline = inline
|
||||
self.screenshot_size = screenshot_size
|
||||
|
||||
def capture_screenshot(self, output_path: Path) -> bool:
|
||||
"""Capture screenshot of current screen."""
|
||||
cmd = ["xcrun", "simctl", "io"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(["screenshot", str(output_path)])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def capture_accessibility_tree(self, output_path: Path) -> dict:
|
||||
"""Capture accessibility tree using shared utility."""
|
||||
try:
|
||||
# Use shared utility to fetch tree
|
||||
tree = get_accessibility_tree(self.udid, nested=True)
|
||||
|
||||
# Save tree
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(tree, f, indent=2)
|
||||
|
||||
# Return summary using shared utility
|
||||
return {"captured": True, "element_count": count_elements(tree)}
|
||||
except Exception as e:
|
||||
return {"captured": False, "error": str(e)}
|
||||
|
||||
def capture_logs(self, output_path: Path, line_limit: int = 100) -> dict:
|
||||
"""Capture recent app logs."""
|
||||
if not self.app_bundle_id:
|
||||
# Can't capture logs without app ID
|
||||
return {"captured": False, "reason": "No app bundle ID specified"}
|
||||
|
||||
# Get app name from bundle ID (simplified)
|
||||
app_name = self.app_bundle_id.split(".")[-1]
|
||||
|
||||
cmd = ["xcrun", "simctl", "spawn"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"log",
|
||||
"show",
|
||||
"--predicate",
|
||||
f'process == "{app_name}"',
|
||||
"--last",
|
||||
"1m", # Last 1 minute
|
||||
"--style",
|
||||
"compact",
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=5)
|
||||
logs = result.stdout
|
||||
|
||||
# Limit lines for token efficiency
|
||||
lines = logs.split("\n")
|
||||
if len(lines) > line_limit:
|
||||
lines = lines[-line_limit:]
|
||||
|
||||
# Save logs
|
||||
with open(output_path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
# Analyze for issues
|
||||
warning_count = sum(1 for line in lines if "warning" in line.lower())
|
||||
error_count = sum(1 for line in lines if "error" in line.lower())
|
||||
|
||||
return {
|
||||
"captured": True,
|
||||
"lines": len(lines),
|
||||
"warnings": warning_count,
|
||||
"errors": error_count,
|
||||
}
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
return {"captured": False, "error": str(e)}
|
||||
|
||||
def capture_device_info(self) -> dict:
|
||||
"""Get device information."""
|
||||
cmd = ["xcrun", "simctl", "list", "devices", "booted"]
|
||||
|
||||
if self.udid:
|
||||
# Specific device info
|
||||
cmd = ["xcrun", "simctl", "list", "devices"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Parse output for device info (simplified)
|
||||
lines = result.stdout.split("\n")
|
||||
device_info = {}
|
||||
|
||||
for line in lines:
|
||||
if "iPhone" in line or "iPad" in line:
|
||||
# Extract device name and state
|
||||
parts = line.strip().split("(")
|
||||
if parts:
|
||||
device_info["name"] = parts[0].strip()
|
||||
if len(parts) > 2:
|
||||
device_info["udid"] = parts[1].replace(")", "").strip()
|
||||
device_info["state"] = parts[2].replace(")", "").strip()
|
||||
break
|
||||
|
||||
return device_info
|
||||
except subprocess.CalledProcessError:
|
||||
return {}
|
||||
|
||||
def capture_all(
|
||||
self, output_dir: str, log_lines: int = 100, app_name: str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Capture complete app state.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to save artifacts
|
||||
log_lines: Number of log lines to capture
|
||||
app_name: App name for semantic naming (for inline mode)
|
||||
|
||||
Returns:
|
||||
Summary of captured state
|
||||
"""
|
||||
# Create output directory (only if not in inline mode)
|
||||
output_path = Path(output_dir)
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
if not self.inline:
|
||||
capture_dir = output_path / f"app-state-{timestamp}"
|
||||
capture_dir.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
capture_dir = None
|
||||
|
||||
summary = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"screenshot_mode": "inline" if self.inline else "file",
|
||||
}
|
||||
|
||||
if capture_dir:
|
||||
summary["output_dir"] = str(capture_dir)
|
||||
|
||||
# Capture screenshot using new unified utility
|
||||
screenshot_result = capture_screenshot(
|
||||
self.udid,
|
||||
size=self.screenshot_size,
|
||||
inline=self.inline,
|
||||
app_name=app_name,
|
||||
)
|
||||
|
||||
if self.inline:
|
||||
# Inline mode: store base64
|
||||
summary["screenshot"] = {
|
||||
"mode": "inline",
|
||||
"base64": screenshot_result["base64_data"],
|
||||
"width": screenshot_result["width"],
|
||||
"height": screenshot_result["height"],
|
||||
"size_preset": self.screenshot_size,
|
||||
}
|
||||
else:
|
||||
# File mode: save to disk
|
||||
screenshot_path = capture_dir / "screenshot.png"
|
||||
# Move temp file to target location
|
||||
import shutil
|
||||
|
||||
shutil.move(screenshot_result["file_path"], screenshot_path)
|
||||
summary["screenshot"] = {
|
||||
"mode": "file",
|
||||
"file": "screenshot.png",
|
||||
"size_bytes": screenshot_result["size_bytes"],
|
||||
}
|
||||
|
||||
# Capture accessibility tree
|
||||
if not self.inline or capture_dir:
|
||||
accessibility_path = (capture_dir or output_path) / "accessibility-tree.json"
|
||||
else:
|
||||
accessibility_path = None
|
||||
|
||||
if accessibility_path:
|
||||
tree_info = self.capture_accessibility_tree(accessibility_path)
|
||||
summary["accessibility"] = tree_info
|
||||
|
||||
# Capture logs (if app ID provided)
|
||||
if self.app_bundle_id:
|
||||
if not self.inline or capture_dir:
|
||||
logs_path = (capture_dir or output_path) / "app-logs.txt"
|
||||
else:
|
||||
logs_path = None
|
||||
|
||||
if logs_path:
|
||||
log_info = self.capture_logs(logs_path, log_lines)
|
||||
summary["logs"] = log_info
|
||||
|
||||
# Get device info
|
||||
device_info = self.capture_device_info()
|
||||
if device_info:
|
||||
summary["device"] = device_info
|
||||
# Save device info (file mode only)
|
||||
if capture_dir:
|
||||
with open(capture_dir / "device-info.json", "w") as f:
|
||||
json.dump(device_info, f, indent=2)
|
||||
|
||||
# Save summary (file mode only)
|
||||
if capture_dir:
|
||||
with open(capture_dir / "summary.json", "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
# Create markdown summary
|
||||
self._create_summary_md(capture_dir, summary)
|
||||
|
||||
return summary
|
||||
|
||||
def _create_summary_md(self, capture_dir: Path, summary: dict) -> None:
|
||||
"""Create markdown summary file."""
|
||||
md_path = capture_dir / "summary.md"
|
||||
|
||||
with open(md_path, "w") as f:
|
||||
f.write("# App State Capture\n\n")
|
||||
f.write(f"**Timestamp:** {summary['timestamp']}\n\n")
|
||||
|
||||
if "device" in summary:
|
||||
f.write("## Device\n")
|
||||
device = summary["device"]
|
||||
f.write(f"- Name: {device.get('name', 'Unknown')}\n")
|
||||
f.write(f"- UDID: {device.get('udid', 'N/A')}\n")
|
||||
f.write(f"- State: {device.get('state', 'Unknown')}\n\n")
|
||||
|
||||
f.write("## Screenshot\n")
|
||||
f.write("\n\n")
|
||||
|
||||
if "accessibility" in summary:
|
||||
acc = summary["accessibility"]
|
||||
f.write("## Accessibility\n")
|
||||
if acc.get("captured"):
|
||||
f.write(f"- Elements: {acc.get('element_count', 0)}\n")
|
||||
else:
|
||||
f.write(f"- Error: {acc.get('error', 'Unknown')}\n")
|
||||
f.write("\n")
|
||||
|
||||
if "logs" in summary:
|
||||
logs = summary["logs"]
|
||||
f.write("## Logs\n")
|
||||
if logs.get("captured"):
|
||||
f.write(f"- Lines: {logs.get('lines', 0)}\n")
|
||||
f.write(f"- Warnings: {logs.get('warnings', 0)}\n")
|
||||
f.write(f"- Errors: {logs.get('errors', 0)}\n")
|
||||
else:
|
||||
f.write(f"- {logs.get('reason', logs.get('error', 'Not captured'))}\n")
|
||||
f.write("\n")
|
||||
|
||||
f.write("## Files\n")
|
||||
f.write("- `screenshot.png` - Current screen\n")
|
||||
f.write("- `accessibility-tree.json` - Full UI hierarchy\n")
|
||||
if self.app_bundle_id:
|
||||
f.write("- `app-logs.txt` - Recent app logs\n")
|
||||
f.write("- `device-info.json` - Device details\n")
|
||||
f.write("- `summary.json` - Complete capture metadata\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Capture complete app state for debugging")
|
||||
parser.add_argument(
|
||||
"--app-bundle-id", help="App bundle ID for log filtering (e.g., com.example.app)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", default=".", help="Output directory (default: current directory)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-lines", type=int, default=100, help="Number of log lines to capture (default: 100)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inline",
|
||||
action="store_true",
|
||||
help="Return screenshots as base64 (inline mode for vision-based automation)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
choices=["full", "half", "quarter", "thumb"],
|
||||
default="half",
|
||||
help="Screenshot size for token optimization (default: half)",
|
||||
)
|
||||
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create capturer
|
||||
capturer = AppStateCapture(
|
||||
app_bundle_id=args.app_bundle_id,
|
||||
udid=udid,
|
||||
inline=args.inline,
|
||||
screenshot_size=args.size,
|
||||
)
|
||||
|
||||
# Capture state
|
||||
try:
|
||||
summary = capturer.capture_all(
|
||||
output_dir=args.output, log_lines=args.log_lines, app_name=args.app_name
|
||||
)
|
||||
|
||||
# Token-efficient output
|
||||
if "output_dir" in summary:
|
||||
print(f"State captured: {summary['output_dir']}/")
|
||||
else:
|
||||
# Inline mode
|
||||
print(
|
||||
f"State captured (inline mode): {summary['screenshot']['width']}x{summary['screenshot']['height']}"
|
||||
)
|
||||
|
||||
# Report any issues found
|
||||
if "logs" in summary and summary["logs"].get("captured"):
|
||||
logs = summary["logs"]
|
||||
if logs["errors"] > 0 or logs["warnings"] > 0:
|
||||
print(f"Issues found: {logs['errors']} errors, {logs['warnings']} warnings")
|
||||
|
||||
if "accessibility" in summary and summary["accessibility"].get("captured"):
|
||||
print(f"Elements: {summary['accessibility']['element_count']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build and Test Automation for Xcode Projects
|
||||
|
||||
Ultra token-efficient build automation with progressive disclosure via xcresult bundles.
|
||||
|
||||
Features:
|
||||
- Minimal default output (5-10 tokens)
|
||||
- Progressive disclosure for error/warning/log details
|
||||
- Native xcresult bundle support
|
||||
- Clean modular architecture
|
||||
|
||||
Usage Examples:
|
||||
# Build (minimal output)
|
||||
python scripts/build_and_test.py --project MyApp.xcodeproj
|
||||
# Output: Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
|
||||
|
||||
# Get error details
|
||||
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
|
||||
|
||||
# Get warnings
|
||||
python scripts/build_and_test.py --get-warnings xcresult-20251018-143052
|
||||
|
||||
# Get build log
|
||||
python scripts/build_and_test.py --get-log xcresult-20251018-143052
|
||||
|
||||
# Get everything as JSON
|
||||
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
|
||||
|
||||
# List recent builds
|
||||
python scripts/build_and_test.py --list-xcresults
|
||||
|
||||
# Verbose mode (for debugging)
|
||||
python scripts/build_and_test.py --project MyApp.xcodeproj --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import our modular components
|
||||
from xcode import BuildRunner, OutputFormatter, XCResultCache, XCResultParser
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and test Xcode projects with progressive disclosure",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Build project (minimal output)
|
||||
python scripts/build_and_test.py --project MyApp.xcodeproj
|
||||
|
||||
# Run tests
|
||||
python scripts/build_and_test.py --project MyApp.xcodeproj --test
|
||||
|
||||
# Get error details from previous build
|
||||
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
|
||||
|
||||
# Get all details as JSON
|
||||
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
|
||||
|
||||
# List recent builds
|
||||
python scripts/build_and_test.py --list-xcresults
|
||||
""",
|
||||
)
|
||||
|
||||
# Build/test mode arguments
|
||||
build_group = parser.add_argument_group("Build/Test Options")
|
||||
project_group = build_group.add_mutually_exclusive_group()
|
||||
project_group.add_argument("--project", help="Path to .xcodeproj file")
|
||||
project_group.add_argument("--workspace", help="Path to .xcworkspace file")
|
||||
|
||||
build_group.add_argument("--scheme", help="Build scheme (auto-detected if not specified)")
|
||||
build_group.add_argument(
|
||||
"--configuration",
|
||||
default="Debug",
|
||||
choices=["Debug", "Release"],
|
||||
help="Build configuration (default: Debug)",
|
||||
)
|
||||
build_group.add_argument("--simulator", help="Simulator name (default: iPhone 15)")
|
||||
build_group.add_argument("--clean", action="store_true", help="Clean before building")
|
||||
build_group.add_argument("--test", action="store_true", help="Run tests")
|
||||
build_group.add_argument("--suite", help="Specific test suite to run")
|
||||
|
||||
# Progressive disclosure arguments
|
||||
disclosure_group = parser.add_argument_group("Progressive Disclosure Options")
|
||||
disclosure_group.add_argument(
|
||||
"--get-errors", metavar="XCRESULT_ID", help="Get error details from xcresult"
|
||||
)
|
||||
disclosure_group.add_argument(
|
||||
"--get-warnings", metavar="XCRESULT_ID", help="Get warning details from xcresult"
|
||||
)
|
||||
disclosure_group.add_argument(
|
||||
"--get-log", metavar="XCRESULT_ID", help="Get build log from xcresult"
|
||||
)
|
||||
disclosure_group.add_argument(
|
||||
"--get-all", metavar="XCRESULT_ID", help="Get all details from xcresult"
|
||||
)
|
||||
disclosure_group.add_argument(
|
||||
"--list-xcresults", action="store_true", help="List recent xcresult bundles"
|
||||
)
|
||||
|
||||
# Output options
|
||||
output_group = parser.add_argument_group("Output Options")
|
||||
output_group.add_argument("--verbose", action="store_true", help="Show detailed output")
|
||||
output_group.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize cache
|
||||
cache = XCResultCache()
|
||||
|
||||
# Handle list mode
|
||||
if args.list_xcresults:
|
||||
xcresults = cache.list()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(json.dumps(xcresults, indent=2))
|
||||
elif not xcresults:
|
||||
print("No xcresult bundles found")
|
||||
else:
|
||||
print(f"Recent XCResult bundles ({len(xcresults)}):")
|
||||
print()
|
||||
for xc in xcresults:
|
||||
print(f" {xc['id']}")
|
||||
print(f" Created: {xc['created']}")
|
||||
print(f" Size: {xc['size_mb']} MB")
|
||||
print()
|
||||
return 0
|
||||
|
||||
# Handle retrieval modes
|
||||
xcresult_id = args.get_errors or args.get_warnings or args.get_log or args.get_all
|
||||
|
||||
if xcresult_id:
|
||||
xcresult_path = cache.get_path(xcresult_id)
|
||||
|
||||
if not xcresult_path or not xcresult_path.exists():
|
||||
print(f"Error: XCResult bundle not found: {xcresult_id}", file=sys.stderr)
|
||||
print("Use --list-xcresults to see available bundles", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load cached stderr for progressive disclosure
|
||||
cached_stderr = cache.get_stderr(xcresult_id)
|
||||
parser = XCResultParser(xcresult_path, stderr=cached_stderr)
|
||||
|
||||
# Get errors
|
||||
if args.get_errors:
|
||||
errors = parser.get_errors()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(json.dumps(errors, indent=2))
|
||||
else:
|
||||
print(OutputFormatter.format_errors(errors))
|
||||
return 0
|
||||
|
||||
# Get warnings
|
||||
if args.get_warnings:
|
||||
warnings = parser.get_warnings()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(json.dumps(warnings, indent=2))
|
||||
else:
|
||||
print(OutputFormatter.format_warnings(warnings))
|
||||
return 0
|
||||
|
||||
# Get log
|
||||
if args.get_log:
|
||||
log = parser.get_build_log()
|
||||
if log:
|
||||
print(OutputFormatter.format_log(log))
|
||||
else:
|
||||
print("No build log available", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# Get all
|
||||
if args.get_all:
|
||||
error_count, warning_count = parser.count_issues()
|
||||
errors = parser.get_errors()
|
||||
warnings = parser.get_warnings()
|
||||
build_log = parser.get_build_log()
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
data = {
|
||||
"xcresult_id": xcresult_id,
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"log_preview": build_log[:1000] if build_log else None,
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print(f"XCResult: {xcresult_id}")
|
||||
print(f"Errors: {error_count}, Warnings: {warning_count}")
|
||||
print()
|
||||
if errors:
|
||||
print(OutputFormatter.format_errors(errors, limit=10))
|
||||
print()
|
||||
if warnings:
|
||||
print(OutputFormatter.format_warnings(warnings, limit=10))
|
||||
print()
|
||||
if build_log:
|
||||
print("Build Log (last 30 lines):")
|
||||
print(OutputFormatter.format_log(build_log, lines=30))
|
||||
return 0
|
||||
|
||||
# Build/test mode
|
||||
if not args.project and not args.workspace:
|
||||
# Try to auto-detect in current directory
|
||||
cwd = Path.cwd()
|
||||
projects = list(cwd.glob("*.xcodeproj"))
|
||||
workspaces = list(cwd.glob("*.xcworkspace"))
|
||||
|
||||
if workspaces:
|
||||
args.workspace = str(workspaces[0])
|
||||
elif projects:
|
||||
args.project = str(projects[0])
|
||||
else:
|
||||
parser.error("No project or workspace specified and none found in current directory")
|
||||
|
||||
# Initialize builder
|
||||
builder = BuildRunner(
|
||||
project_path=args.project,
|
||||
workspace_path=args.workspace,
|
||||
scheme=args.scheme,
|
||||
configuration=args.configuration,
|
||||
simulator=args.simulator,
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
# Execute build or test
|
||||
if args.test:
|
||||
success, xcresult_id, stderr = builder.test(test_suite=args.suite)
|
||||
else:
|
||||
success, xcresult_id, stderr = builder.build(clean=args.clean)
|
||||
|
||||
if not xcresult_id and not stderr:
|
||||
print("Error: Build/test failed without creating xcresult or error output", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Save stderr to cache for progressive disclosure
|
||||
if xcresult_id and stderr:
|
||||
cache.save_stderr(xcresult_id, stderr)
|
||||
|
||||
# Parse results
|
||||
xcresult_path = cache.get_path(xcresult_id) if xcresult_id else None
|
||||
parser = XCResultParser(xcresult_path, stderr=stderr)
|
||||
error_count, warning_count = parser.count_issues()
|
||||
|
||||
# Format output
|
||||
status = "SUCCESS" if success else "FAILED"
|
||||
|
||||
# Generate hints for failed builds
|
||||
hints = None
|
||||
if not success:
|
||||
errors = parser.get_errors()
|
||||
hints = OutputFormatter.generate_hints(errors)
|
||||
|
||||
if args.verbose:
|
||||
# Verbose mode with error/warning details
|
||||
errors = parser.get_errors() if error_count > 0 else None
|
||||
warnings = parser.get_warnings() if warning_count > 0 else None
|
||||
|
||||
output = OutputFormatter.format_verbose(
|
||||
status=status,
|
||||
error_count=error_count,
|
||||
warning_count=warning_count,
|
||||
xcresult_id=xcresult_id or "N/A",
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
)
|
||||
print(output)
|
||||
elif args.json:
|
||||
# JSON mode
|
||||
data = {
|
||||
"success": success,
|
||||
"xcresult_id": xcresult_id or None,
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
}
|
||||
if hints:
|
||||
data["hints"] = hints
|
||||
import json
|
||||
|
||||
print(json.dumps(data, indent=2))
|
||||
else:
|
||||
# Minimal mode (default)
|
||||
output = OutputFormatter.format_minimal(
|
||||
status=status,
|
||||
error_count=error_count,
|
||||
warning_count=warning_count,
|
||||
xcresult_id=xcresult_id or "N/A",
|
||||
hints=hints,
|
||||
)
|
||||
print(output)
|
||||
|
||||
# Exit with appropriate code
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Simulator Clipboard Manager
|
||||
|
||||
Copy text to simulator clipboard for testing paste flows.
|
||||
Optimized for minimal token output.
|
||||
|
||||
Usage: python scripts/clipboard.py --copy "text to copy"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from common import resolve_udid
|
||||
|
||||
|
||||
class ClipboardManager:
|
||||
"""Manages clipboard operations on iOS simulator."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize clipboard manager.
|
||||
|
||||
Args:
|
||||
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||
"""
|
||||
self.udid = udid
|
||||
|
||||
def copy(self, text: str) -> bool:
|
||||
"""
|
||||
Copy text to simulator clipboard.
|
||||
|
||||
Args:
|
||||
text: Text to copy to clipboard
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = ["xcrun", "simctl", "pbcopy"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.append(text)
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Copy text to iOS simulator clipboard")
|
||||
parser.add_argument("--copy", required=True, help="Text to copy to clipboard")
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
parser.add_argument("--test-name", help="Test scenario name for tracking")
|
||||
parser.add_argument("--expected", help="Expected behavior after paste")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create manager and copy text
|
||||
manager = ClipboardManager(udid=udid)
|
||||
|
||||
if manager.copy(args.copy):
|
||||
# Token-efficient output
|
||||
output = f'Copied: "{args.copy}"'
|
||||
|
||||
if args.test_name:
|
||||
output += f" (test: {args.test_name})"
|
||||
|
||||
print(output)
|
||||
|
||||
# Provide usage guidance
|
||||
if args.expected:
|
||||
print(f"Expected: {args.expected}")
|
||||
|
||||
print()
|
||||
print("Next steps:")
|
||||
print("1. Tap text field with: python scripts/navigator.py --find-type TextField --tap")
|
||||
print("2. Paste with: python scripts/keyboard.py --key return")
|
||||
print(" Or use Cmd+V gesture with: python scripts/keyboard.py --key cmd+v")
|
||||
|
||||
else:
|
||||
print("Failed to copy text to clipboard")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Common utilities shared across iOS simulator scripts.
|
||||
|
||||
This module centralizes genuinely reused code patterns to eliminate duplication
|
||||
while respecting Jackson's Law - no over-abstraction, only truly shared logic.
|
||||
|
||||
Organization:
|
||||
- device_utils: Device detection, command building, coordinate transformation
|
||||
- idb_utils: IDB-specific operations (accessibility tree, element manipulation)
|
||||
- cache_utils: Progressive disclosure caching for large outputs
|
||||
- screenshot_utils: Screenshot capture with file and inline modes
|
||||
"""
|
||||
|
||||
from .cache_utils import ProgressiveCache, get_cache
|
||||
from .device_utils import (
|
||||
build_idb_command,
|
||||
build_simctl_command,
|
||||
get_booted_device_udid,
|
||||
get_device_screen_size,
|
||||
resolve_udid,
|
||||
transform_screenshot_coords,
|
||||
)
|
||||
from .idb_utils import (
|
||||
count_elements,
|
||||
flatten_tree,
|
||||
get_accessibility_tree,
|
||||
get_screen_size,
|
||||
)
|
||||
from .screenshot_utils import (
|
||||
capture_screenshot,
|
||||
format_screenshot_result,
|
||||
generate_screenshot_name,
|
||||
get_size_preset,
|
||||
resize_screenshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# cache_utils
|
||||
"ProgressiveCache",
|
||||
# device_utils
|
||||
"build_idb_command",
|
||||
"build_simctl_command",
|
||||
# screenshot_utils
|
||||
"capture_screenshot",
|
||||
# idb_utils
|
||||
"count_elements",
|
||||
"flatten_tree",
|
||||
"format_screenshot_result",
|
||||
"generate_screenshot_name",
|
||||
"get_accessibility_tree",
|
||||
"get_booted_device_udid",
|
||||
"get_cache",
|
||||
"get_device_screen_size",
|
||||
"get_screen_size",
|
||||
"get_size_preset",
|
||||
"resize_screenshot",
|
||||
"resolve_udid",
|
||||
"transform_screenshot_coords",
|
||||
]
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Progressive disclosure cache for large outputs.
|
||||
|
||||
Implements cache system to support progressive disclosure pattern:
|
||||
- Return concise summary with cache_id for large outputs
|
||||
- User retrieves full details on demand via cache_id
|
||||
- Reduces token usage by 96% for common queries
|
||||
|
||||
Cache directory: ~/.ios-simulator-skill/cache/
|
||||
Cache expiration: Configurable per cache type (default 1 hour)
|
||||
|
||||
Used by:
|
||||
- sim_list.py - Simulator listing progressive disclosure
|
||||
- Future: build logs, UI trees, etc.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ProgressiveCache:
|
||||
"""Cache for progressive disclosure pattern.
|
||||
|
||||
Stores large outputs with timestamped IDs for on-demand retrieval.
|
||||
Automatically cleans up expired entries.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: str | None = None, max_age_hours: int = 1):
|
||||
"""Initialize cache system.
|
||||
|
||||
Args:
|
||||
cache_dir: Cache directory path (default: ~/.ios-simulator-skill/cache/)
|
||||
max_age_hours: Max age for cache entries before expiration (default: 1 hour)
|
||||
"""
|
||||
if cache_dir is None:
|
||||
cache_dir = str(Path("~/.ios-simulator-skill/cache").expanduser())
|
||||
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.max_age_hours = max_age_hours
|
||||
|
||||
# Create cache directory if needed
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save(self, data: dict[str, Any], cache_type: str) -> str:
|
||||
"""Save data to cache and return cache_id.
|
||||
|
||||
Args:
|
||||
data: Dictionary data to cache
|
||||
cache_type: Type of cache ('simulator-list', 'build-log', 'ui-tree', etc.)
|
||||
|
||||
Returns:
|
||||
Cache ID like 'sim-20251028-143052' for use in progressive disclosure
|
||||
|
||||
Example:
|
||||
cache_id = cache.save({'devices': [...]}, 'simulator-list')
|
||||
# Returns: 'sim-20251028-143052'
|
||||
"""
|
||||
# Generate cache_id with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
cache_prefix = cache_type.split("-")[0] # e.g., 'sim' from 'simulator-list'
|
||||
cache_id = f"{cache_prefix}-{timestamp}"
|
||||
|
||||
# Save to file
|
||||
cache_file = self.cache_dir / f"{cache_id}.json"
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"cache_id": cache_id,
|
||||
"cache_type": cache_type,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"data": data,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
return cache_id
|
||||
|
||||
def get(self, cache_id: str) -> dict[str, Any] | None:
|
||||
"""Retrieve data from cache by cache_id.
|
||||
|
||||
Args:
|
||||
cache_id: Cache ID from save() or list_entries()
|
||||
|
||||
Returns:
|
||||
Cached data dictionary, or None if not found/expired
|
||||
|
||||
Example:
|
||||
data = cache.get('sim-20251028-143052')
|
||||
if data:
|
||||
print(f"Found {len(data)} devices")
|
||||
"""
|
||||
cache_file = self.cache_dir / f"{cache_id}.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
if self._is_expired(cache_file):
|
||||
cache_file.unlink() # Delete expired file
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file) as f:
|
||||
entry = json.load(f)
|
||||
return entry.get("data")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def list_entries(self, cache_type: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List available cache entries with metadata.
|
||||
|
||||
Args:
|
||||
cache_type: Filter by type (e.g., 'simulator-list'), or None for all
|
||||
|
||||
Returns:
|
||||
List of cache entries with id, type, created_at, age_seconds
|
||||
|
||||
Example:
|
||||
entries = cache.list_entries('simulator-list')
|
||||
for entry in entries:
|
||||
print(f"{entry['id']} - {entry['age_seconds']}s old")
|
||||
"""
|
||||
entries = []
|
||||
|
||||
for cache_file in sorted(self.cache_dir.glob("*.json"), reverse=True):
|
||||
# Check if expired
|
||||
if self._is_expired(cache_file):
|
||||
cache_file.unlink()
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(cache_file) as f:
|
||||
entry = json.load(f)
|
||||
|
||||
# Filter by type if specified
|
||||
if cache_type and entry.get("cache_type") != cache_type:
|
||||
continue
|
||||
|
||||
created_at = datetime.fromisoformat(entry.get("created_at", ""))
|
||||
age_seconds = (datetime.now() - created_at).total_seconds()
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"id": entry.get("cache_id"),
|
||||
"type": entry.get("cache_type"),
|
||||
"created_at": entry.get("created_at"),
|
||||
"age_seconds": int(age_seconds),
|
||||
}
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
return entries
|
||||
|
||||
def cleanup(self, max_age_hours: int | None = None) -> int:
|
||||
"""Remove expired cache entries.
|
||||
|
||||
Args:
|
||||
max_age_hours: Age threshold (default: uses instance max_age_hours)
|
||||
|
||||
Returns:
|
||||
Number of entries deleted
|
||||
|
||||
Example:
|
||||
deleted = cache.cleanup()
|
||||
print(f"Deleted {deleted} expired cache entries")
|
||||
"""
|
||||
if max_age_hours is None:
|
||||
max_age_hours = self.max_age_hours
|
||||
|
||||
deleted = 0
|
||||
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
if self._is_expired(cache_file, max_age_hours):
|
||||
cache_file.unlink()
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
def clear(self, cache_type: str | None = None) -> int:
|
||||
"""Clear all cache entries of a type.
|
||||
|
||||
Args:
|
||||
cache_type: Type to clear (e.g., 'simulator-list'), or None to clear all
|
||||
|
||||
Returns:
|
||||
Number of entries deleted
|
||||
|
||||
Example:
|
||||
cleared = cache.clear('simulator-list')
|
||||
print(f"Cleared {cleared} simulator list entries")
|
||||
"""
|
||||
deleted = 0
|
||||
|
||||
for cache_file in self.cache_dir.glob("*.json"):
|
||||
if cache_type is None:
|
||||
# Clear all
|
||||
cache_file.unlink()
|
||||
deleted += 1
|
||||
else:
|
||||
# Clear by type
|
||||
try:
|
||||
with open(cache_file) as f:
|
||||
entry = json.load(f)
|
||||
if entry.get("cache_type") == cache_type:
|
||||
cache_file.unlink()
|
||||
deleted += 1
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
return deleted
|
||||
|
||||
def _is_expired(self, cache_file: Path, max_age_hours: int | None = None) -> bool:
|
||||
"""Check if cache file is expired.
|
||||
|
||||
Args:
|
||||
cache_file: Path to cache file
|
||||
max_age_hours: Age threshold (default: uses instance max_age_hours)
|
||||
|
||||
Returns:
|
||||
True if file is older than max_age_hours
|
||||
"""
|
||||
if max_age_hours is None:
|
||||
max_age_hours = self.max_age_hours
|
||||
|
||||
try:
|
||||
with open(cache_file) as f:
|
||||
entry = json.load(f)
|
||||
created_at = datetime.fromisoformat(entry.get("created_at", ""))
|
||||
age = datetime.now() - created_at
|
||||
return age > timedelta(hours=max_age_hours)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
# Module-level cache instances (lazy-loaded)
|
||||
_cache_instances: dict[str, ProgressiveCache] = {}
|
||||
|
||||
|
||||
def get_cache(cache_dir: str | None = None) -> ProgressiveCache:
|
||||
"""Get or create global cache instance.
|
||||
|
||||
Args:
|
||||
cache_dir: Custom cache directory (uses default if None)
|
||||
|
||||
Returns:
|
||||
ProgressiveCache instance
|
||||
"""
|
||||
# Use cache_dir as key, or 'default' if None
|
||||
key = cache_dir or "default"
|
||||
|
||||
if key not in _cache_instances:
|
||||
_cache_instances[key] = ProgressiveCache(cache_dir)
|
||||
|
||||
return _cache_instances[key]
|
||||
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared device and simulator utilities.
|
||||
|
||||
Common patterns for interacting with simulators via xcrun simctl and IDB.
|
||||
Standardizes command building and device targeting to prevent errors.
|
||||
|
||||
Follows Jackson's Law - only extracts genuinely reused patterns.
|
||||
|
||||
Used by:
|
||||
- app_launcher.py (8 call sites) - App lifecycle commands
|
||||
- Multiple scripts (15+ locations) - IDB command building
|
||||
- navigator.py, gesture.py - Coordinate transformation
|
||||
- test_recorder.py, app_state_capture.py - Auto-UDID detection
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def build_simctl_command(
|
||||
operation: str,
|
||||
udid: str | None = None,
|
||||
*args,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build xcrun simctl command with proper device handling.
|
||||
|
||||
Standardizes command building to prevent device targeting bugs.
|
||||
Automatically uses "booted" if no UDID provided.
|
||||
|
||||
Used by:
|
||||
- app_launcher.py: launch, terminate, install, uninstall, openurl, listapps, spawn
|
||||
- Multiple scripts: generic simctl operations
|
||||
|
||||
Args:
|
||||
operation: simctl operation (launch, terminate, install, etc.)
|
||||
udid: Device UDID (uses 'booted' if None)
|
||||
*args: Additional command arguments
|
||||
|
||||
Returns:
|
||||
Complete command list ready for subprocess.run()
|
||||
|
||||
Examples:
|
||||
# Launch app on booted simulator
|
||||
cmd = build_simctl_command("launch", None, "com.app.bundle")
|
||||
# Returns: ["xcrun", "simctl", "launch", "booted", "com.app.bundle"]
|
||||
|
||||
# Launch on specific device
|
||||
cmd = build_simctl_command("launch", "ABC123", "com.app.bundle")
|
||||
# Returns: ["xcrun", "simctl", "launch", "ABC123", "com.app.bundle"]
|
||||
|
||||
# Install app on specific device
|
||||
cmd = build_simctl_command("install", "ABC123", "/path/to/app.app")
|
||||
# Returns: ["xcrun", "simctl", "install", "ABC123", "/path/to/app.app"]
|
||||
"""
|
||||
cmd = ["xcrun", "simctl", operation]
|
||||
|
||||
# Add device (booted or specific UDID)
|
||||
cmd.append(udid if udid else "booted")
|
||||
|
||||
# Add remaining arguments
|
||||
cmd.extend(str(arg) for arg in args)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_idb_command(
|
||||
operation: str,
|
||||
udid: str | None = None,
|
||||
*args,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build IDB command with proper device targeting.
|
||||
|
||||
Standardizes IDB command building across all scripts using IDB.
|
||||
Handles device UDID consistently.
|
||||
|
||||
Used by:
|
||||
- navigator.py: ui tap, ui text, ui describe-all
|
||||
- gesture.py: ui swipe, ui tap
|
||||
- keyboard.py: ui key, ui text, ui tap
|
||||
- And more: 15+ locations
|
||||
|
||||
Args:
|
||||
operation: IDB operation path (e.g., "ui tap", "ui text", "ui describe-all")
|
||||
udid: Device UDID (omits --udid flag if None, IDB uses booted by default)
|
||||
*args: Additional command arguments
|
||||
|
||||
Returns:
|
||||
Complete command list ready for subprocess.run()
|
||||
|
||||
Examples:
|
||||
# Tap on booted simulator
|
||||
cmd = build_idb_command("ui tap", None, "200", "400")
|
||||
# Returns: ["idb", "ui", "tap", "200", "400"]
|
||||
|
||||
# Tap on specific device
|
||||
cmd = build_idb_command("ui tap", "ABC123", "200", "400")
|
||||
# Returns: ["idb", "ui", "tap", "200", "400", "--udid", "ABC123"]
|
||||
|
||||
# Get accessibility tree
|
||||
cmd = build_idb_command("ui describe-all", "ABC123", "--json", "--nested")
|
||||
# Returns: ["idb", "ui", "describe-all", "--json", "--nested", "--udid", "ABC123"]
|
||||
|
||||
# Enter text
|
||||
cmd = build_idb_command("ui text", None, "hello world")
|
||||
# Returns: ["idb", "ui", "text", "hello world"]
|
||||
"""
|
||||
# Split operation into parts (e.g., "ui tap" -> ["ui", "tap"])
|
||||
cmd = ["idb"] + operation.split()
|
||||
|
||||
# Add arguments
|
||||
cmd.extend(str(arg) for arg in args)
|
||||
|
||||
# Add device targeting if specified (optional for IDB, uses booted by default)
|
||||
if udid:
|
||||
cmd.extend(["--udid", udid])
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def get_booted_device_udid() -> str | None:
|
||||
"""
|
||||
Auto-detect currently booted simulator UDID.
|
||||
|
||||
Queries xcrun simctl for booted devices and returns first match.
|
||||
|
||||
Returns:
|
||||
UDID of booted simulator, or None if no simulator is booted.
|
||||
|
||||
Example:
|
||||
udid = get_booted_device_udid()
|
||||
if udid:
|
||||
print(f"Booted simulator: {udid}")
|
||||
else:
|
||||
print("No simulator is currently booted")
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "booted"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse output to find UDID
|
||||
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
|
||||
for line in result.stdout.split("\n"):
|
||||
# Look for UUID pattern in parentheses
|
||||
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_udid(udid_arg: str | None) -> str:
|
||||
"""
|
||||
Resolve device UDID with auto-detection fallback.
|
||||
|
||||
If udid_arg is provided, returns it immediately.
|
||||
If None, attempts to auto-detect booted simulator.
|
||||
Raises error if neither is available.
|
||||
|
||||
Args:
|
||||
udid_arg: Explicit UDID from command line, or None
|
||||
|
||||
Returns:
|
||||
Valid UDID string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no UDID provided and no booted simulator found
|
||||
|
||||
Example:
|
||||
try:
|
||||
udid = resolve_udid(args.udid) # args.udid might be None
|
||||
print(f"Using device: {udid}")
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
"""
|
||||
if udid_arg:
|
||||
return udid_arg
|
||||
|
||||
booted_udid = get_booted_device_udid()
|
||||
if booted_udid:
|
||||
return booted_udid
|
||||
|
||||
raise RuntimeError(
|
||||
"No device UDID provided and no simulator is currently booted.\n"
|
||||
"Boot a simulator or provide --udid explicitly:\n"
|
||||
" xcrun simctl boot <device-name>\n"
|
||||
" python scripts/script_name.py --udid <device-udid>"
|
||||
)
|
||||
|
||||
|
||||
def get_device_screen_size(udid: str) -> tuple[int, int]:
|
||||
"""
|
||||
Get actual screen dimensions for device via accessibility tree.
|
||||
|
||||
Queries IDB accessibility tree to determine actual device resolution.
|
||||
Falls back to iPhone 14 defaults (390x844) if detection fails.
|
||||
|
||||
Args:
|
||||
udid: Device UDID
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) in pixels
|
||||
|
||||
Example:
|
||||
width, height = get_device_screen_size("ABC123")
|
||||
print(f"Device screen: {width}x{height}")
|
||||
"""
|
||||
try:
|
||||
cmd = build_idb_command("ui describe-all", udid, "--json")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Parse JSON response
|
||||
data = json.loads(result.stdout)
|
||||
tree = data[0] if isinstance(data, list) and len(data) > 0 else data
|
||||
|
||||
# Get frame size from root element
|
||||
if tree and "frame" in tree:
|
||||
frame = tree["frame"]
|
||||
width = int(frame.get("width", 390))
|
||||
height = int(frame.get("height", 844))
|
||||
return (width, height)
|
||||
|
||||
# Fallback
|
||||
return (390, 844)
|
||||
except Exception:
|
||||
# Graceful fallback to iPhone 14 Pro defaults
|
||||
return (390, 844)
|
||||
|
||||
|
||||
def resolve_device_identifier(identifier: str) -> str:
|
||||
"""
|
||||
Resolve device name or partial UDID to full UDID.
|
||||
|
||||
Supports multiple identifier formats:
|
||||
- Full UDID: "ABC-123-DEF456..." (36 character UUID)
|
||||
- Device name: "iPhone 16 Pro" (matches full name)
|
||||
- Partial match: "iPhone 16" (matches first device containing this string)
|
||||
- Special: "booted" (resolves to currently booted device)
|
||||
|
||||
Args:
|
||||
identifier: Device UDID, name, or special value "booted"
|
||||
|
||||
Returns:
|
||||
Full device UDID
|
||||
|
||||
Raises:
|
||||
RuntimeError: If identifier cannot be resolved
|
||||
|
||||
Example:
|
||||
udid = resolve_device_identifier("iPhone 16 Pro")
|
||||
# Returns: "ABC123DEF456..."
|
||||
|
||||
udid = resolve_device_identifier("booted")
|
||||
# Returns UDID of booted simulator
|
||||
"""
|
||||
# Handle "booted" special case
|
||||
if identifier.lower() == "booted":
|
||||
booted = get_booted_device_udid()
|
||||
if booted:
|
||||
return booted
|
||||
raise RuntimeError(
|
||||
"No simulator is currently booted. "
|
||||
"Boot a simulator first: xcrun simctl boot <device-udid>"
|
||||
)
|
||||
|
||||
# Check if already a full UDID (36 character UUID format)
|
||||
if re.match(r"^[A-F0-9\-]{36}$", identifier, re.IGNORECASE):
|
||||
return identifier.upper()
|
||||
|
||||
# Try to match by device name
|
||||
simulators = list_simulators(state=None)
|
||||
exact_matches = [s for s in simulators if s["name"].lower() == identifier.lower()]
|
||||
if exact_matches:
|
||||
return exact_matches[0]["udid"]
|
||||
|
||||
# Try partial match
|
||||
partial_matches = [s for s in simulators if identifier.lower() in s["name"].lower()]
|
||||
if partial_matches:
|
||||
return partial_matches[0]["udid"]
|
||||
|
||||
# No match found
|
||||
raise RuntimeError(
|
||||
f"Device '{identifier}' not found. "
|
||||
f"Use 'xcrun simctl list devices' to see available simulators."
|
||||
)
|
||||
|
||||
|
||||
def list_simulators(state: str | None = None) -> list[dict]:
|
||||
"""
|
||||
List iOS simulators with optional state filtering.
|
||||
|
||||
Queries xcrun simctl and returns structured list of simulators.
|
||||
Optionally filters by state (available, booted, all).
|
||||
|
||||
Args:
|
||||
state: Optional filter - "available", "booted", or None for all
|
||||
|
||||
Returns:
|
||||
List of simulator dicts with keys:
|
||||
- "name": Device name (e.g., "iPhone 16 Pro")
|
||||
- "udid": Device UDID (36 char UUID)
|
||||
- "state": Device state ("Booted", "Shutdown", "Unavailable")
|
||||
- "runtime": iOS version (e.g., "iOS 18.0", "unavailable")
|
||||
- "type": Device type ("iPhone", "iPad", "Apple Watch", etc.)
|
||||
|
||||
Example:
|
||||
# List all simulators
|
||||
all_sims = list_simulators()
|
||||
print(f"Total simulators: {len(all_sims)}")
|
||||
|
||||
# List only available simulators
|
||||
available = list_simulators(state="available")
|
||||
for sim in available:
|
||||
print(f"{sim['name']} ({sim['state']}) - {sim['udid']}")
|
||||
|
||||
# List only booted simulators
|
||||
booted = list_simulators(state="booted")
|
||||
for sim in booted:
|
||||
print(f"Booted: {sim['name']}")
|
||||
"""
|
||||
try:
|
||||
# Query simctl for device list
|
||||
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
simulators = []
|
||||
|
||||
# Parse JSON response
|
||||
# Format: {"devices": {"iOS 18.0": [{...}, {...}], "iOS 17.0": [...], ...}}
|
||||
for ios_version, devices in data.get("devices", {}).items():
|
||||
for device in devices:
|
||||
sim = {
|
||||
"name": device.get("name", "Unknown"),
|
||||
"udid": device.get("udid", ""),
|
||||
"state": device.get("state", "Unknown"),
|
||||
"runtime": ios_version,
|
||||
"type": _extract_device_type(device.get("name", "")),
|
||||
}
|
||||
simulators.append(sim)
|
||||
|
||||
# Apply state filtering
|
||||
if state == "booted":
|
||||
return [s for s in simulators if s["state"] == "Booted"]
|
||||
if state == "available":
|
||||
return [s for s in simulators if s["state"] == "Shutdown"] # Available to boot
|
||||
if state is None:
|
||||
return simulators
|
||||
return [s for s in simulators if s["state"].lower() == state.lower()]
|
||||
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
|
||||
raise RuntimeError(f"Failed to list simulators: {e}") from e
|
||||
|
||||
|
||||
def _extract_device_type(device_name: str) -> str:
|
||||
"""
|
||||
Extract device type from device name.
|
||||
|
||||
Parses device name to determine type (iPhone, iPad, Watch, etc.).
|
||||
|
||||
Args:
|
||||
device_name: Full device name (e.g., "iPhone 16 Pro")
|
||||
|
||||
Returns:
|
||||
Device type string
|
||||
|
||||
Example:
|
||||
_extract_device_type("iPhone 16 Pro") # Returns "iPhone"
|
||||
_extract_device_type("iPad Air") # Returns "iPad"
|
||||
_extract_device_type("Apple Watch Series 9") # Returns "Watch"
|
||||
"""
|
||||
if "iPhone" in device_name:
|
||||
return "iPhone"
|
||||
if "iPad" in device_name:
|
||||
return "iPad"
|
||||
if "Watch" in device_name or "Apple Watch" in device_name:
|
||||
return "Watch"
|
||||
if "TV" in device_name or "Apple TV" in device_name:
|
||||
return "TV"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def transform_screenshot_coords(
|
||||
x: float,
|
||||
y: float,
|
||||
screenshot_width: int,
|
||||
screenshot_height: int,
|
||||
device_width: int,
|
||||
device_height: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Transform screenshot coordinates to device coordinates.
|
||||
|
||||
Handles the case where a screenshot was downscaled (e.g., to 'half' size)
|
||||
and needs to be transformed back to actual device pixel coordinates
|
||||
for accurate tapping.
|
||||
|
||||
The transformation is linear:
|
||||
device_x = (screenshot_x / screenshot_width) * device_width
|
||||
device_y = (screenshot_y / screenshot_height) * device_height
|
||||
|
||||
Args:
|
||||
x, y: Coordinates in the screenshot
|
||||
screenshot_width, screenshot_height: Screenshot dimensions (e.g., 195, 422)
|
||||
device_width, device_height: Actual device dimensions (e.g., 390, 844)
|
||||
|
||||
Returns:
|
||||
Tuple of (device_x, device_y) in device pixels
|
||||
|
||||
Example:
|
||||
# Screenshot taken at 'half' size: 195x422 (from 390x844 device)
|
||||
device_x, device_y = transform_screenshot_coords(
|
||||
100, 200, # Tap point in screenshot
|
||||
195, 422, # Screenshot dimensions
|
||||
390, 844 # Device dimensions
|
||||
)
|
||||
print(f"Tap at device coords: ({device_x}, {device_y})")
|
||||
# Output: Tap at device coords: (200, 400)
|
||||
"""
|
||||
device_x = int((x / screenshot_width) * device_width)
|
||||
device_y = int((y / screenshot_height) * device_height)
|
||||
return (device_x, device_y)
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared IDB utility functions.
|
||||
|
||||
This module provides common IDB operations used across multiple scripts.
|
||||
Follows Jackson's Law - only shared code that's truly reused, not speculative.
|
||||
|
||||
Used by:
|
||||
- navigator.py - Accessibility tree navigation
|
||||
- screen_mapper.py - UI element analysis
|
||||
- accessibility_audit.py - WCAG compliance checking
|
||||
- test_recorder.py - Test documentation
|
||||
- app_state_capture.py - State snapshots
|
||||
- gesture.py - Touch gesture operations
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def get_accessibility_tree(udid: str | None = None, nested: bool = True) -> dict:
|
||||
"""
|
||||
Fetch accessibility tree from IDB.
|
||||
|
||||
The accessibility tree represents the complete UI hierarchy of the current
|
||||
screen, with all element properties needed for semantic navigation.
|
||||
|
||||
Args:
|
||||
udid: Device UDID (uses booted simulator if None)
|
||||
nested: Include nested structure (default True). If False, returns flat array.
|
||||
|
||||
Returns:
|
||||
Root element of accessibility tree as dict.
|
||||
Structure: {
|
||||
"type": "Window",
|
||||
"AXLabel": "App Name",
|
||||
"frame": {"x": 0, "y": 0, "width": 390, "height": 844},
|
||||
"children": [...]
|
||||
}
|
||||
|
||||
Raises:
|
||||
SystemExit: If IDB command fails or returns invalid JSON
|
||||
|
||||
Example:
|
||||
tree = get_accessibility_tree("UDID123")
|
||||
# Root is Window element with all children nested
|
||||
"""
|
||||
cmd = ["idb", "ui", "describe-all", "--json"]
|
||||
if nested:
|
||||
cmd.append("--nested")
|
||||
if udid:
|
||||
cmd.extend(["--udid", udid])
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
tree_data = json.loads(result.stdout)
|
||||
|
||||
# IDB returns array format, extract first element (root)
|
||||
if isinstance(tree_data, list) and len(tree_data) > 0:
|
||||
return tree_data[0]
|
||||
return tree_data
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: Failed to get accessibility tree: {e.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print("Error: Invalid JSON from idb", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def flatten_tree(node: dict, depth: int = 0, elements: list[dict] | None = None) -> list[dict]:
|
||||
"""
|
||||
Flatten nested accessibility tree into list of elements.
|
||||
|
||||
Converts the hierarchical accessibility tree into a flat list where each
|
||||
element includes its depth for context.
|
||||
|
||||
Used by:
|
||||
- navigator.py - Element finding
|
||||
- screen_mapper.py - Element analysis
|
||||
- accessibility_audit.py - Audit scanning
|
||||
|
||||
Args:
|
||||
node: Root node of tree (typically from get_accessibility_tree)
|
||||
depth: Current depth (used internally, start at 0)
|
||||
elements: Accumulator list (used internally, start as None)
|
||||
|
||||
Returns:
|
||||
Flat list of elements, each with "depth" key indicating nesting level.
|
||||
Structure of each element: {
|
||||
"type": "Button",
|
||||
"AXLabel": "Login",
|
||||
"frame": {...},
|
||||
"depth": 2,
|
||||
...
|
||||
}
|
||||
|
||||
Example:
|
||||
tree = get_accessibility_tree()
|
||||
flat = flatten_tree(tree)
|
||||
for elem in flat:
|
||||
print(f"{' ' * elem['depth']}{elem.get('type')}: {elem.get('AXLabel')}")
|
||||
"""
|
||||
if elements is None:
|
||||
elements = []
|
||||
|
||||
# Add current node with depth tracking
|
||||
node_copy = node.copy()
|
||||
node_copy["depth"] = depth
|
||||
elements.append(node_copy)
|
||||
|
||||
# Process children recursively
|
||||
for child in node.get("children", []):
|
||||
flatten_tree(child, depth + 1, elements)
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def count_elements(node: dict) -> int:
|
||||
"""
|
||||
Count total elements in tree (recursive).
|
||||
|
||||
Traverses entire tree counting all elements for reporting purposes.
|
||||
|
||||
Used by:
|
||||
- test_recorder.py - Element counting per step
|
||||
- screen_mapper.py - Summary statistics
|
||||
|
||||
Args:
|
||||
node: Root node of tree
|
||||
|
||||
Returns:
|
||||
Total element count including root and all descendants
|
||||
|
||||
Example:
|
||||
tree = get_accessibility_tree()
|
||||
total = count_elements(tree)
|
||||
print(f"Screen has {total} elements")
|
||||
"""
|
||||
count = 1
|
||||
for child in node.get("children", []):
|
||||
count += count_elements(child)
|
||||
return count
|
||||
|
||||
|
||||
def get_screen_size(udid: str | None = None) -> tuple[int, int]:
|
||||
"""
|
||||
Get screen dimensions from accessibility tree.
|
||||
|
||||
Extracts the screen size from the root element's frame. Useful for
|
||||
gesture calculations and coordinate normalization.
|
||||
|
||||
Used by:
|
||||
- gesture.py - Gesture positioning
|
||||
- Potentially: screenshot positioning, screen-aware scaling
|
||||
|
||||
Args:
|
||||
udid: Device UDID (uses booted if None)
|
||||
|
||||
Returns:
|
||||
(width, height) tuple. Defaults to (390, 844) if detection fails
|
||||
or tree cannot be accessed.
|
||||
|
||||
Example:
|
||||
width, height = get_screen_size()
|
||||
center_x = width // 2
|
||||
center_y = height // 2
|
||||
"""
|
||||
DEFAULT_WIDTH = 390 # iPhone 14
|
||||
DEFAULT_HEIGHT = 844
|
||||
|
||||
try:
|
||||
tree = get_accessibility_tree(udid, nested=False)
|
||||
frame = tree.get("frame", {})
|
||||
width = int(frame.get("width", DEFAULT_WIDTH))
|
||||
height = int(frame.get("height", DEFAULT_HEIGHT))
|
||||
return (width, height)
|
||||
except Exception:
|
||||
# Silently fall back to defaults if tree access fails
|
||||
return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
||||
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Screenshot utilities with dual-mode support.
|
||||
|
||||
Provides unified screenshot handling with:
|
||||
- File-based mode: Persistent artifacts for test documentation
|
||||
- Inline base64 mode: Vision-based automation for agent analysis
|
||||
- Size presets: Token optimization (full/half/quarter/thumb)
|
||||
- Semantic naming: {appName}_{screenName}_{state}_{timestamp}.png
|
||||
|
||||
Supports resize operations via PIL (optional dependency).
|
||||
|
||||
Used by:
|
||||
- test_recorder.py - Step-based screenshot recording
|
||||
- app_state_capture.py - State snapshot captures
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Try to import PIL for resizing, but make it optional
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
|
||||
def generate_screenshot_name(
|
||||
app_name: str | None = None,
|
||||
screen_name: str | None = None,
|
||||
state: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
extension: str = "png",
|
||||
) -> str:
|
||||
"""Generate semantic screenshot filename.
|
||||
|
||||
Format: {appName}_{screenName}_{state}_{timestamp}.{ext}
|
||||
Falls back to: screenshot_{timestamp}.{ext}
|
||||
|
||||
Args:
|
||||
app_name: Application name (e.g., 'MyApp')
|
||||
screen_name: Screen name (e.g., 'Login')
|
||||
state: State description (e.g., 'Empty', 'Filled', 'Error')
|
||||
timestamp: ISO timestamp (uses current time if None)
|
||||
extension: File extension (default: 'png')
|
||||
|
||||
Returns:
|
||||
Semantic filename ready for safe file creation
|
||||
|
||||
Example:
|
||||
name = generate_screenshot_name('MyApp', 'Login', 'Empty')
|
||||
# Returns: 'MyApp_Login_Empty_20251028-143052.png'
|
||||
|
||||
name = generate_screenshot_name()
|
||||
# Returns: 'screenshot_20251028-143052.png'
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
# Build semantic name
|
||||
if app_name or screen_name or state:
|
||||
parts = [app_name, screen_name, state]
|
||||
parts = [p for p in parts if p] # Filter None/empty
|
||||
name = "_".join(parts) + f"_{timestamp}"
|
||||
else:
|
||||
name = f"screenshot_{timestamp}"
|
||||
|
||||
return f"{name}.{extension}"
|
||||
|
||||
|
||||
def get_size_preset(size: str = "half") -> tuple[float, float]:
|
||||
"""Get scale factors for size preset.
|
||||
|
||||
Args:
|
||||
size: 'full', 'half', 'quarter', 'thumb'
|
||||
|
||||
Returns:
|
||||
Tuple of (scale_x, scale_y) for resizing
|
||||
|
||||
Example:
|
||||
scale_x, scale_y = get_size_preset('half')
|
||||
# Returns: (0.5, 0.5)
|
||||
"""
|
||||
presets = {
|
||||
"full": (1.0, 1.0),
|
||||
"half": (0.5, 0.5),
|
||||
"quarter": (0.25, 0.25),
|
||||
"thumb": (0.1, 0.1),
|
||||
}
|
||||
return presets.get(size, (0.5, 0.5))
|
||||
|
||||
|
||||
def resize_screenshot(
|
||||
input_path: str,
|
||||
output_path: str | None = None,
|
||||
size: str = "half",
|
||||
quality: int = 85,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Resize screenshot for token optimization.
|
||||
|
||||
Requires PIL (Pillow). Falls back gracefully without it.
|
||||
|
||||
Args:
|
||||
input_path: Path to original screenshot
|
||||
output_path: Output path (uses input_path if None)
|
||||
size: 'full', 'half', 'quarter', 'thumb'
|
||||
quality: JPEG quality (1-100, default: 85)
|
||||
|
||||
Returns:
|
||||
Tuple of (output_path, width, height) of resized image
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If input file doesn't exist
|
||||
ValueError: If PIL not installed and size != 'full'
|
||||
|
||||
Example:
|
||||
output, w, h = resize_screenshot(
|
||||
'screenshot.png',
|
||||
'screenshot_half.png',
|
||||
'half'
|
||||
)
|
||||
print(f"Resized to {w}x{h}")
|
||||
"""
|
||||
input_file = Path(input_path)
|
||||
if not input_file.exists():
|
||||
raise FileNotFoundError(f"Screenshot not found: {input_path}")
|
||||
|
||||
# If full size, just copy
|
||||
if size == "full":
|
||||
if output_path:
|
||||
import shutil
|
||||
|
||||
shutil.copy(input_path, output_path)
|
||||
output_file = Path(output_path)
|
||||
else:
|
||||
output_file = input_file
|
||||
|
||||
# Get original dimensions
|
||||
if HAS_PIL:
|
||||
img = Image.open(str(output_file))
|
||||
return (str(output_file), img.width, img.height)
|
||||
return (str(output_file), 0, 0) # Dimensions unknown without PIL
|
||||
|
||||
# Need PIL to resize
|
||||
if not HAS_PIL:
|
||||
raise ValueError(
|
||||
f"Size preset '{size}' requires PIL (Pillow). " "Install with: pip3 install pillow"
|
||||
)
|
||||
|
||||
# Open original image
|
||||
img = Image.open(str(input_file))
|
||||
orig_w, orig_h = img.size
|
||||
|
||||
# Calculate new size
|
||||
scale_x, scale_y = get_size_preset(size)
|
||||
new_w = int(orig_w * scale_x)
|
||||
new_h = int(orig_h * scale_y)
|
||||
|
||||
# Resize with high-quality resampling
|
||||
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Determine output path
|
||||
if output_path is None:
|
||||
# Insert size marker before extension
|
||||
stem = input_file.stem
|
||||
suffix = input_file.suffix
|
||||
output_path = str(input_file.parent / f"{stem}_{size}{suffix}")
|
||||
|
||||
# Save resized image
|
||||
resized.save(output_path, quality=quality, optimize=True)
|
||||
|
||||
return (output_path, new_w, new_h)
|
||||
|
||||
|
||||
def capture_screenshot(
|
||||
udid: str,
|
||||
output_path: str | None = None,
|
||||
size: str = "half",
|
||||
inline: bool = False,
|
||||
app_name: str | None = None,
|
||||
screen_name: str | None = None,
|
||||
state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture screenshot with flexible output modes.
|
||||
|
||||
Supports both file-based (persistent artifacts) and inline base64 modes
|
||||
(for vision-based automation).
|
||||
|
||||
Args:
|
||||
udid: Device UDID
|
||||
output_path: File path for file mode (generates semantic name if None)
|
||||
size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||
inline: If True, returns base64 data instead of saving to file
|
||||
app_name: App name for semantic naming
|
||||
screen_name: Screen name for semantic naming
|
||||
state: State description for semantic naming
|
||||
|
||||
Returns:
|
||||
Dict with mode-specific fields:
|
||||
|
||||
File mode:
|
||||
{
|
||||
'mode': 'file',
|
||||
'file_path': str,
|
||||
'size_bytes': int,
|
||||
'width': int,
|
||||
'height': int,
|
||||
'size_preset': str
|
||||
}
|
||||
|
||||
Inline mode:
|
||||
{
|
||||
'mode': 'inline',
|
||||
'base64_data': str,
|
||||
'mime_type': 'image/png',
|
||||
'width': int,
|
||||
'height': int,
|
||||
'size_preset': str
|
||||
}
|
||||
|
||||
Example:
|
||||
# File mode
|
||||
result = capture_screenshot('ABC123', app_name='MyApp')
|
||||
print(f"Saved to: {result['file_path']}")
|
||||
|
||||
# Inline mode
|
||||
result = capture_screenshot('ABC123', inline=True, size='half')
|
||||
print(f"Screenshot: {result['width']}x{result['height']}")
|
||||
print(f"Base64: {result['base64_data'][:50]}...")
|
||||
"""
|
||||
try:
|
||||
# Capture raw screenshot to temp file
|
||||
temp_path = "/tmp/ios_simulator_screenshot.png"
|
||||
cmd = ["xcrun", "simctl", "io", udid, "screenshot", temp_path]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
if inline:
|
||||
# Inline mode: resize and convert to base64
|
||||
# Resize if needed
|
||||
if size != "full" and HAS_PIL:
|
||||
resized_path, width, height = resize_screenshot(temp_path, size=size)
|
||||
else:
|
||||
resized_path = temp_path
|
||||
# Get dimensions via PIL if available
|
||||
if HAS_PIL:
|
||||
img = Image.open(resized_path)
|
||||
width, height = img.size
|
||||
else:
|
||||
width, height = 390, 844 # Fallback to common device size
|
||||
|
||||
# Read and encode as base64
|
||||
with open(resized_path, "rb") as f:
|
||||
base64_data = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
# Clean up temp files
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
if resized_path != temp_path:
|
||||
Path(resized_path).unlink(missing_ok=True)
|
||||
|
||||
return {
|
||||
"mode": "inline",
|
||||
"base64_data": base64_data,
|
||||
"mime_type": "image/png",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"size_preset": size,
|
||||
}
|
||||
|
||||
# File mode: save to output path with semantic naming
|
||||
if output_path is None:
|
||||
output_path = generate_screenshot_name(app_name, screen_name, state)
|
||||
|
||||
# Resize if needed
|
||||
if size != "full" and HAS_PIL:
|
||||
final_path, width, height = resize_screenshot(temp_path, output_path, size)
|
||||
else:
|
||||
# Just move temp to output
|
||||
import shutil
|
||||
|
||||
shutil.move(temp_path, output_path)
|
||||
final_path = output_path
|
||||
|
||||
# Get dimensions via PIL if available
|
||||
if HAS_PIL:
|
||||
img = Image.open(final_path)
|
||||
width, height = img.size
|
||||
else:
|
||||
width, height = 390, 844 # Fallback
|
||||
|
||||
# Get file size
|
||||
size_bytes = Path(final_path).stat().st_size
|
||||
|
||||
return {
|
||||
"mode": "file",
|
||||
"file_path": final_path,
|
||||
"size_bytes": size_bytes,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"size_preset": size,
|
||||
}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to capture screenshot: {e.stderr}") from e
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Screenshot capture error: {e!s}") from e
|
||||
|
||||
|
||||
def format_screenshot_result(result: dict[str, Any]) -> str:
|
||||
"""Format screenshot result for human-readable output.
|
||||
|
||||
Args:
|
||||
result: Result dictionary from capture_screenshot()
|
||||
|
||||
Returns:
|
||||
Formatted string for printing
|
||||
|
||||
Example:
|
||||
result = capture_screenshot('ABC123', inline=True)
|
||||
print(format_screenshot_result(result))
|
||||
"""
|
||||
if result["mode"] == "file":
|
||||
return (
|
||||
f"Screenshot: {result['file_path']}\n"
|
||||
f"Dimensions: {result['width']}x{result['height']}\n"
|
||||
f"Size: {result['size_bytes']} bytes"
|
||||
)
|
||||
return (
|
||||
f"Screenshot (inline): {result['width']}x{result['height']}\n"
|
||||
f"Base64 length: {len(result['base64_data'])} chars"
|
||||
)
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Gesture Controller - Swipes and Complex Gestures
|
||||
|
||||
Performs navigation gestures like swipes, scrolls, and pinches.
|
||||
Token-efficient output for common navigation patterns.
|
||||
|
||||
This script handles touch gestures for iOS simulator automation. It provides
|
||||
directional swipes, multi-swipe scrolling, pull-to-refresh, and pinch gestures.
|
||||
Automatically detects screen size from the device for accurate gesture positioning.
|
||||
|
||||
Key Features:
|
||||
- Directional swipes (up, down, left, right)
|
||||
- Multi-swipe scrolling with customizable amount
|
||||
- Pull-to-refresh gesture
|
||||
- Pinch to zoom (in/out)
|
||||
- Custom swipe between any two points
|
||||
- Drag and drop simulation
|
||||
- Auto-detects screen dimensions from device
|
||||
|
||||
Usage Examples:
|
||||
# Simple directional swipe
|
||||
python scripts/gesture.py --swipe up --udid <device-id>
|
||||
|
||||
# Scroll down multiple times
|
||||
python scripts/gesture.py --scroll down --scroll-amount 3 --udid <device-id>
|
||||
|
||||
# Pull to refresh
|
||||
python scripts/gesture.py --refresh --udid <device-id>
|
||||
|
||||
# Custom swipe coordinates
|
||||
python scripts/gesture.py --swipe-from 100,500 --swipe-to 100,100 --udid <device-id>
|
||||
|
||||
# Pinch to zoom
|
||||
python scripts/gesture.py --pinch out --udid <device-id>
|
||||
|
||||
# Long press at coordinates
|
||||
python scripts/gesture.py --long-press 200,300 --duration 2.0 --udid <device-id>
|
||||
|
||||
Output Format:
|
||||
Swiped up
|
||||
Scrolled down (3x)
|
||||
Performed pull to refresh
|
||||
|
||||
Gesture Details:
|
||||
- Swipes use 70% of screen by default (configurable)
|
||||
- Scrolls are multiple small 30% swipes with delays
|
||||
- Start points are offset from edges for reliability
|
||||
- Screen size auto-detected from accessibility tree root element
|
||||
- Falls back to iPhone 14 dimensions (390x844) if detection fails
|
||||
|
||||
Technical Details:
|
||||
- Uses `idb ui swipe x1 y1 x2 y2` for gesture execution
|
||||
- Duration parameter converts to milliseconds for IDB
|
||||
- Automatically fetches screen size on initialization
|
||||
- Parses IDB accessibility tree to get root frame dimensions
|
||||
- All coordinates calculated as fractions of screen size for device independence
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from common import (
|
||||
get_device_screen_size,
|
||||
get_screen_size,
|
||||
resolve_udid,
|
||||
transform_screenshot_coords,
|
||||
)
|
||||
|
||||
|
||||
class GestureController:
|
||||
"""Performs gestures on iOS simulator."""
|
||||
|
||||
# Standard screen dimensions (will be detected if possible)
|
||||
DEFAULT_WIDTH = 390 # iPhone 14
|
||||
DEFAULT_HEIGHT = 844
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize gesture controller."""
|
||||
self.udid = udid
|
||||
self.screen_size = self._get_screen_size()
|
||||
|
||||
def _get_screen_size(self) -> tuple[int, int]:
|
||||
"""Try to detect screen size from device using shared utility."""
|
||||
return get_screen_size(self.udid)
|
||||
|
||||
def swipe(self, direction: str, distance_ratio: float = 0.7) -> bool:
|
||||
"""
|
||||
Perform directional swipe.
|
||||
|
||||
Args:
|
||||
direction: up, down, left, right
|
||||
distance_ratio: How far to swipe (0.0-1.0 of screen)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
width, height = self.screen_size
|
||||
center_x = width // 2
|
||||
center_y = height // 2
|
||||
|
||||
# Calculate swipe coordinates based on direction
|
||||
if direction == "up":
|
||||
start = (center_x, int(height * 0.7))
|
||||
end = (center_x, int(height * (1 - distance_ratio + 0.3)))
|
||||
elif direction == "down":
|
||||
start = (center_x, int(height * 0.3))
|
||||
end = (center_x, int(height * (distance_ratio - 0.3 + 0.3)))
|
||||
elif direction == "left":
|
||||
start = (int(width * 0.8), center_y)
|
||||
end = (int(width * (1 - distance_ratio + 0.2)), center_y)
|
||||
elif direction == "right":
|
||||
start = (int(width * 0.2), center_y)
|
||||
end = (int(width * (distance_ratio - 0.2 + 0.2)), center_y)
|
||||
else:
|
||||
return False
|
||||
|
||||
return self.swipe_between(start, end)
|
||||
|
||||
def swipe_between(
|
||||
self, start: tuple[int, int], end: tuple[int, int], duration: float = 0.3
|
||||
) -> bool:
|
||||
"""
|
||||
Swipe between two points.
|
||||
|
||||
Args:
|
||||
start: Starting coordinates (x, y)
|
||||
end: Ending coordinates (x, y)
|
||||
duration: Swipe duration in seconds
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = ["idb", "ui", "swipe"]
|
||||
cmd.extend([str(start[0]), str(start[1]), str(end[0]), str(end[1])])
|
||||
|
||||
# IDB doesn't support duration directly, but we can add delay
|
||||
if duration != 0.3:
|
||||
cmd.extend(["--duration", str(int(duration * 1000))])
|
||||
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def scroll(self, direction: str, amount: int = 3) -> bool:
|
||||
"""
|
||||
Perform multiple small swipes to scroll.
|
||||
|
||||
Args:
|
||||
direction: up, down
|
||||
amount: Number of small swipes
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
for _ in range(amount):
|
||||
if not self.swipe(direction, distance_ratio=0.3):
|
||||
return False
|
||||
time.sleep(0.2) # Small delay between swipes
|
||||
return True
|
||||
|
||||
def tap_and_hold(self, x: int, y: int, duration: float = 2.0) -> bool:
|
||||
"""
|
||||
Long press at coordinates.
|
||||
|
||||
Args:
|
||||
x, y: Coordinates
|
||||
duration: Hold duration in seconds
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# IDB doesn't have native long press, simulate with tap
|
||||
# In real implementation, might need to use different approach
|
||||
cmd = ["idb", "ui", "tap", str(x), str(y)]
|
||||
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
# Simulate hold with delay
|
||||
time.sleep(duration)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def pinch(self, direction: str = "out", center: tuple[int, int] | None = None) -> bool:
|
||||
"""
|
||||
Perform pinch gesture (zoom in/out).
|
||||
|
||||
Args:
|
||||
direction: 'in' (zoom out) or 'out' (zoom in)
|
||||
center: Center point for pinch
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if not center:
|
||||
width, height = self.screen_size
|
||||
center = (width // 2, height // 2)
|
||||
|
||||
# Calculate pinch points
|
||||
offset = 100 if direction == "out" else 50
|
||||
|
||||
if direction == "out":
|
||||
# Zoom in - fingers move apart
|
||||
start1 = (center[0] - 20, center[1] - 20)
|
||||
end1 = (center[0] - offset, center[1] - offset)
|
||||
start2 = (center[0] + 20, center[1] + 20)
|
||||
end2 = (center[0] + offset, center[1] + offset)
|
||||
else:
|
||||
# Zoom out - fingers move together
|
||||
start1 = (center[0] - offset, center[1] - offset)
|
||||
end1 = (center[0] - 20, center[1] - 20)
|
||||
start2 = (center[0] + offset, center[1] + offset)
|
||||
end2 = (center[0] + 20, center[1] + 20)
|
||||
|
||||
# Perform two swipes simultaneously (simulated)
|
||||
success1 = self.swipe_between(start1, end1)
|
||||
success2 = self.swipe_between(start2, end2)
|
||||
|
||||
return success1 and success2
|
||||
|
||||
def drag_and_drop(self, start: tuple[int, int], end: tuple[int, int]) -> bool:
|
||||
"""
|
||||
Drag element from one position to another.
|
||||
|
||||
Args:
|
||||
start: Starting coordinates
|
||||
end: Ending coordinates
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# Use slow swipe to simulate drag
|
||||
return self.swipe_between(start, end, duration=1.0)
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Pull to refresh gesture."""
|
||||
width, _ = self.screen_size
|
||||
start = (width // 2, 100)
|
||||
end = (width // 2, 400)
|
||||
return self.swipe_between(start, end)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Perform gestures on iOS simulator")
|
||||
|
||||
# Gesture options
|
||||
parser.add_argument(
|
||||
"--swipe", choices=["up", "down", "left", "right"], help="Perform directional swipe"
|
||||
)
|
||||
parser.add_argument("--swipe-from", help="Custom swipe start coordinates (x,y)")
|
||||
parser.add_argument("--swipe-to", help="Custom swipe end coordinates (x,y)")
|
||||
parser.add_argument(
|
||||
"--scroll", choices=["up", "down"], help="Scroll in direction (multiple small swipes)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scroll-amount", type=int, default=3, help="Number of scroll swipes (default: 3)"
|
||||
)
|
||||
parser.add_argument("--long-press", help="Long press at coordinates (x,y)")
|
||||
parser.add_argument(
|
||||
"--duration", type=float, default=2.0, help="Duration for long press in seconds"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pinch", choices=["in", "out"], help="Pinch gesture (in=zoom out, out=zoom in)"
|
||||
)
|
||||
parser.add_argument("--refresh", action="store_true", help="Pull to refresh gesture")
|
||||
|
||||
# Coordinate transformation
|
||||
parser.add_argument(
|
||||
"--screenshot-coords",
|
||||
action="store_true",
|
||||
help="Interpret swipe coordinates as from a screenshot (requires --screenshot-width/height)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-width",
|
||||
type=int,
|
||||
help="Screenshot width for coordinate transformation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-height",
|
||||
type=int,
|
||||
help="Screenshot height for coordinate transformation",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
controller = GestureController(udid=udid)
|
||||
|
||||
# Execute requested gesture
|
||||
if args.swipe:
|
||||
if controller.swipe(args.swipe):
|
||||
print(f"Swiped {args.swipe}")
|
||||
else:
|
||||
print(f"Failed to swipe {args.swipe}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.swipe_from and args.swipe_to:
|
||||
# Custom swipe
|
||||
start = tuple(map(int, args.swipe_from.split(",")))
|
||||
end = tuple(map(int, args.swipe_to.split(",")))
|
||||
|
||||
# Handle coordinate transformation if requested
|
||||
if args.screenshot_coords:
|
||||
if not args.screenshot_width or not args.screenshot_height:
|
||||
print(
|
||||
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
device_w, device_h = get_device_screen_size(udid)
|
||||
start = transform_screenshot_coords(
|
||||
start[0],
|
||||
start[1],
|
||||
args.screenshot_width,
|
||||
args.screenshot_height,
|
||||
device_w,
|
||||
device_h,
|
||||
)
|
||||
end = transform_screenshot_coords(
|
||||
end[0],
|
||||
end[1],
|
||||
args.screenshot_width,
|
||||
args.screenshot_height,
|
||||
device_w,
|
||||
device_h,
|
||||
)
|
||||
print("Transformed screenshot coords to device coords")
|
||||
|
||||
if controller.swipe_between(start, end):
|
||||
print(f"Swiped from {start} to {end}")
|
||||
else:
|
||||
print("Failed to swipe")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.scroll:
|
||||
if controller.scroll(args.scroll, args.scroll_amount):
|
||||
print(f"Scrolled {args.scroll} ({args.scroll_amount}x)")
|
||||
else:
|
||||
print(f"Failed to scroll {args.scroll}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.long_press:
|
||||
coords = tuple(map(int, args.long_press.split(",")))
|
||||
if controller.tap_and_hold(coords[0], coords[1], args.duration):
|
||||
print(f"Long pressed at {coords} for {args.duration}s")
|
||||
else:
|
||||
print("Failed to long press")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.pinch:
|
||||
if controller.pinch(args.pinch):
|
||||
action = "Zoomed in" if args.pinch == "out" else "Zoomed out"
|
||||
print(action)
|
||||
else:
|
||||
print(f"Failed to pinch {args.pinch}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.refresh:
|
||||
if controller.refresh():
|
||||
print("Performed pull to refresh")
|
||||
else:
|
||||
print("Failed to refresh")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Keyboard Controller - Text Entry and Hardware Buttons
|
||||
|
||||
Handles keyboard input, special keys, and hardware button simulation.
|
||||
Token-efficient text entry and navigation control.
|
||||
|
||||
This script provides text input and hardware button control for iOS simulator
|
||||
automation. It handles both typing text strings and pressing special keys like
|
||||
return, delete, tab, etc. Also controls hardware buttons like home and lock.
|
||||
|
||||
Key Features:
|
||||
- Type text strings into focused elements
|
||||
- Press special keys (return, delete, tab, space, arrows)
|
||||
- Hardware button simulation (home, lock, volume, screenshot)
|
||||
- Character-by-character typing with delays (for animations)
|
||||
- Multiple key press support
|
||||
- iOS HID key code mapping for reliability
|
||||
|
||||
Usage Examples:
|
||||
# Type text into focused field
|
||||
python scripts/keyboard.py --type "hello@example.com" --udid <device-id>
|
||||
|
||||
# Press return key to submit
|
||||
python scripts/keyboard.py --key return --udid <device-id>
|
||||
|
||||
# Press delete 3 times
|
||||
python scripts/keyboard.py --key delete --key delete --key delete --udid <device-id>
|
||||
|
||||
# Press home button
|
||||
python scripts/keyboard.py --button home --udid <device-id>
|
||||
|
||||
# Press lock button
|
||||
python scripts/keyboard.py --button lock --udid <device-id>
|
||||
|
||||
# Type with delay between characters (for animations)
|
||||
python scripts/keyboard.py --type "slow typing" --delay 0.1 --udid <device-id>
|
||||
|
||||
Output Format:
|
||||
Typed: "hello@example.com"
|
||||
Pressed return
|
||||
Pressed home button
|
||||
|
||||
Special Keys Supported:
|
||||
- return/enter: Submit forms, new lines (HID code 40)
|
||||
- delete/backspace: Remove characters (HID code 42)
|
||||
- tab: Navigate between fields (HID code 43)
|
||||
- space: Space character (HID code 44)
|
||||
- escape: Cancel/dismiss (HID code 41)
|
||||
- up/down/left/right: Arrow keys (HID codes 82/81/80/79)
|
||||
|
||||
Hardware Buttons Supported:
|
||||
- home: Return to home screen
|
||||
- lock/power: Lock device
|
||||
- volume-up/volume-down: Volume control
|
||||
- ringer: Toggle mute
|
||||
- screenshot: Capture screen
|
||||
|
||||
Technical Details:
|
||||
- Uses `idb ui text` for typing text strings
|
||||
- Uses `idb ui key <code>` for special keys with iOS HID codes
|
||||
- HID codes from Apple's UIKeyboardHIDUsage specification
|
||||
- Hardware buttons use `xcrun simctl` button actions
|
||||
- Text entry works on currently focused element
|
||||
- Special keys are integers (40=Return, 42=Delete, etc.)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from common import resolve_udid
|
||||
|
||||
|
||||
class KeyboardController:
|
||||
"""Controls keyboard and hardware buttons on iOS simulator."""
|
||||
|
||||
# Special key mappings to iOS HID key codes
|
||||
# See: https://developer.apple.com/documentation/uikit/uikeyboardhidusage
|
||||
SPECIAL_KEYS = {
|
||||
"return": 40,
|
||||
"enter": 40,
|
||||
"delete": 42,
|
||||
"backspace": 42,
|
||||
"tab": 43,
|
||||
"space": 44,
|
||||
"escape": 41,
|
||||
"up": 82,
|
||||
"down": 81,
|
||||
"left": 80,
|
||||
"right": 79,
|
||||
}
|
||||
|
||||
# Hardware button mappings
|
||||
HARDWARE_BUTTONS = {
|
||||
"home": "HOME",
|
||||
"lock": "LOCK",
|
||||
"volume-up": "VOLUME_UP",
|
||||
"volume-down": "VOLUME_DOWN",
|
||||
"ringer": "RINGER",
|
||||
"power": "LOCK", # Alias
|
||||
"screenshot": "SCREENSHOT",
|
||||
}
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize keyboard controller."""
|
||||
self.udid = udid
|
||||
|
||||
def type_text(self, text: str, delay: float = 0.0) -> bool:
|
||||
"""
|
||||
Type text into current focus.
|
||||
|
||||
Args:
|
||||
text: Text to type
|
||||
delay: Delay between characters (for slow typing effect)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if delay > 0:
|
||||
# Type character by character with delay
|
||||
for char in text:
|
||||
if not self._type_single(char):
|
||||
return False
|
||||
time.sleep(delay)
|
||||
return True
|
||||
# Type all at once (efficient)
|
||||
return self._type_single(text)
|
||||
|
||||
def _type_single(self, text: str) -> bool:
|
||||
"""Type text using IDB."""
|
||||
cmd = ["idb", "ui", "text", text]
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def press_key(self, key: str, count: int = 1) -> bool:
|
||||
"""
|
||||
Press a special key.
|
||||
|
||||
Args:
|
||||
key: Key name (return, delete, tab, etc.)
|
||||
count: Number of times to press
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# Map key name to IDB key code
|
||||
key_code = self.SPECIAL_KEYS.get(key.lower())
|
||||
if not key_code:
|
||||
# Try as literal integer key code
|
||||
try:
|
||||
key_code = int(key)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
cmd = ["idb", "ui", "key", str(key_code)]
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
for _ in range(count):
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
if count > 1:
|
||||
time.sleep(0.1) # Small delay for multiple presses
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def press_key_sequence(self, keys: list[str]) -> bool:
|
||||
"""
|
||||
Press a sequence of keys.
|
||||
|
||||
Args:
|
||||
keys: List of key names
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd_base = ["idb", "ui", "key-sequence"]
|
||||
|
||||
# Map keys to codes
|
||||
mapped_keys = []
|
||||
for key in keys:
|
||||
mapped = self.SPECIAL_KEYS.get(key.lower())
|
||||
if mapped is None:
|
||||
# Try as integer
|
||||
try:
|
||||
mapped = int(key)
|
||||
except ValueError:
|
||||
return False
|
||||
mapped_keys.append(str(mapped))
|
||||
|
||||
cmd = cmd_base + mapped_keys
|
||||
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def press_hardware_button(self, button: str) -> bool:
|
||||
"""
|
||||
Press hardware button.
|
||||
|
||||
Args:
|
||||
button: Button name (home, lock, volume-up, etc.)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
button_code = self.HARDWARE_BUTTONS.get(button.lower())
|
||||
if not button_code:
|
||||
return False
|
||||
|
||||
cmd = ["idb", "ui", "button", button_code]
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def clear_text(self, select_all: bool = True) -> bool:
|
||||
"""
|
||||
Clear text in current field.
|
||||
|
||||
Args:
|
||||
select_all: Use Cmd+A to select all first
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if select_all:
|
||||
# Select all then delete
|
||||
# Note: This might need adjustment for iOS keyboard shortcuts
|
||||
success = self.press_key_combo(["cmd", "a"])
|
||||
if success:
|
||||
return self.press_key("delete")
|
||||
else:
|
||||
# Just delete multiple times
|
||||
return self.press_key("delete", count=50)
|
||||
return None
|
||||
|
||||
def press_key_combo(self, keys: list[str]) -> bool:
|
||||
"""
|
||||
Press key combination (like Cmd+A).
|
||||
|
||||
Args:
|
||||
keys: List of keys to press together
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# IDB doesn't directly support key combos
|
||||
# This is a workaround - may need platform-specific handling
|
||||
if "cmd" in keys or "command" in keys:
|
||||
# Handle common shortcuts
|
||||
if "a" in keys:
|
||||
# Select all - might work with key sequence
|
||||
return self.press_key_sequence(["command", "a"])
|
||||
if "c" in keys:
|
||||
return self.press_key_sequence(["command", "c"])
|
||||
if "v" in keys:
|
||||
return self.press_key_sequence(["command", "v"])
|
||||
if "x" in keys:
|
||||
return self.press_key_sequence(["command", "x"])
|
||||
|
||||
# Try as sequence
|
||||
return self.press_key_sequence(keys)
|
||||
|
||||
def dismiss_keyboard(self) -> bool:
|
||||
"""Dismiss on-screen keyboard."""
|
||||
# Common ways to dismiss keyboard on iOS
|
||||
# Try Done button first, then Return
|
||||
success = self.press_key("return")
|
||||
if not success:
|
||||
# Try tapping outside (would need coordinate)
|
||||
pass
|
||||
return success
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Control keyboard and hardware buttons")
|
||||
|
||||
# Text input
|
||||
parser.add_argument("--type", help="Type text into current focus")
|
||||
parser.add_argument("--slow", action="store_true", help="Type slowly (character by character)")
|
||||
|
||||
# Special keys
|
||||
parser.add_argument("--key", help="Press special key (return, delete, tab, space, etc.)")
|
||||
parser.add_argument("--key-sequence", help="Press key sequence (comma-separated)")
|
||||
parser.add_argument("--count", type=int, default=1, help="Number of times to press key")
|
||||
|
||||
# Hardware buttons
|
||||
parser.add_argument(
|
||||
"--button",
|
||||
choices=["home", "lock", "volume-up", "volume-down", "ringer", "screenshot"],
|
||||
help="Press hardware button",
|
||||
)
|
||||
|
||||
# Other operations
|
||||
parser.add_argument("--clear", action="store_true", help="Clear current text field")
|
||||
parser.add_argument("--dismiss", action="store_true", help="Dismiss keyboard")
|
||||
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
controller = KeyboardController(udid=udid)
|
||||
|
||||
# Execute requested action
|
||||
if args.type:
|
||||
delay = 0.1 if args.slow else 0.0
|
||||
if controller.type_text(args.type, delay):
|
||||
if args.slow:
|
||||
print(f'Typed: "{args.type}" (slowly)')
|
||||
else:
|
||||
print(f'Typed: "{args.type}"')
|
||||
else:
|
||||
print("Failed to type text")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.key:
|
||||
if controller.press_key(args.key, args.count):
|
||||
if args.count > 1:
|
||||
print(f"Pressed {args.key} ({args.count}x)")
|
||||
else:
|
||||
print(f"Pressed {args.key}")
|
||||
else:
|
||||
print(f"Failed to press {args.key}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.key_sequence:
|
||||
keys = args.key_sequence.split(",")
|
||||
if controller.press_key_sequence(keys):
|
||||
print(f"Pressed sequence: {' -> '.join(keys)}")
|
||||
else:
|
||||
print("Failed to press key sequence")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.button:
|
||||
if controller.press_hardware_button(args.button):
|
||||
print(f"Pressed {args.button} button")
|
||||
else:
|
||||
print(f"Failed to press {args.button}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.clear:
|
||||
if controller.clear_text():
|
||||
print("Cleared text field")
|
||||
else:
|
||||
print("Failed to clear text")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.dismiss:
|
||||
if controller.dismiss_keyboard():
|
||||
print("Dismissed keyboard")
|
||||
else:
|
||||
print("Failed to dismiss keyboard")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Simulator Log Monitoring and Analysis
|
||||
|
||||
Real-time log streaming from iOS simulators with intelligent filtering, error detection,
|
||||
and token-efficient summarization. Enhanced version of app_state_capture.py's log capture.
|
||||
|
||||
Features:
|
||||
- Real-time log streaming from booted simulators
|
||||
- Smart filtering by app bundle ID, subsystem, category, severity
|
||||
- Error/warning classification and deduplication
|
||||
- Duration-based or continuous follow mode
|
||||
- Token-efficient summaries with full logs saved to file
|
||||
- Integration with test_recorder and app_state_capture
|
||||
|
||||
Usage Examples:
|
||||
# Monitor app logs in real-time (follow mode)
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --follow
|
||||
|
||||
# Capture logs for specific duration
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
|
||||
|
||||
# Extract errors and warnings only from last 5 minutes
|
||||
python scripts/log_monitor.py --severity error,warning --last 5m
|
||||
|
||||
# Save logs to file
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
|
||||
|
||||
# Verbose output with full log lines
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LogMonitor:
|
||||
"""Monitor and analyze iOS simulator logs with intelligent filtering."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_bundle_id: str | None = None,
|
||||
device_udid: str | None = None,
|
||||
severity_filter: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize log monitor.
|
||||
|
||||
Args:
|
||||
app_bundle_id: Filter logs by app bundle ID
|
||||
device_udid: Device UDID (uses booted if not specified)
|
||||
severity_filter: List of severities to include (error, warning, info, debug)
|
||||
"""
|
||||
self.app_bundle_id = app_bundle_id
|
||||
self.device_udid = device_udid or "booted"
|
||||
self.severity_filter = severity_filter or ["error", "warning", "info", "debug"]
|
||||
|
||||
# Log storage
|
||||
self.log_lines: list[str] = []
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
self.info_messages: list[str] = []
|
||||
|
||||
# Statistics
|
||||
self.error_count = 0
|
||||
self.warning_count = 0
|
||||
self.info_count = 0
|
||||
self.debug_count = 0
|
||||
self.total_lines = 0
|
||||
|
||||
# Deduplication
|
||||
self.seen_messages: set[str] = set()
|
||||
|
||||
# Process control
|
||||
self.log_process: subprocess.Popen | None = None
|
||||
self.interrupted = False
|
||||
|
||||
def parse_time_duration(self, duration_str: str) -> float:
|
||||
"""
|
||||
Parse duration string to seconds.
|
||||
|
||||
Args:
|
||||
duration_str: Duration like "30s", "5m", "1h"
|
||||
|
||||
Returns:
|
||||
Duration in seconds
|
||||
"""
|
||||
match = re.match(r"(\d+)([smh])", duration_str.lower())
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"Invalid duration format: {duration_str}. Use format like '30s', '5m', '1h'"
|
||||
)
|
||||
|
||||
value, unit = match.groups()
|
||||
value = int(value)
|
||||
|
||||
if unit == "s":
|
||||
return value
|
||||
if unit == "m":
|
||||
return value * 60
|
||||
if unit == "h":
|
||||
return value * 3600
|
||||
|
||||
return 0
|
||||
|
||||
def classify_log_line(self, line: str) -> str | None:
|
||||
"""
|
||||
Classify log line by severity.
|
||||
|
||||
Args:
|
||||
line: Log line to classify
|
||||
|
||||
Returns:
|
||||
Severity level (error, warning, info, debug) or None
|
||||
"""
|
||||
line_lower = line.lower()
|
||||
|
||||
# Error patterns
|
||||
error_patterns = [
|
||||
r"\berror\b",
|
||||
r"\bfault\b",
|
||||
r"\bfailed\b",
|
||||
r"\bexception\b",
|
||||
r"\bcrash\b",
|
||||
r"❌",
|
||||
]
|
||||
|
||||
# Warning patterns
|
||||
warning_patterns = [r"\bwarning\b", r"\bwarn\b", r"\bdeprecated\b", r"⚠️"]
|
||||
|
||||
# Info patterns
|
||||
info_patterns = [r"\binfo\b", r"\bnotice\b", r"ℹ️"]
|
||||
|
||||
for pattern in error_patterns:
|
||||
if re.search(pattern, line_lower):
|
||||
return "error"
|
||||
|
||||
for pattern in warning_patterns:
|
||||
if re.search(pattern, line_lower):
|
||||
return "warning"
|
||||
|
||||
for pattern in info_patterns:
|
||||
if re.search(pattern, line_lower):
|
||||
return "info"
|
||||
|
||||
return "debug"
|
||||
|
||||
def deduplicate_message(self, line: str) -> bool:
|
||||
"""
|
||||
Check if message is duplicate.
|
||||
|
||||
Args:
|
||||
line: Log line
|
||||
|
||||
Returns:
|
||||
True if this is a new message, False if duplicate
|
||||
"""
|
||||
# Create signature by removing timestamps and process IDs
|
||||
signature = re.sub(r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}", "", line)
|
||||
signature = re.sub(r"\[\d+\]", "", signature)
|
||||
signature = re.sub(r"\s+", " ", signature).strip()
|
||||
|
||||
if signature in self.seen_messages:
|
||||
return False
|
||||
|
||||
self.seen_messages.add(signature)
|
||||
return True
|
||||
|
||||
def process_log_line(self, line: str):
|
||||
"""
|
||||
Process a single log line.
|
||||
|
||||
Args:
|
||||
line: Log line to process
|
||||
"""
|
||||
if not line.strip():
|
||||
return
|
||||
|
||||
self.total_lines += 1
|
||||
self.log_lines.append(line)
|
||||
|
||||
# Classify severity
|
||||
severity = self.classify_log_line(line)
|
||||
|
||||
# Skip if not in filter
|
||||
if severity not in self.severity_filter:
|
||||
return
|
||||
|
||||
# Deduplicate (for errors and warnings)
|
||||
if severity in ["error", "warning"] and not self.deduplicate_message(line):
|
||||
return
|
||||
|
||||
# Store by severity
|
||||
if severity == "error":
|
||||
self.error_count += 1
|
||||
self.errors.append(line)
|
||||
elif severity == "warning":
|
||||
self.warning_count += 1
|
||||
self.warnings.append(line)
|
||||
elif severity == "info":
|
||||
self.info_count += 1
|
||||
if len(self.info_messages) < 20: # Keep only recent info
|
||||
self.info_messages.append(line)
|
||||
else: # debug
|
||||
self.debug_count += 1
|
||||
|
||||
def stream_logs(
|
||||
self,
|
||||
follow: bool = False,
|
||||
duration: float | None = None,
|
||||
last_minutes: float | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Stream logs from simulator.
|
||||
|
||||
Args:
|
||||
follow: Follow mode (continuous streaming)
|
||||
duration: Capture duration in seconds
|
||||
last_minutes: Show logs from last N minutes
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Build log stream command
|
||||
cmd = ["xcrun", "simctl", "spawn", self.device_udid, "log", "stream"]
|
||||
|
||||
# Add filters
|
||||
if self.app_bundle_id:
|
||||
# Filter by process name (extracted from bundle ID)
|
||||
app_name = self.app_bundle_id.split(".")[-1]
|
||||
cmd.extend(["--predicate", f'processImagePath CONTAINS "{app_name}"'])
|
||||
|
||||
# Add time filter for historical logs
|
||||
if last_minutes:
|
||||
start_time = datetime.now() - timedelta(minutes=last_minutes)
|
||||
time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
cmd.extend(["--start", time_str])
|
||||
|
||||
# Setup signal handler for graceful interruption
|
||||
def signal_handler(sig, frame):
|
||||
self.interrupted = True
|
||||
if self.log_process:
|
||||
self.log_process.terminate()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
try:
|
||||
# Start log streaming process
|
||||
self.log_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1, # Line buffered
|
||||
)
|
||||
|
||||
# Track start time for duration
|
||||
start_time = datetime.now()
|
||||
|
||||
# Process log lines
|
||||
for line in iter(self.log_process.stdout.readline, ""):
|
||||
if not line:
|
||||
break
|
||||
|
||||
# Process the line
|
||||
self.process_log_line(line.rstrip())
|
||||
|
||||
# Print in follow mode
|
||||
if follow:
|
||||
severity = self.classify_log_line(line)
|
||||
if severity in self.severity_filter:
|
||||
print(line.rstrip())
|
||||
|
||||
# Check duration
|
||||
if duration and (datetime.now() - start_time).total_seconds() >= duration:
|
||||
break
|
||||
|
||||
# Check if interrupted
|
||||
if self.interrupted:
|
||||
break
|
||||
|
||||
# Wait for process to finish
|
||||
self.log_process.wait()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error streaming logs: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
finally:
|
||||
if self.log_process:
|
||||
self.log_process.terminate()
|
||||
|
||||
def get_summary(self, verbose: bool = False) -> str:
|
||||
"""
|
||||
Get log summary.
|
||||
|
||||
Args:
|
||||
verbose: Include full log details
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
if self.app_bundle_id:
|
||||
lines.append(f"Logs for: {self.app_bundle_id}")
|
||||
else:
|
||||
lines.append("Logs for: All processes")
|
||||
|
||||
# Statistics
|
||||
lines.append(f"Total lines: {self.total_lines}")
|
||||
lines.append(
|
||||
f"Errors: {self.error_count}, Warnings: {self.warning_count}, Info: {self.info_count}"
|
||||
)
|
||||
|
||||
# Top issues
|
||||
if self.errors:
|
||||
lines.append(f"\nTop Errors ({len(self.errors)}):")
|
||||
for error in self.errors[:5]: # Show first 5
|
||||
lines.append(f" ❌ {error[:120]}") # Truncate long lines
|
||||
|
||||
if self.warnings:
|
||||
lines.append(f"\nTop Warnings ({len(self.warnings)}):")
|
||||
for warning in self.warnings[:5]: # Show first 5
|
||||
lines.append(f" ⚠️ {warning[:120]}")
|
||||
|
||||
# Verbose output
|
||||
if verbose and self.log_lines:
|
||||
lines.append("\n=== Recent Log Lines ===")
|
||||
for line in self.log_lines[-50:]: # Last 50 lines
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_json_output(self) -> dict:
|
||||
"""Get log results as JSON."""
|
||||
return {
|
||||
"app_bundle_id": self.app_bundle_id,
|
||||
"device_udid": self.device_udid,
|
||||
"statistics": {
|
||||
"total_lines": self.total_lines,
|
||||
"errors": self.error_count,
|
||||
"warnings": self.warning_count,
|
||||
"info": self.info_count,
|
||||
"debug": self.debug_count,
|
||||
},
|
||||
"errors": self.errors[:20], # Limit to 20
|
||||
"warnings": self.warnings[:20],
|
||||
"sample_logs": self.log_lines[-50:], # Last 50 lines
|
||||
}
|
||||
|
||||
def save_logs(self, output_dir: str) -> str:
|
||||
"""
|
||||
Save logs to file.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to save logs
|
||||
|
||||
Returns:
|
||||
Path to saved log file
|
||||
"""
|
||||
# Create output directory
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
app_name = self.app_bundle_id.split(".")[-1] if self.app_bundle_id else "simulator"
|
||||
log_file = output_path / f"{app_name}-{timestamp}.log"
|
||||
|
||||
# Write all log lines
|
||||
with open(log_file, "w") as f:
|
||||
f.write("\n".join(self.log_lines))
|
||||
|
||||
# Also save JSON summary
|
||||
json_file = output_path / f"{app_name}-{timestamp}-summary.json"
|
||||
with open(json_file, "w") as f:
|
||||
json.dump(self.get_json_output(), f, indent=2)
|
||||
|
||||
return str(log_file)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Monitor and analyze iOS simulator logs",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Monitor app in real-time
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --follow
|
||||
|
||||
# Capture logs for 30 seconds
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --duration 30s
|
||||
|
||||
# Show errors/warnings from last 5 minutes
|
||||
python scripts/log_monitor.py --severity error,warning --last 5m
|
||||
|
||||
# Save logs to file
|
||||
python scripts/log_monitor.py --app com.myapp.MyApp --duration 1m --output logs/
|
||||
""",
|
||||
)
|
||||
|
||||
# Filtering options
|
||||
parser.add_argument(
|
||||
"--app", dest="app_bundle_id", help="App bundle ID to filter logs (e.g., com.myapp.MyApp)"
|
||||
)
|
||||
parser.add_argument("--device-udid", help="Device UDID (uses booted if not specified)")
|
||||
parser.add_argument(
|
||||
"--severity", help="Comma-separated severity levels (error,warning,info,debug)"
|
||||
)
|
||||
|
||||
# Time options
|
||||
time_group = parser.add_mutually_exclusive_group()
|
||||
time_group.add_argument(
|
||||
"--follow", action="store_true", help="Follow mode (continuous streaming)"
|
||||
)
|
||||
time_group.add_argument("--duration", help="Capture duration (e.g., 30s, 5m, 1h)")
|
||||
time_group.add_argument(
|
||||
"--last", dest="last_minutes", help="Show logs from last N minutes (e.g., 5m)"
|
||||
)
|
||||
|
||||
# Output options
|
||||
parser.add_argument("--output", help="Save logs to directory")
|
||||
parser.add_argument("--verbose", action="store_true", help="Show detailed output")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse severity filter
|
||||
severity_filter = None
|
||||
if args.severity:
|
||||
severity_filter = [s.strip().lower() for s in args.severity.split(",")]
|
||||
|
||||
# Initialize monitor
|
||||
monitor = LogMonitor(
|
||||
app_bundle_id=args.app_bundle_id,
|
||||
device_udid=args.device_udid,
|
||||
severity_filter=severity_filter,
|
||||
)
|
||||
|
||||
# Parse duration
|
||||
duration = None
|
||||
if args.duration:
|
||||
duration = monitor.parse_time_duration(args.duration)
|
||||
|
||||
# Parse last minutes
|
||||
last_minutes = None
|
||||
if args.last_minutes:
|
||||
last_minutes = monitor.parse_time_duration(args.last_minutes) / 60
|
||||
|
||||
# Stream logs
|
||||
print("Monitoring logs...", file=sys.stderr)
|
||||
if args.app_bundle_id:
|
||||
print(f"App: {args.app_bundle_id}", file=sys.stderr)
|
||||
|
||||
success = monitor.stream_logs(follow=args.follow, duration=duration, last_minutes=last_minutes)
|
||||
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
# Save logs if requested
|
||||
if args.output:
|
||||
log_file = monitor.save_logs(args.output)
|
||||
print(f"\nLogs saved to: {log_file}", file=sys.stderr)
|
||||
|
||||
# Output results
|
||||
if not args.follow: # Don't show summary in follow mode
|
||||
if args.json:
|
||||
print(json.dumps(monitor.get_json_output(), indent=2))
|
||||
else:
|
||||
print("\n" + monitor.get_summary(verbose=args.verbose))
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Simulator Navigator - Smart Element Finder and Interactor
|
||||
|
||||
Finds and interacts with UI elements using accessibility data.
|
||||
Prioritizes structured navigation over pixel-based interaction.
|
||||
|
||||
This script is the core automation tool for iOS simulator navigation. It finds
|
||||
UI elements by text, type, or accessibility ID and performs actions on them
|
||||
(tap, enter text). Uses semantic element finding instead of fragile pixel coordinates.
|
||||
|
||||
Key Features:
|
||||
- Find elements by text (fuzzy or exact matching)
|
||||
- Find elements by type (Button, TextField, etc.)
|
||||
- Find elements by accessibility identifier
|
||||
- Tap elements at their center point
|
||||
- Enter text into text fields
|
||||
- List all tappable elements on screen
|
||||
- Automatic element caching for performance
|
||||
|
||||
Usage Examples:
|
||||
# Find and tap a button by text
|
||||
python scripts/navigator.py --find-text "Login" --tap --udid <device-id>
|
||||
|
||||
# Enter text into first text field
|
||||
python scripts/navigator.py --find-type TextField --index 0 --enter-text "username" --udid <device-id>
|
||||
|
||||
# Tap element by accessibility ID
|
||||
python scripts/navigator.py --find-id "submitButton" --tap --udid <device-id>
|
||||
|
||||
# List all interactive elements
|
||||
python scripts/navigator.py --list --udid <device-id>
|
||||
|
||||
# Tap at specific coordinates (fallback)
|
||||
python scripts/navigator.py --tap-at 200,400 --udid <device-id>
|
||||
|
||||
Output Format:
|
||||
Tapped: Button "Login" at (320, 450)
|
||||
Entered text in: TextField "Username"
|
||||
Not found: text='Submit'
|
||||
|
||||
Navigation Priority (best to worst):
|
||||
1. Find by accessibility label/text (most reliable)
|
||||
2. Find by element type + index (good for forms)
|
||||
3. Find by accessibility ID (precise but app-specific)
|
||||
4. Tap at coordinates (last resort, fragile)
|
||||
|
||||
Technical Details:
|
||||
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
|
||||
- Caches tree for multiple operations (call with force_refresh to update)
|
||||
- Finds elements by parsing tree recursively
|
||||
- Calculates tap coordinates from element frame center
|
||||
- Uses `idb ui tap` for tapping, `idb ui text` for text entry
|
||||
- Extracts data from AXLabel, AXValue, and AXUniqueId fields
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from common import (
|
||||
flatten_tree,
|
||||
get_accessibility_tree,
|
||||
get_device_screen_size,
|
||||
resolve_udid,
|
||||
transform_screenshot_coords,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Element:
|
||||
"""Represents a UI element from accessibility tree."""
|
||||
|
||||
type: str
|
||||
label: str | None
|
||||
value: str | None
|
||||
identifier: str | None
|
||||
frame: dict[str, float]
|
||||
traits: list[str]
|
||||
enabled: bool = True
|
||||
|
||||
@property
|
||||
def center(self) -> tuple[int, int]:
|
||||
"""Calculate center point for tapping."""
|
||||
x = int(self.frame["x"] + self.frame["width"] / 2)
|
||||
y = int(self.frame["y"] + self.frame["height"] / 2)
|
||||
return (x, y)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Human-readable description."""
|
||||
label = self.label or self.value or self.identifier or "Unnamed"
|
||||
return f'{self.type} "{label}"'
|
||||
|
||||
|
||||
class Navigator:
|
||||
"""Navigates iOS apps using accessibility data."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize navigator with optional device UDID."""
|
||||
self.udid = udid
|
||||
self._tree_cache = None
|
||||
|
||||
def get_accessibility_tree(self, force_refresh: bool = False) -> dict:
|
||||
"""Get accessibility tree (cached for efficiency)."""
|
||||
if self._tree_cache and not force_refresh:
|
||||
return self._tree_cache
|
||||
|
||||
# Delegate to shared utility
|
||||
self._tree_cache = get_accessibility_tree(self.udid, nested=True)
|
||||
return self._tree_cache
|
||||
|
||||
def _flatten_tree(self, node: dict, elements: list[Element] | None = None) -> list[Element]:
|
||||
"""Flatten accessibility tree into list of elements."""
|
||||
if elements is None:
|
||||
elements = []
|
||||
|
||||
# Create element from node
|
||||
if node.get("type"):
|
||||
element = Element(
|
||||
type=node.get("type", "Unknown"),
|
||||
label=node.get("AXLabel"),
|
||||
value=node.get("AXValue"),
|
||||
identifier=node.get("AXUniqueId"),
|
||||
frame=node.get("frame", {}),
|
||||
traits=node.get("traits", []),
|
||||
enabled=node.get("enabled", True),
|
||||
)
|
||||
elements.append(element)
|
||||
|
||||
# Process children
|
||||
for child in node.get("children", []):
|
||||
self._flatten_tree(child, elements)
|
||||
|
||||
return elements
|
||||
|
||||
def find_element(
|
||||
self,
|
||||
text: str | None = None,
|
||||
element_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
index: int = 0,
|
||||
fuzzy: bool = True,
|
||||
) -> Element | None:
|
||||
"""
|
||||
Find element by various criteria.
|
||||
|
||||
Args:
|
||||
text: Text to search in label/value
|
||||
element_type: Type of element (Button, TextField, etc.)
|
||||
identifier: Accessibility identifier
|
||||
index: Which matching element to return (0-based)
|
||||
fuzzy: Use fuzzy matching for text
|
||||
|
||||
Returns:
|
||||
Element if found, None otherwise
|
||||
"""
|
||||
tree = self.get_accessibility_tree()
|
||||
elements = self._flatten_tree(tree)
|
||||
|
||||
matches = []
|
||||
|
||||
for elem in elements:
|
||||
# Skip disabled elements
|
||||
if not elem.enabled:
|
||||
continue
|
||||
|
||||
# Check type
|
||||
if element_type and elem.type != element_type:
|
||||
continue
|
||||
|
||||
# Check identifier (exact match)
|
||||
if identifier and elem.identifier != identifier:
|
||||
continue
|
||||
|
||||
# Check text (in label or value)
|
||||
if text:
|
||||
elem_text = (elem.label or "") + " " + (elem.value or "")
|
||||
if fuzzy:
|
||||
if text.lower() not in elem_text.lower():
|
||||
continue
|
||||
elif text not in (elem.label, elem.value):
|
||||
continue
|
||||
|
||||
matches.append(elem)
|
||||
|
||||
if matches and index < len(matches):
|
||||
return matches[index]
|
||||
|
||||
return None
|
||||
|
||||
def tap(self, element: Element) -> bool:
|
||||
"""Tap on an element."""
|
||||
x, y = element.center
|
||||
return self.tap_at(x, y)
|
||||
|
||||
def tap_at(self, x: int, y: int) -> bool:
|
||||
"""Tap at specific coordinates."""
|
||||
cmd = ["idb", "ui", "tap", str(x), str(y)]
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def enter_text(self, text: str, element: Element | None = None) -> bool:
|
||||
"""
|
||||
Enter text into element or current focus.
|
||||
|
||||
Args:
|
||||
text: Text to enter
|
||||
element: Optional element to tap first
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# Tap element if provided
|
||||
if element:
|
||||
if not self.tap(element):
|
||||
return False
|
||||
# Small delay for focus
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Enter text
|
||||
cmd = ["idb", "ui", "text", text]
|
||||
if self.udid:
|
||||
cmd.extend(["--udid", self.udid])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def find_and_tap(
|
||||
self,
|
||||
text: str | None = None,
|
||||
element_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
index: int = 0,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Find element and tap it.
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
element = self.find_element(text, element_type, identifier, index)
|
||||
|
||||
if not element:
|
||||
criteria = []
|
||||
if text:
|
||||
criteria.append(f"text='{text}'")
|
||||
if element_type:
|
||||
criteria.append(f"type={element_type}")
|
||||
if identifier:
|
||||
criteria.append(f"id={identifier}")
|
||||
return (False, f"Not found: {', '.join(criteria)}")
|
||||
|
||||
if self.tap(element):
|
||||
return (True, f"Tapped: {element.description} at {element.center}")
|
||||
return (False, f"Failed to tap: {element.description}")
|
||||
|
||||
def find_and_enter_text(
|
||||
self,
|
||||
text_to_enter: str,
|
||||
find_text: str | None = None,
|
||||
element_type: str | None = "TextField",
|
||||
identifier: str | None = None,
|
||||
index: int = 0,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Find element and enter text into it.
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
element = self.find_element(find_text, element_type, identifier, index)
|
||||
|
||||
if not element:
|
||||
return (False, "TextField not found")
|
||||
|
||||
if self.enter_text(text_to_enter, element):
|
||||
return (True, f"Entered text in: {element.description}")
|
||||
return (False, "Failed to enter text")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Navigate iOS apps using accessibility data")
|
||||
|
||||
# Finding options
|
||||
parser.add_argument("--find-text", help="Find element by text (fuzzy match)")
|
||||
parser.add_argument("--find-exact", help="Find element by exact text")
|
||||
parser.add_argument("--find-type", help="Element type (Button, TextField, etc.)")
|
||||
parser.add_argument("--find-id", help="Accessibility identifier")
|
||||
parser.add_argument("--index", type=int, default=0, help="Which match to use (0-based)")
|
||||
|
||||
# Action options
|
||||
parser.add_argument("--tap", action="store_true", help="Tap the found element")
|
||||
parser.add_argument("--tap-at", help="Tap at coordinates (x,y)")
|
||||
parser.add_argument("--enter-text", help="Enter text into element")
|
||||
|
||||
# Coordinate transformation
|
||||
parser.add_argument(
|
||||
"--screenshot-coords",
|
||||
action="store_true",
|
||||
help="Interpret tap coordinates as from a screenshot (requires --screenshot-width/height)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-width",
|
||||
type=int,
|
||||
help="Screenshot width for coordinate transformation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--screenshot-height",
|
||||
type=int,
|
||||
help="Screenshot height for coordinate transformation",
|
||||
)
|
||||
|
||||
# Other options
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="List all tappable elements")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
navigator = Navigator(udid=udid)
|
||||
|
||||
# List mode
|
||||
if args.list:
|
||||
tree = navigator.get_accessibility_tree()
|
||||
elements = navigator._flatten_tree(tree)
|
||||
|
||||
# Filter to tappable elements
|
||||
tappable = [
|
||||
e
|
||||
for e in elements
|
||||
if e.enabled and e.type in ["Button", "Link", "Cell", "TextField", "SecureTextField"]
|
||||
]
|
||||
|
||||
print(f"Tappable elements ({len(tappable)}):")
|
||||
for elem in tappable[:10]: # Limit output for tokens
|
||||
print(f" {elem.type}: \"{elem.label or elem.value or 'Unnamed'}\" {elem.center}")
|
||||
|
||||
if len(tappable) > 10:
|
||||
print(f" ... and {len(tappable) - 10} more")
|
||||
sys.exit(0)
|
||||
|
||||
# Direct tap at coordinates
|
||||
if args.tap_at:
|
||||
coords = args.tap_at.split(",")
|
||||
if len(coords) != 2:
|
||||
print("Error: --tap-at requires x,y format")
|
||||
sys.exit(1)
|
||||
|
||||
x, y = int(coords[0]), int(coords[1])
|
||||
|
||||
# Handle coordinate transformation if requested
|
||||
if args.screenshot_coords:
|
||||
if not args.screenshot_width or not args.screenshot_height:
|
||||
print(
|
||||
"Error: --screenshot-coords requires --screenshot-width and --screenshot-height"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
device_w, device_h = get_device_screen_size(udid)
|
||||
x, y = transform_screenshot_coords(
|
||||
x,
|
||||
y,
|
||||
args.screenshot_width,
|
||||
args.screenshot_height,
|
||||
device_w,
|
||||
device_h,
|
||||
)
|
||||
print(
|
||||
f"Transformed screenshot coords ({coords[0]}, {coords[1]}) "
|
||||
f"to device coords ({x}, {y})"
|
||||
)
|
||||
|
||||
if navigator.tap_at(x, y):
|
||||
print(f"Tapped at ({x}, {y})")
|
||||
else:
|
||||
print(f"Failed to tap at ({x}, {y})")
|
||||
sys.exit(1)
|
||||
|
||||
# Find and tap
|
||||
elif args.tap:
|
||||
text = args.find_text or args.find_exact
|
||||
fuzzy = args.find_text is not None
|
||||
|
||||
success, message = navigator.find_and_tap(
|
||||
text=text, element_type=args.find_type, identifier=args.find_id, index=args.index
|
||||
)
|
||||
|
||||
print(message)
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
# Find and enter text
|
||||
elif args.enter_text:
|
||||
text = args.find_text or args.find_exact
|
||||
|
||||
success, message = navigator.find_and_enter_text(
|
||||
text_to_enter=args.enter_text,
|
||||
find_text=text,
|
||||
element_type=args.find_type or "TextField",
|
||||
identifier=args.find_id,
|
||||
index=args.index,
|
||||
)
|
||||
|
||||
print(message)
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
# Just find (no action)
|
||||
else:
|
||||
text = args.find_text or args.find_exact
|
||||
fuzzy = args.find_text is not None
|
||||
|
||||
element = navigator.find_element(
|
||||
text=text,
|
||||
element_type=args.find_type,
|
||||
identifier=args.find_id,
|
||||
index=args.index,
|
||||
fuzzy=fuzzy,
|
||||
)
|
||||
|
||||
if element:
|
||||
print(f"Found: {element.description} at {element.center}")
|
||||
else:
|
||||
print("Element not found")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Privacy & Permissions Manager
|
||||
|
||||
Grant/revoke app permissions for testing permission flows.
|
||||
Supports 13+ services with audit trail tracking.
|
||||
|
||||
Usage: python scripts/privacy_manager.py --grant camera --bundle-id com.app
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from common import resolve_udid
|
||||
|
||||
|
||||
class PrivacyManager:
|
||||
"""Manages iOS app privacy and permissions."""
|
||||
|
||||
# Supported services
|
||||
SUPPORTED_SERVICES = {
|
||||
"camera": "Camera access",
|
||||
"microphone": "Microphone access",
|
||||
"location": "Location services",
|
||||
"contacts": "Contacts access",
|
||||
"photos": "Photos library access",
|
||||
"calendar": "Calendar access",
|
||||
"health": "Health data access",
|
||||
"reminders": "Reminders access",
|
||||
"motion": "Motion & fitness",
|
||||
"keyboard": "Keyboard access",
|
||||
"mediaLibrary": "Media library",
|
||||
"calls": "Call history",
|
||||
"siri": "Siri access",
|
||||
}
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize privacy manager.
|
||||
|
||||
Args:
|
||||
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||
"""
|
||||
self.udid = udid
|
||||
|
||||
def grant_permission(
|
||||
self,
|
||||
bundle_id: str,
|
||||
service: str,
|
||||
scenario: str | None = None,
|
||||
step: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Grant permission for app.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle ID
|
||||
service: Service name (camera, microphone, location, etc.)
|
||||
scenario: Test scenario name for audit trail
|
||||
step: Step number in test scenario
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if service not in self.SUPPORTED_SERVICES:
|
||||
print(f"Error: Unknown service '{service}'")
|
||||
print(f"Supported: {', '.join(self.SUPPORTED_SERVICES.keys())}")
|
||||
return False
|
||||
|
||||
cmd = ["xcrun", "simctl", "privacy"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(["grant", service, bundle_id])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
|
||||
# Log audit entry
|
||||
self._log_audit("grant", bundle_id, service, scenario, step)
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def revoke_permission(
|
||||
self,
|
||||
bundle_id: str,
|
||||
service: str,
|
||||
scenario: str | None = None,
|
||||
step: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Revoke permission for app.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle ID
|
||||
service: Service name
|
||||
scenario: Test scenario name for audit trail
|
||||
step: Step number in test scenario
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if service not in self.SUPPORTED_SERVICES:
|
||||
print(f"Error: Unknown service '{service}'")
|
||||
return False
|
||||
|
||||
cmd = ["xcrun", "simctl", "privacy"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(["revoke", service, bundle_id])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
|
||||
# Log audit entry
|
||||
self._log_audit("revoke", bundle_id, service, scenario, step)
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def reset_permission(
|
||||
self,
|
||||
bundle_id: str,
|
||||
service: str,
|
||||
scenario: str | None = None,
|
||||
step: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Reset permission to default.
|
||||
|
||||
Args:
|
||||
bundle_id: App bundle ID
|
||||
service: Service name
|
||||
scenario: Test scenario name for audit trail
|
||||
step: Step number in test scenario
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if service not in self.SUPPORTED_SERVICES:
|
||||
print(f"Error: Unknown service '{service}'")
|
||||
return False
|
||||
|
||||
cmd = ["xcrun", "simctl", "privacy"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(["reset", service, bundle_id])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
|
||||
# Log audit entry
|
||||
self._log_audit("reset", bundle_id, service, scenario, step)
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _log_audit(
|
||||
action: str,
|
||||
bundle_id: str,
|
||||
service: str,
|
||||
scenario: str | None = None,
|
||||
step: int | None = None,
|
||||
) -> None:
|
||||
"""Log permission change to audit trail (for test tracking).
|
||||
|
||||
Args:
|
||||
action: grant, revoke, or reset
|
||||
bundle_id: App bundle ID
|
||||
service: Service name
|
||||
scenario: Test scenario name
|
||||
step: Step number
|
||||
"""
|
||||
# Could write to file, but for now just log to stdout for transparency
|
||||
timestamp = datetime.now().isoformat()
|
||||
location = f" (step {step})" if step else ""
|
||||
scenario_info = f" in {scenario}" if scenario else ""
|
||||
print(
|
||||
f"[Audit] {timestamp}: {action.upper()} {service} for {bundle_id}{scenario_info}{location}"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Manage iOS app privacy and permissions")
|
||||
|
||||
# Required
|
||||
parser.add_argument("--bundle-id", required=True, help="App bundle ID (e.g., com.example.app)")
|
||||
|
||||
# Action (mutually exclusive)
|
||||
action_group = parser.add_mutually_exclusive_group(required=True)
|
||||
action_group.add_argument(
|
||||
"--grant",
|
||||
help="Grant permission (service name or comma-separated list)",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--revoke", help="Revoke permission (service name or comma-separated list)"
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--reset",
|
||||
help="Reset permission to default (service name or comma-separated list)",
|
||||
)
|
||||
action_group.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List all supported services",
|
||||
)
|
||||
|
||||
# Test tracking
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
help="Test scenario name for audit trail",
|
||||
)
|
||||
parser.add_argument("--step", type=int, help="Step number in test scenario")
|
||||
|
||||
# Device
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# List supported services
|
||||
if args.list:
|
||||
print("Supported Privacy Services:\n")
|
||||
for service, description in PrivacyManager.SUPPORTED_SERVICES.items():
|
||||
print(f" {service:<15} - {description}")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(" python scripts/privacy_manager.py --grant camera --bundle-id com.app")
|
||||
print(" python scripts/privacy_manager.py --revoke location --bundle-id com.app")
|
||||
print(" python scripts/privacy_manager.py --grant camera,photos --bundle-id com.app")
|
||||
sys.exit(0)
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
manager = PrivacyManager(udid=udid)
|
||||
|
||||
# Parse service names (support comma-separated list)
|
||||
if args.grant:
|
||||
services = [s.strip() for s in args.grant.split(",")]
|
||||
action = "grant"
|
||||
action_fn = manager.grant_permission
|
||||
elif args.revoke:
|
||||
services = [s.strip() for s in args.revoke.split(",")]
|
||||
action = "revoke"
|
||||
action_fn = manager.revoke_permission
|
||||
else: # reset
|
||||
services = [s.strip() for s in args.reset.split(",")]
|
||||
action = "reset"
|
||||
action_fn = manager.reset_permission
|
||||
|
||||
# Execute action for each service
|
||||
all_success = True
|
||||
for service in services:
|
||||
if service not in PrivacyManager.SUPPORTED_SERVICES:
|
||||
print(f"Error: Unknown service '{service}'")
|
||||
all_success = False
|
||||
continue
|
||||
|
||||
success = action_fn(
|
||||
args.bundle_id,
|
||||
service,
|
||||
scenario=args.scenario,
|
||||
step=args.step,
|
||||
)
|
||||
|
||||
if success:
|
||||
description = PrivacyManager.SUPPORTED_SERVICES[service]
|
||||
print(f"✓ {action.capitalize()} {service}: {description}")
|
||||
else:
|
||||
print(f"✗ Failed to {action} {service}")
|
||||
all_success = False
|
||||
|
||||
if not all_success:
|
||||
sys.exit(1)
|
||||
|
||||
# Summary
|
||||
if len(services) > 1:
|
||||
print(f"\nPermissions {action}ed: {', '.join(services)}")
|
||||
|
||||
if args.scenario:
|
||||
print(f"Test scenario: {args.scenario}" + (f" (step {args.step})" if args.step else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Push Notification Simulator
|
||||
|
||||
Send simulated push notifications to test notification handling.
|
||||
Supports custom payloads and test tracking.
|
||||
|
||||
Usage: python scripts/push_notification.py --bundle-id com.app --title "Alert" --body "Message"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from common import resolve_udid
|
||||
|
||||
|
||||
class PushNotificationSender:
|
||||
"""Sends simulated push notifications to iOS simulator."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize push notification sender.
|
||||
|
||||
Args:
|
||||
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||
"""
|
||||
self.udid = udid
|
||||
|
||||
def send(
|
||||
self,
|
||||
bundle_id: str,
|
||||
payload: dict | str,
|
||||
_test_name: str | None = None,
|
||||
_expected_behavior: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send push notification to app.
|
||||
|
||||
Args:
|
||||
bundle_id: Target app bundle ID
|
||||
payload: Push payload (dict or JSON string) or path to JSON file
|
||||
test_name: Test scenario name for tracking
|
||||
expected_behavior: Expected behavior after notification arrives
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
# Handle different payload formats
|
||||
if isinstance(payload, str):
|
||||
# Check if it's a file path
|
||||
payload_path = Path(payload)
|
||||
if payload_path.exists():
|
||||
with open(payload_path) as f:
|
||||
payload_data = json.load(f)
|
||||
else:
|
||||
# Try to parse as JSON string
|
||||
try:
|
||||
payload_data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error: Invalid JSON payload: {payload}")
|
||||
return False
|
||||
else:
|
||||
payload_data = payload
|
||||
|
||||
# Ensure payload has aps dictionary
|
||||
if "aps" not in payload_data:
|
||||
payload_data = {"aps": payload_data}
|
||||
|
||||
# Create temp file with payload
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(payload_data, f)
|
||||
temp_payload_path = f.name
|
||||
|
||||
# Build simctl command
|
||||
cmd = ["xcrun", "simctl", "push"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend([bundle_id, temp_payload_path])
|
||||
|
||||
# Send notification
|
||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Clean up temp file
|
||||
Path(temp_payload_path).unlink()
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error sending push notification: {e.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
def send_simple(
|
||||
self,
|
||||
bundle_id: str,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
badge: int | None = None,
|
||||
sound: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Send simple push notification with common parameters.
|
||||
|
||||
Args:
|
||||
bundle_id: Target app bundle ID
|
||||
title: Alert title
|
||||
body: Alert body
|
||||
badge: Badge number
|
||||
sound: Whether to play sound
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
payload = {}
|
||||
|
||||
if title or body:
|
||||
alert = {}
|
||||
if title:
|
||||
alert["title"] = title
|
||||
if body:
|
||||
alert["body"] = body
|
||||
payload["alert"] = alert
|
||||
|
||||
if badge is not None:
|
||||
payload["badge"] = badge
|
||||
|
||||
if sound:
|
||||
payload["sound"] = "default"
|
||||
|
||||
# Wrap in aps
|
||||
full_payload = {"aps": payload}
|
||||
|
||||
return self.send(bundle_id, full_payload)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Send simulated push notification to iOS app")
|
||||
|
||||
# Required
|
||||
parser.add_argument(
|
||||
"--bundle-id", required=True, help="Target app bundle ID (e.g., com.example.app)"
|
||||
)
|
||||
|
||||
# Simple payload options
|
||||
parser.add_argument("--title", help="Alert title (for simple notifications)")
|
||||
parser.add_argument("--body", help="Alert body message")
|
||||
parser.add_argument("--badge", type=int, help="Badge number")
|
||||
parser.add_argument("--no-sound", action="store_true", help="Don't play notification sound")
|
||||
|
||||
# Custom payload
|
||||
parser.add_argument(
|
||||
"--payload",
|
||||
help="Custom JSON payload file or inline JSON string",
|
||||
)
|
||||
|
||||
# Test tracking
|
||||
parser.add_argument("--test-name", help="Test scenario name for tracking")
|
||||
parser.add_argument(
|
||||
"--expected",
|
||||
help="Expected behavior after notification",
|
||||
)
|
||||
|
||||
# Device
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
sender = PushNotificationSender(udid=udid)
|
||||
|
||||
# Send notification
|
||||
if args.payload:
|
||||
# Custom payload mode
|
||||
success = sender.send(args.bundle_id, args.payload)
|
||||
else:
|
||||
# Simple notification mode
|
||||
success = sender.send_simple(
|
||||
args.bundle_id,
|
||||
title=args.title,
|
||||
body=args.body,
|
||||
badge=args.badge,
|
||||
sound=not args.no_sound,
|
||||
)
|
||||
|
||||
if success:
|
||||
# Token-efficient output
|
||||
output = "Push notification sent"
|
||||
|
||||
if args.test_name:
|
||||
output += f" (test: {args.test_name})"
|
||||
|
||||
print(output)
|
||||
|
||||
if args.expected:
|
||||
print(f"Expected: {args.expected}")
|
||||
|
||||
print()
|
||||
print("Notification details:")
|
||||
if args.title:
|
||||
print(f" Title: {args.title}")
|
||||
if args.body:
|
||||
print(f" Body: {args.body}")
|
||||
if args.badge:
|
||||
print(f" Badge: {args.badge}")
|
||||
|
||||
print()
|
||||
print("Verify notification handling:")
|
||||
print("1. Check app log output: python scripts/log_monitor.py --app " + args.bundle_id)
|
||||
print(
|
||||
"2. Capture state: python scripts/app_state_capture.py --app-bundle-id "
|
||||
+ args.bundle_id
|
||||
)
|
||||
|
||||
else:
|
||||
print("Failed to send push notification")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Screen Mapper - Current Screen Analyzer
|
||||
|
||||
Maps the current screen's UI elements for navigation decisions.
|
||||
Provides token-efficient summaries of available interactions.
|
||||
|
||||
This script analyzes the iOS simulator screen using IDB's accessibility tree
|
||||
and provides a compact, actionable summary of what's currently visible and
|
||||
interactive on the screen. Perfect for AI agents making navigation decisions.
|
||||
|
||||
Key Features:
|
||||
- Token-efficient output (5-7 lines by default)
|
||||
- Identifies buttons, text fields, navigation elements
|
||||
- Counts interactive and focusable elements
|
||||
- Progressive detail with --verbose flag
|
||||
- Navigation hints with --hints flag
|
||||
|
||||
Usage Examples:
|
||||
# Quick summary (default)
|
||||
python scripts/screen_mapper.py --udid <device-id>
|
||||
|
||||
# Detailed element breakdown
|
||||
python scripts/screen_mapper.py --udid <device-id> --verbose
|
||||
|
||||
# Include navigation suggestions
|
||||
python scripts/screen_mapper.py --udid <device-id> --hints
|
||||
|
||||
# Full JSON output for parsing
|
||||
python scripts/screen_mapper.py --udid <device-id> --json
|
||||
|
||||
Output Format (default):
|
||||
Screen: LoginViewController (45 elements, 7 interactive)
|
||||
Buttons: "Login", "Cancel", "Forgot Password"
|
||||
TextFields: 2 (0 filled)
|
||||
Navigation: NavBar: "Sign In"
|
||||
Focusable: 7 elements
|
||||
|
||||
Technical Details:
|
||||
- Uses IDB's accessibility tree via `idb ui describe-all --json --nested`
|
||||
- Parses IDB's array format: [{ root element with children }]
|
||||
- Identifies element types: Button, TextField, NavigationBar, TabBar, etc.
|
||||
- Extracts labels from AXLabel, AXValue, and AXUniqueId fields
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from common import get_accessibility_tree, resolve_udid
|
||||
|
||||
|
||||
class ScreenMapper:
|
||||
"""
|
||||
Analyzes current screen for navigation decisions.
|
||||
|
||||
This class fetches the iOS accessibility tree from IDB and analyzes it
|
||||
to provide actionable summaries for navigation. It categorizes elements
|
||||
by type, counts interactive elements, and identifies key UI patterns.
|
||||
|
||||
Attributes:
|
||||
udid (Optional[str]): Device UDID to target, or None for booted device
|
||||
INTERACTIVE_TYPES (Set[str]): Element types that users can interact with
|
||||
|
||||
Design Philosophy:
|
||||
- Token efficiency: Provide minimal but complete information
|
||||
- Progressive disclosure: Summary by default, details on request
|
||||
- Navigation-focused: Highlight elements relevant for automation
|
||||
"""
|
||||
|
||||
# Element types we care about for navigation
|
||||
# These are the accessibility element types that indicate user interaction points
|
||||
INTERACTIVE_TYPES = {
|
||||
"Button",
|
||||
"Link",
|
||||
"TextField",
|
||||
"SecureTextField",
|
||||
"Cell",
|
||||
"Switch",
|
||||
"Slider",
|
||||
"Stepper",
|
||||
"SegmentedControl",
|
||||
"TabBar",
|
||||
"NavigationBar",
|
||||
"Toolbar",
|
||||
}
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""
|
||||
Initialize screen mapper.
|
||||
|
||||
Args:
|
||||
udid: Optional device UDID. If None, uses booted simulator.
|
||||
|
||||
Example:
|
||||
mapper = ScreenMapper(udid="656DC652-1C9F-4AB2-AD4F-F38E65976BDA")
|
||||
mapper = ScreenMapper() # Uses booted device
|
||||
"""
|
||||
self.udid = udid
|
||||
|
||||
def get_accessibility_tree(self) -> dict:
|
||||
"""
|
||||
Fetch accessibility tree from iOS simulator via IDB.
|
||||
|
||||
Delegates to shared utility for consistent tree fetching across all scripts.
|
||||
"""
|
||||
return get_accessibility_tree(self.udid, nested=True)
|
||||
|
||||
def analyze_tree(self, node: dict, depth: int = 0) -> dict:
|
||||
"""Analyze accessibility tree for navigation info."""
|
||||
analysis = {
|
||||
"elements_by_type": defaultdict(list),
|
||||
"total_elements": 0,
|
||||
"interactive_elements": 0,
|
||||
"text_fields": [],
|
||||
"buttons": [],
|
||||
"navigation": {},
|
||||
"screen_name": None,
|
||||
"focusable": 0,
|
||||
}
|
||||
|
||||
self._analyze_recursive(node, analysis, depth)
|
||||
|
||||
# Post-process for clean output
|
||||
analysis["elements_by_type"] = dict(analysis["elements_by_type"])
|
||||
|
||||
return analysis
|
||||
|
||||
def _analyze_recursive(self, node: dict, analysis: dict, depth: int):
|
||||
"""Recursively analyze tree nodes."""
|
||||
elem_type = node.get("type")
|
||||
label = node.get("AXLabel", "")
|
||||
value = node.get("AXValue", "")
|
||||
identifier = node.get("AXUniqueId", "")
|
||||
|
||||
# Count element
|
||||
if elem_type:
|
||||
analysis["total_elements"] += 1
|
||||
|
||||
# Track by type
|
||||
if elem_type in self.INTERACTIVE_TYPES:
|
||||
analysis["interactive_elements"] += 1
|
||||
|
||||
# Store concise info (label only, not full node)
|
||||
elem_info = label or value or identifier or "Unnamed"
|
||||
analysis["elements_by_type"][elem_type].append(elem_info)
|
||||
|
||||
# Special handling for common types
|
||||
if elem_type == "Button":
|
||||
analysis["buttons"].append(elem_info)
|
||||
elif elem_type in ("TextField", "SecureTextField"):
|
||||
analysis["text_fields"].append(
|
||||
{"type": elem_type, "label": elem_info, "has_value": bool(value)}
|
||||
)
|
||||
elif elem_type == "NavigationBar":
|
||||
analysis["navigation"]["nav_title"] = label or "Navigation"
|
||||
elif elem_type == "TabBar":
|
||||
# Count tab items
|
||||
tab_count = len(node.get("children", []))
|
||||
analysis["navigation"]["tab_count"] = tab_count
|
||||
|
||||
# Track focusable elements
|
||||
if node.get("enabled", False) and elem_type in self.INTERACTIVE_TYPES:
|
||||
analysis["focusable"] += 1
|
||||
|
||||
# Try to identify screen name from view controller
|
||||
if not analysis["screen_name"] and identifier:
|
||||
if "ViewController" in identifier or "Screen" in identifier:
|
||||
analysis["screen_name"] = identifier
|
||||
|
||||
# Process children
|
||||
for child in node.get("children", []):
|
||||
self._analyze_recursive(child, analysis, depth + 1)
|
||||
|
||||
def format_summary(self, analysis: dict, verbose: bool = False) -> str:
|
||||
"""Format analysis as token-efficient summary."""
|
||||
lines = []
|
||||
|
||||
# Screen identification (1 line)
|
||||
screen = analysis["screen_name"] or "Unknown Screen"
|
||||
total = analysis["total_elements"]
|
||||
interactive = analysis["interactive_elements"]
|
||||
lines.append(f"Screen: {screen} ({total} elements, {interactive} interactive)")
|
||||
|
||||
# Buttons summary (1 line)
|
||||
if analysis["buttons"]:
|
||||
button_list = ", ".join(f'"{b}"' for b in analysis["buttons"][:5])
|
||||
if len(analysis["buttons"]) > 5:
|
||||
button_list += f" +{len(analysis['buttons']) - 5} more"
|
||||
lines.append(f"Buttons: {button_list}")
|
||||
|
||||
# Text fields summary (1 line)
|
||||
if analysis["text_fields"]:
|
||||
field_count = len(analysis["text_fields"])
|
||||
[f["type"] for f in analysis["text_fields"]]
|
||||
filled = sum(1 for f in analysis["text_fields"] if f["has_value"])
|
||||
lines.append(f"TextFields: {field_count} ({filled} filled)")
|
||||
|
||||
# Navigation summary (1 line)
|
||||
nav_parts = []
|
||||
if "nav_title" in analysis["navigation"]:
|
||||
nav_parts.append(f"NavBar: \"{analysis['navigation']['nav_title']}\"")
|
||||
if "tab_count" in analysis["navigation"]:
|
||||
nav_parts.append(f"TabBar: {analysis['navigation']['tab_count']} tabs")
|
||||
if nav_parts:
|
||||
lines.append(f"Navigation: {', '.join(nav_parts)}")
|
||||
|
||||
# Focusable count (1 line)
|
||||
lines.append(f"Focusable: {analysis['focusable']} elements")
|
||||
|
||||
# Verbose mode adds element type breakdown
|
||||
if verbose:
|
||||
lines.append("\nElements by type:")
|
||||
for elem_type, items in analysis["elements_by_type"].items():
|
||||
if items: # Only show types that exist
|
||||
lines.append(f" {elem_type}: {len(items)}")
|
||||
for item in items[:3]: # Show first 3
|
||||
lines.append(f" - {item}")
|
||||
if len(items) > 3:
|
||||
lines.append(f" ... +{len(items) - 3} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_navigation_hints(self, analysis: dict) -> list[str]:
|
||||
"""Generate navigation hints based on screen analysis."""
|
||||
hints = []
|
||||
|
||||
# Check for common patterns
|
||||
if "Login" in str(analysis.get("buttons", [])):
|
||||
hints.append("Login screen detected - find TextFields for credentials")
|
||||
|
||||
if analysis["text_fields"]:
|
||||
unfilled = [f for f in analysis["text_fields"] if not f["has_value"]]
|
||||
if unfilled:
|
||||
hints.append(f"{len(unfilled)} empty text field(s) - may need input")
|
||||
|
||||
if not analysis["buttons"] and not analysis["text_fields"]:
|
||||
hints.append("No interactive elements - try swiping or going back")
|
||||
|
||||
if "tab_count" in analysis.get("navigation", {}):
|
||||
hints.append(f"Tab bar available with {analysis['navigation']['tab_count']} tabs")
|
||||
|
||||
return hints
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Map current screen UI elements")
|
||||
parser.add_argument("--verbose", action="store_true", help="Show detailed element breakdown")
|
||||
parser.add_argument("--json", action="store_true", help="Output raw JSON analysis")
|
||||
parser.add_argument("--hints", action="store_true", help="Include navigation hints")
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create mapper and analyze
|
||||
mapper = ScreenMapper(udid=udid)
|
||||
tree = mapper.get_accessibility_tree()
|
||||
analysis = mapper.analyze_tree(tree)
|
||||
|
||||
# Output based on format
|
||||
if args.json:
|
||||
# Full JSON (verbose)
|
||||
print(json.dumps(analysis, indent=2, default=str))
|
||||
else:
|
||||
# Token-efficient summary (default)
|
||||
summary = mapper.format_summary(analysis, verbose=args.verbose)
|
||||
print(summary)
|
||||
|
||||
# Add hints if requested
|
||||
if args.hints:
|
||||
hints = mapper.get_navigation_hints(analysis)
|
||||
if hints:
|
||||
print("\nHints:")
|
||||
for hint in hints:
|
||||
print(f" - {hint}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# iOS Simulator Testing Environment Health Check
|
||||
#
|
||||
# Verifies that all required tools and dependencies are properly installed
|
||||
# and configured for iOS simulator testing.
|
||||
#
|
||||
# Usage: bash scripts/sim_health_check.sh [--help]
|
||||
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check flags
|
||||
SHOW_HELP=false
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--help|-h)
|
||||
SHOW_HELP=true
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$SHOW_HELP" = true ]; then
|
||||
cat <<EOF
|
||||
iOS Simulator Testing - Environment Health Check
|
||||
|
||||
Verifies that your environment is properly configured for iOS simulator testing.
|
||||
|
||||
Usage: bash scripts/sim_health_check.sh [options]
|
||||
|
||||
Options:
|
||||
--help, -h Show this help message
|
||||
|
||||
This script checks for:
|
||||
- Xcode Command Line Tools installation
|
||||
- iOS Simulator availability
|
||||
- IDB (iOS Development Bridge) installation
|
||||
- Available simulator devices
|
||||
- Python 3 installation (for scripts)
|
||||
|
||||
Exit codes:
|
||||
0 - All checks passed
|
||||
1 - One or more checks failed (see output for details)
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE} iOS Simulator Testing - Environment Health Check${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
CHECKS_PASSED=0
|
||||
CHECKS_FAILED=0
|
||||
|
||||
# Function to print check status
|
||||
check_passed() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
((CHECKS_PASSED++))
|
||||
}
|
||||
|
||||
check_failed() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
((CHECKS_FAILED++))
|
||||
}
|
||||
|
||||
check_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
# Check 1: macOS
|
||||
echo -e "${BLUE}[1/8]${NC} Checking operating system..."
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS_VERSION=$(sw_vers -productVersion)
|
||||
check_passed "macOS detected (version $OS_VERSION)"
|
||||
else
|
||||
check_failed "Not running on macOS (detected: $OSTYPE)"
|
||||
echo " iOS Simulator testing requires macOS"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 2: Xcode Command Line Tools
|
||||
echo -e "${BLUE}[2/8]${NC} Checking Xcode Command Line Tools..."
|
||||
if command -v xcrun &> /dev/null; then
|
||||
XCODE_PATH=$(xcode-select -p 2>/dev/null || echo "not found")
|
||||
if [ "$XCODE_PATH" != "not found" ]; then
|
||||
XCODE_VERSION=$(xcodebuild -version 2>/dev/null | head -n 1 || echo "Unknown")
|
||||
check_passed "Xcode Command Line Tools installed"
|
||||
echo " Path: $XCODE_PATH"
|
||||
echo " Version: $XCODE_VERSION"
|
||||
else
|
||||
check_failed "Xcode Command Line Tools path not set"
|
||||
echo " Run: xcode-select --install"
|
||||
fi
|
||||
else
|
||||
check_failed "xcrun command not found"
|
||||
echo " Install Xcode Command Line Tools: xcode-select --install"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 3: simctl availability
|
||||
echo -e "${BLUE}[3/8]${NC} Checking simctl (Simulator Control)..."
|
||||
if command -v xcrun &> /dev/null && xcrun simctl help &> /dev/null; then
|
||||
check_passed "simctl is available"
|
||||
else
|
||||
check_failed "simctl not available"
|
||||
echo " simctl comes with Xcode Command Line Tools"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 4: IDB installation
|
||||
echo -e "${BLUE}[4/8]${NC} Checking IDB (iOS Development Bridge)..."
|
||||
if command -v idb &> /dev/null; then
|
||||
IDB_PATH=$(which idb)
|
||||
IDB_VERSION=$(idb --version 2>/dev/null || echo "Unknown")
|
||||
check_passed "IDB is installed"
|
||||
echo " Path: $IDB_PATH"
|
||||
echo " Version: $IDB_VERSION"
|
||||
else
|
||||
check_warning "IDB not found in PATH"
|
||||
echo " IDB is optional but provides advanced UI automation"
|
||||
echo " Install: https://fbidb.io/docs/installation"
|
||||
echo " Recommended: brew tap facebook/fb && brew install idb-companion"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 5: Python 3 installation
|
||||
echo -e "${BLUE}[5/8]${NC} Checking Python 3..."
|
||||
if command -v python3 &> /dev/null; then
|
||||
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
|
||||
check_passed "Python 3 is installed (version $PYTHON_VERSION)"
|
||||
else
|
||||
check_failed "Python 3 not found"
|
||||
echo " Python 3 is required for testing scripts"
|
||||
echo " Install: brew install python3"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 6: Available simulators
|
||||
echo -e "${BLUE}[6/8]${NC} Checking available iOS Simulators..."
|
||||
if command -v xcrun &> /dev/null; then
|
||||
SIMULATOR_COUNT=$(xcrun simctl list devices available 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
|
||||
|
||||
if [ "$SIMULATOR_COUNT" -gt 0 ]; then
|
||||
check_passed "Found $SIMULATOR_COUNT available simulator(s)"
|
||||
|
||||
# Show first 5 simulators
|
||||
echo ""
|
||||
echo " Available simulators (showing up to 5):"
|
||||
xcrun simctl list devices available 2>/dev/null | grep "iPhone\|iPad" | head -5 | while read -r line; do
|
||||
echo " - $line"
|
||||
done
|
||||
else
|
||||
check_warning "No simulators found"
|
||||
echo " Create simulators via Xcode or simctl"
|
||||
echo " Example: xcrun simctl create 'iPhone 15' 'iPhone 15'"
|
||||
fi
|
||||
else
|
||||
check_failed "Cannot check simulators (simctl not available)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 7: Booted simulators
|
||||
echo -e "${BLUE}[7/8]${NC} Checking booted simulators..."
|
||||
if command -v xcrun &> /dev/null; then
|
||||
BOOTED_SIMS=$(xcrun simctl list devices booted 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
|
||||
|
||||
if [ "$BOOTED_SIMS" -gt 0 ]; then
|
||||
check_passed "$BOOTED_SIMS simulator(s) currently booted"
|
||||
|
||||
echo ""
|
||||
echo " Booted simulators:"
|
||||
xcrun simctl list devices booted 2>/dev/null | grep "iPhone\|iPad" | while read -r line; do
|
||||
echo " - $line"
|
||||
done
|
||||
else
|
||||
check_warning "No simulators currently booted"
|
||||
echo " Boot a simulator to begin testing"
|
||||
echo " Example: xcrun simctl boot <device-udid>"
|
||||
echo " Or: open -a Simulator"
|
||||
fi
|
||||
else
|
||||
check_failed "Cannot check booted simulators (simctl not available)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check 8: Required Python packages (optional check)
|
||||
echo -e "${BLUE}[8/8]${NC} Checking Python packages..."
|
||||
if command -v python3 &> /dev/null; then
|
||||
MISSING_PACKAGES=()
|
||||
|
||||
# Check for PIL/Pillow (for visual_diff.py)
|
||||
if python3 -c "import PIL" 2>/dev/null; then
|
||||
check_passed "Pillow (PIL) installed - visual diff available"
|
||||
else
|
||||
MISSING_PACKAGES+=("pillow")
|
||||
check_warning "Pillow (PIL) not installed - visual diff won't work"
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo " Install missing packages:"
|
||||
echo " pip3 install ${MISSING_PACKAGES[*]}"
|
||||
fi
|
||||
else
|
||||
check_warning "Cannot check Python packages (Python 3 not available)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE} Summary${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Checks passed: ${GREEN}$CHECKS_PASSED${NC}"
|
||||
if [ "$CHECKS_FAILED" -gt 0 ]; then
|
||||
echo -e "Checks failed: ${RED}$CHECKS_FAILED${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Action required:${NC} Fix the failed checks above before testing"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Environment is ready for iOS simulator testing${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Boot a simulator: open -a Simulator"
|
||||
echo " 2. Launch your app: xcrun simctl launch booted <bundle-id>"
|
||||
echo " 3. Run accessibility audit: python scripts/accessibility_audit.py"
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Simulator Listing with Progressive Disclosure
|
||||
|
||||
Lists available simulators with token-efficient summaries.
|
||||
Full details available on demand via cache IDs.
|
||||
|
||||
Achieves 96% token reduction (57k→2k tokens) for common queries.
|
||||
|
||||
Usage Examples:
|
||||
# Concise summary (default)
|
||||
python scripts/sim_list.py
|
||||
|
||||
# Get full details for cached list
|
||||
python scripts/sim_list.py --get-details <cache-id>
|
||||
|
||||
# Get recommendations
|
||||
python scripts/sim_list.py --suggest
|
||||
|
||||
# Filter by device type
|
||||
python scripts/sim_list.py --device-type iPhone
|
||||
|
||||
Output (default):
|
||||
Simulator Summary [cache-sim-20251028-143052]
|
||||
├─ Total: 47 devices
|
||||
├─ Available: 31
|
||||
└─ Booted: 1
|
||||
|
||||
✓ iPhone 16 Pro (iOS 18.1) [ABC-123...]
|
||||
|
||||
Use --get-details cache-sim-20251028-143052 for full list
|
||||
|
||||
Technical Details:
|
||||
- Uses xcrun simctl list devices
|
||||
- Caches results with 1-hour TTL
|
||||
- Reduces output by 96% by default
|
||||
- Token efficiency: summary = ~30 tokens, full list = ~1500 tokens
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from common import get_cache
|
||||
|
||||
|
||||
class SimulatorLister:
|
||||
"""Lists iOS simulators with progressive disclosure."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize lister with cache."""
|
||||
self.cache = get_cache()
|
||||
|
||||
def list_simulators(self) -> dict:
|
||||
"""
|
||||
Get list of all simulators.
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"devices": [...],
|
||||
"runtimes": [...],
|
||||
"total_devices": int,
|
||||
"available_devices": int,
|
||||
"booted_devices": [...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
||||
return {"devices": {}, "runtimes": []}
|
||||
|
||||
def parse_devices(self, sim_data: dict) -> list[dict]:
|
||||
"""
|
||||
Parse simulator data into flat list.
|
||||
|
||||
Returns:
|
||||
List of device dicts with runtime info
|
||||
"""
|
||||
devices = []
|
||||
|
||||
devices_by_runtime = sim_data.get("devices", {})
|
||||
|
||||
for runtime_str, device_list in devices_by_runtime.items():
|
||||
# Extract iOS version from runtime string
|
||||
# Format: "iOS 18.1", "tvOS 18", etc.
|
||||
runtime_name = runtime_str.replace(" Simulator", "").strip()
|
||||
|
||||
for device in device_list:
|
||||
devices.append(
|
||||
{
|
||||
"name": device.get("name"),
|
||||
"udid": device.get("udid"),
|
||||
"state": device.get("state"),
|
||||
"runtime": runtime_name,
|
||||
"is_available": device.get("isAvailable", False),
|
||||
}
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
def get_concise_summary(self, devices: list[dict]) -> dict:
|
||||
"""
|
||||
Generate concise summary with cache ID.
|
||||
|
||||
Returns 96% fewer tokens than full list.
|
||||
"""
|
||||
booted = [d for d in devices if d["state"] == "Booted"]
|
||||
available = [d for d in devices if d["is_available"]]
|
||||
iphone = [d for d in available if "iPhone" in d["name"]]
|
||||
|
||||
# Cache full list for later retrieval
|
||||
cache_id = self.cache.save(
|
||||
{
|
||||
"devices": devices,
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||
},
|
||||
"simulator-list",
|
||||
)
|
||||
|
||||
return {
|
||||
"cache_id": cache_id,
|
||||
"summary": {
|
||||
"total_devices": len(devices),
|
||||
"available_devices": len(available),
|
||||
"booted_devices": len(booted),
|
||||
},
|
||||
"quick_access": {
|
||||
"booted": booted[:3] if booted else [],
|
||||
"recommended_iphone": iphone[:3] if iphone else [],
|
||||
},
|
||||
}
|
||||
|
||||
def get_full_list(
|
||||
self,
|
||||
cache_id: str,
|
||||
device_type: str | None = None,
|
||||
runtime: str | None = None,
|
||||
) -> list[dict] | None:
|
||||
"""
|
||||
Retrieve full simulator list from cache.
|
||||
|
||||
Args:
|
||||
cache_id: Cache ID from concise summary
|
||||
device_type: Filter by type (iPhone, iPad, etc.)
|
||||
runtime: Filter by iOS version
|
||||
|
||||
Returns:
|
||||
List of devices matching filters
|
||||
"""
|
||||
data = self.cache.get(cache_id)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
devices = data.get("devices", [])
|
||||
|
||||
# Apply filters
|
||||
if device_type:
|
||||
devices = [d for d in devices if device_type in d["name"]]
|
||||
if runtime:
|
||||
devices = [d for d in devices if runtime.lower() in d["runtime"].lower()]
|
||||
|
||||
return devices
|
||||
|
||||
def suggest_simulators(self, limit: int = 4) -> list[dict]:
|
||||
"""
|
||||
Get simulator recommendations.
|
||||
|
||||
Returns:
|
||||
List of recommended simulators (best candidates for building)
|
||||
"""
|
||||
all_sims = self.list_simulators()
|
||||
devices = self.parse_devices(all_sims)
|
||||
|
||||
# Score devices for recommendations
|
||||
scored = []
|
||||
for device in devices:
|
||||
score = 0
|
||||
|
||||
# Prefer booted
|
||||
if device["state"] == "Booted":
|
||||
score += 10
|
||||
# Prefer available
|
||||
if device["is_available"]:
|
||||
score += 5
|
||||
# Prefer recent iOS versions
|
||||
ios_version = device["runtime"]
|
||||
if "18" in ios_version:
|
||||
score += 3
|
||||
elif "17" in ios_version:
|
||||
score += 2
|
||||
# Prefer iPhones over other types
|
||||
if "iPhone" in device["name"]:
|
||||
score += 1
|
||||
|
||||
scored.append({"device": device, "score": score})
|
||||
|
||||
# Sort by score and return top N
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return [s["device"] for s in scored[:limit]]
|
||||
|
||||
|
||||
def format_device(device: dict) -> str:
|
||||
"""Format device for display."""
|
||||
state_icon = "✓" if device["state"] == "Booted" else " "
|
||||
avail_icon = "●" if device["is_available"] else "○"
|
||||
name = device["name"]
|
||||
runtime = device["runtime"]
|
||||
udid_short = device["udid"][:8] + "..."
|
||||
return f"{state_icon} {avail_icon} {name} ({runtime}) [{udid_short}]"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="List iOS simulators with progressive disclosure")
|
||||
parser.add_argument(
|
||||
"--get-details",
|
||||
metavar="CACHE_ID",
|
||||
help="Get full details for cached simulator list",
|
||||
)
|
||||
parser.add_argument("--suggest", action="store_true", help="Get simulator recommendations")
|
||||
parser.add_argument(
|
||||
"--device-type",
|
||||
help="Filter by device type (iPhone, iPad, Apple Watch, etc.)",
|
||||
)
|
||||
parser.add_argument("--runtime", help="Filter by iOS version (e.g., iOS-18, iOS-17)")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
lister = SimulatorLister()
|
||||
|
||||
# Get full list with details
|
||||
if args.get_details:
|
||||
devices = lister.get_full_list(
|
||||
args.get_details, device_type=args.device_type, runtime=args.runtime
|
||||
)
|
||||
|
||||
if devices is None:
|
||||
print(f"Error: Cache ID not found or expired: {args.get_details}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(devices, indent=2))
|
||||
else:
|
||||
print(f"Simulators ({len(devices)}):\n")
|
||||
for device in devices:
|
||||
print(f" {format_device(device)}")
|
||||
|
||||
# Get recommendations
|
||||
elif args.suggest:
|
||||
suggestions = lister.suggest_simulators()
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(suggestions, indent=2))
|
||||
else:
|
||||
print("Recommended Simulators:\n")
|
||||
for i, device in enumerate(suggestions, 1):
|
||||
print(f"{i}. {format_device(device)}")
|
||||
|
||||
# Default: concise summary
|
||||
else:
|
||||
all_sims = lister.list_simulators()
|
||||
devices = lister.parse_devices(all_sims)
|
||||
summary = lister.get_concise_summary(devices)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2))
|
||||
else:
|
||||
# Human-readable concise output
|
||||
cache_id = summary["cache_id"]
|
||||
s = summary["summary"]
|
||||
q = summary["quick_access"]
|
||||
|
||||
print(f"Simulator Summary [{cache_id}]")
|
||||
print(f"├─ Total: {s['total_devices']} devices")
|
||||
print(f"├─ Available: {s['available_devices']}")
|
||||
print(f"└─ Booted: {s['booted_devices']}")
|
||||
|
||||
if q["booted"]:
|
||||
print()
|
||||
for device in q["booted"]:
|
||||
print(f" {format_device(device)}")
|
||||
|
||||
print()
|
||||
print(f"Use --get-details {cache_id} for full list")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Boot iOS simulators and wait for readiness.
|
||||
|
||||
This script boots one or more simulators and optionally waits for them to reach
|
||||
a ready state. It measures boot time and provides progress feedback.
|
||||
|
||||
Key features:
|
||||
- Boot by UDID or device name
|
||||
- Wait for device readiness with configurable timeout
|
||||
- Measure boot performance
|
||||
- Batch boot operations (boot all, boot by type)
|
||||
- Progress reporting for CI/CD pipelines
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from common.device_utils import (
|
||||
get_booted_device_udid,
|
||||
list_simulators,
|
||||
resolve_device_identifier,
|
||||
)
|
||||
|
||||
|
||||
class SimulatorBooter:
|
||||
"""Boot iOS simulators with optional readiness waiting."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize booter with optional device UDID."""
|
||||
self.udid = udid
|
||||
|
||||
def boot(self, wait_ready: bool = False, timeout_seconds: int = 120) -> tuple[bool, str]:
|
||||
"""
|
||||
Boot simulator and optionally wait for readiness.
|
||||
|
||||
Args:
|
||||
wait_ready: Wait for device to be ready before returning
|
||||
timeout_seconds: Maximum seconds to wait for readiness
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
if not self.udid:
|
||||
return False, "Error: Device UDID not specified"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Check if already booted
|
||||
try:
|
||||
booted = get_booted_device_udid()
|
||||
if booted == self.udid:
|
||||
elapsed = time.time() - start_time
|
||||
return True, (f"Device already booted: {self.udid} " f"[checked in {elapsed:.1f}s]")
|
||||
except RuntimeError:
|
||||
pass # No booted device, proceed with boot
|
||||
|
||||
# Execute boot command
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "boot", self.udid]
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip()
|
||||
return False, f"Boot failed: {error}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Boot command timed out"
|
||||
except Exception as e:
|
||||
return False, f"Boot error: {e}"
|
||||
|
||||
# Optionally wait for readiness
|
||||
if wait_ready:
|
||||
ready, wait_message = self._wait_for_ready(timeout_seconds)
|
||||
elapsed = time.time() - start_time
|
||||
if ready:
|
||||
return True, (f"Device booted and ready: {self.udid} " f"[{elapsed:.1f}s total]")
|
||||
return False, wait_message
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Device booted: {self.udid} [boot in {elapsed:.1f}s] "
|
||||
"(use --wait-ready to wait for availability)"
|
||||
)
|
||||
|
||||
def _wait_for_ready(self, timeout_seconds: int = 120) -> tuple[bool, str]:
|
||||
"""
|
||||
Wait for device to reach ready state.
|
||||
|
||||
Args:
|
||||
timeout_seconds: Maximum seconds to wait
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
start_time = time.time()
|
||||
poll_interval = 0.5
|
||||
checks = 0
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
try:
|
||||
checks += 1
|
||||
# Check if device responds to simctl commands
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Device ready: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
|
||||
)
|
||||
except (subprocess.TimeoutExpired, RuntimeError):
|
||||
pass # Not ready yet
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return False, (
|
||||
f"Boot timeout: Device did not reach ready state "
|
||||
f"within {elapsed:.1f}s ({checks} checks)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def boot_all() -> tuple[int, int]:
|
||||
"""
|
||||
Boot all available simulators.
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state="available")
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
booter = SimulatorBooter(udid=sim["udid"])
|
||||
success, _message = booter.boot(wait_ready=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def boot_by_type(device_type: str) -> tuple[int, int]:
|
||||
"""
|
||||
Boot all simulators of a specific type.
|
||||
|
||||
Args:
|
||||
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state="available")
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
if device_type.lower() in sim["name"].lower():
|
||||
booter = SimulatorBooter(udid=sim["udid"])
|
||||
success, _message = booter.boot(wait_ready=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Boot iOS simulators and wait for readiness")
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID or name (required unless using --all or --type)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
help="Device name (alternative to --udid)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait-ready",
|
||||
action="store_true",
|
||||
help="Wait for device to reach ready state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=120,
|
||||
help="Timeout for --wait-ready in seconds (default: 120)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Boot all available simulators",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--type",
|
||||
help="Boot all simulators of a specific type (e.g., iPhone, iPad)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle batch operations
|
||||
if args.all:
|
||||
succeeded, failed = SimulatorBooter.boot_all()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "boot_all",
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Boot summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.type:
|
||||
succeeded, failed = SimulatorBooter.boot_by_type(args.type)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "boot_by_type",
|
||||
"type": args.type,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Boot {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
# Resolve device identifier
|
||||
device_id = args.udid or args.name
|
||||
if not device_id:
|
||||
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
udid = resolve_device_identifier(device_id)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Boot device
|
||||
booter = SimulatorBooter(udid=udid)
|
||||
success, message = booter.boot(wait_ready=args.wait_ready, timeout_seconds=args.timeout)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "boot",
|
||||
"device_id": device_id,
|
||||
"udid": udid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create iOS simulators dynamically.
|
||||
|
||||
This script creates new simulators with specified device type and iOS version.
|
||||
Useful for CI/CD pipelines that need on-demand test device provisioning.
|
||||
|
||||
Key features:
|
||||
- Create by device type (iPhone 16 Pro, iPad Air, etc.)
|
||||
- Specify iOS version (17.0, 18.0, etc.)
|
||||
- Custom device naming
|
||||
- Return newly created device UDID
|
||||
- List available device types and runtimes
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from common.device_utils import list_simulators
|
||||
|
||||
|
||||
class SimulatorCreator:
|
||||
"""Create iOS simulators with specified configurations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize simulator creator."""
|
||||
pass
|
||||
|
||||
def create(
|
||||
self,
|
||||
device_type: str,
|
||||
ios_version: str | None = None,
|
||||
custom_name: str | None = None,
|
||||
) -> tuple[bool, str, str | None]:
|
||||
"""
|
||||
Create new iOS simulator.
|
||||
|
||||
Args:
|
||||
device_type: Device type (e.g., "iPhone 16 Pro", "iPad Air")
|
||||
ios_version: iOS version (e.g., "18.0"). If None, uses latest.
|
||||
custom_name: Custom device name. If None, uses default.
|
||||
|
||||
Returns:
|
||||
(success, message, new_udid) tuple
|
||||
"""
|
||||
# Get available device types and runtimes
|
||||
available_types = self._get_device_types()
|
||||
if not available_types:
|
||||
return False, "Failed to get available device types", None
|
||||
|
||||
# Normalize device type
|
||||
device_type_id = None
|
||||
for dt in available_types:
|
||||
if device_type.lower() in dt["name"].lower():
|
||||
device_type_id = dt["identifier"]
|
||||
break
|
||||
|
||||
if not device_type_id:
|
||||
return (
|
||||
False,
|
||||
f"Device type '{device_type}' not found. "
|
||||
f"Use --list-devices for available types.",
|
||||
None,
|
||||
)
|
||||
|
||||
# Get available runtimes
|
||||
available_runtimes = self._get_runtimes()
|
||||
if not available_runtimes:
|
||||
return False, "Failed to get available runtimes", None
|
||||
|
||||
# Resolve iOS version
|
||||
runtime_id = None
|
||||
if ios_version:
|
||||
for rt in available_runtimes:
|
||||
if ios_version in rt["name"]:
|
||||
runtime_id = rt["identifier"]
|
||||
break
|
||||
|
||||
if not runtime_id:
|
||||
return (
|
||||
False,
|
||||
f"iOS version '{ios_version}' not found. "
|
||||
f"Use --list-runtimes for available versions.",
|
||||
None,
|
||||
)
|
||||
# Use latest runtime
|
||||
elif available_runtimes:
|
||||
runtime_id = available_runtimes[-1]["identifier"]
|
||||
|
||||
if not runtime_id:
|
||||
return False, "No iOS runtime available", None
|
||||
|
||||
# Create device
|
||||
try:
|
||||
# Build device name
|
||||
device_name = (
|
||||
custom_name or f"{device_type_id.split('.')[-1]}-{ios_version or 'latest'}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"create",
|
||||
device_name,
|
||||
device_type_id,
|
||||
runtime_id,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or result.stdout.strip()
|
||||
return False, f"Creation failed: {error}", None
|
||||
|
||||
# Extract UDID from output
|
||||
new_udid = result.stdout.strip()
|
||||
|
||||
return (
|
||||
True,
|
||||
f"Device created: {device_name} ({device_type}) iOS {ios_version or 'latest'} "
|
||||
f"UDID: {new_udid}",
|
||||
new_udid,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Creation command timed out", None
|
||||
except Exception as e:
|
||||
return False, f"Creation error: {e}", None
|
||||
|
||||
@staticmethod
|
||||
def _get_device_types() -> list[dict]:
|
||||
"""
|
||||
Get available device types.
|
||||
|
||||
Returns:
|
||||
List of device type dicts with "name" and "identifier" keys
|
||||
"""
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "list", "devicetypes", "-j"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
devices = []
|
||||
|
||||
for device in data.get("devicetypes", []):
|
||||
devices.append(
|
||||
{
|
||||
"name": device.get("name", ""),
|
||||
"identifier": device.get("identifier", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return devices
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_runtimes() -> list[dict]:
|
||||
"""
|
||||
Get available iOS runtimes.
|
||||
|
||||
Returns:
|
||||
List of runtime dicts with "name" and "identifier" keys
|
||||
"""
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "list", "runtimes", "-j"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
runtimes = []
|
||||
|
||||
for runtime in data.get("runtimes", []):
|
||||
# Only include iOS runtimes (skip watchOS, tvOS, etc.)
|
||||
identifier = runtime.get("identifier", "")
|
||||
if "iOS" in identifier or "iOS" in runtime.get("name", ""):
|
||||
runtimes.append(
|
||||
{
|
||||
"name": runtime.get("name", ""),
|
||||
"identifier": runtime.get("identifier", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by version number (latest first)
|
||||
runtimes.sort(key=lambda r: r.get("identifier", ""), reverse=True)
|
||||
|
||||
return runtimes
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def list_device_types() -> list[dict]:
|
||||
"""
|
||||
List all available device types.
|
||||
|
||||
Returns:
|
||||
List of device types with name and identifier
|
||||
"""
|
||||
return SimulatorCreator._get_device_types()
|
||||
|
||||
@staticmethod
|
||||
def list_runtimes() -> list[dict]:
|
||||
"""
|
||||
List all available iOS runtimes.
|
||||
|
||||
Returns:
|
||||
List of runtimes with name and identifier
|
||||
"""
|
||||
return SimulatorCreator._get_runtimes()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Create iOS simulators dynamically")
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
required=False,
|
||||
help="Device type (e.g., 'iPhone 16 Pro', 'iPad Air')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runtime",
|
||||
help="iOS version (e.g., '18.0', '17.0'). Defaults to latest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
help="Custom device name. Defaults to auto-generated.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-devices",
|
||||
action="store_true",
|
||||
help="List all available device types",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-runtimes",
|
||||
action="store_true",
|
||||
help="List all available iOS runtimes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
creator = SimulatorCreator()
|
||||
|
||||
# Handle info queries
|
||||
if args.list_devices:
|
||||
devices = creator.list_device_types()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(json.dumps({"devices": devices}))
|
||||
else:
|
||||
print(f"Available device types ({len(devices)}):")
|
||||
for dev in devices[:20]: # Show first 20
|
||||
print(f" - {dev['name']}")
|
||||
if len(devices) > 20:
|
||||
print(f" ... and {len(devices) - 20} more")
|
||||
sys.exit(0)
|
||||
|
||||
if args.list_runtimes:
|
||||
runtimes = creator.list_runtimes()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(json.dumps({"runtimes": runtimes}))
|
||||
else:
|
||||
print(f"Available iOS runtimes ({len(runtimes)}):")
|
||||
for rt in runtimes:
|
||||
print(f" - {rt['name']}")
|
||||
sys.exit(0)
|
||||
|
||||
# Create device
|
||||
if not args.device:
|
||||
print(
|
||||
"Error: Specify --device, --list-devices, or --list-runtimes",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
success, message, new_udid = creator.create(
|
||||
device_type=args.device,
|
||||
ios_version=args.runtime,
|
||||
custom_name=args.name,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "create",
|
||||
"device_type": args.device,
|
||||
"runtime": args.runtime,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"new_udid": new_udid,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Delete iOS simulators permanently.
|
||||
|
||||
This script permanently removes simulators and frees disk space.
|
||||
Includes safety confirmation to prevent accidental deletion.
|
||||
|
||||
Key features:
|
||||
- Delete by UDID or device name
|
||||
- Confirmation required for safety
|
||||
- Batch delete operations
|
||||
- Report freed disk space estimate
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from common.device_utils import (
|
||||
list_simulators,
|
||||
resolve_device_identifier,
|
||||
)
|
||||
|
||||
|
||||
class SimulatorDeleter:
|
||||
"""Delete iOS simulators with safety confirmation."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize with optional device UDID."""
|
||||
self.udid = udid
|
||||
|
||||
def delete(self, confirm: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
Delete simulator permanently.
|
||||
|
||||
Args:
|
||||
confirm: Skip confirmation prompt (for batch operations)
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
if not self.udid:
|
||||
return False, "Error: Device UDID not specified"
|
||||
|
||||
# Safety confirmation
|
||||
if not confirm:
|
||||
try:
|
||||
response = input(
|
||||
f"Permanently delete simulator {self.udid}? " f"(type 'yes' to confirm): "
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
return False, "Deletion cancelled by user"
|
||||
except KeyboardInterrupt:
|
||||
return False, "Deletion cancelled"
|
||||
|
||||
# Execute delete command
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "delete", self.udid]
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or result.stdout.strip()
|
||||
return False, f"Deletion failed: {error}"
|
||||
|
||||
return True, f"Device deleted: {self.udid} [disk space freed]"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Deletion command timed out"
|
||||
except Exception as e:
|
||||
return False, f"Deletion error: {e}"
|
||||
|
||||
@staticmethod
|
||||
def delete_all(confirm: bool = False) -> tuple[int, int]:
|
||||
"""
|
||||
Delete all simulators permanently.
|
||||
|
||||
Args:
|
||||
confirm: Skip confirmation prompt
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state=None)
|
||||
|
||||
if not confirm:
|
||||
count = len(simulators)
|
||||
try:
|
||||
response = input(
|
||||
f"Permanently delete ALL {count} simulators? " f"(type 'yes' to confirm): "
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
return 0, count
|
||||
except KeyboardInterrupt:
|
||||
return 0, count
|
||||
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||
success, _message = deleter.delete(confirm=True)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def delete_by_type(device_type: str, confirm: bool = False) -> tuple[int, int]:
|
||||
"""
|
||||
Delete all simulators of a specific type.
|
||||
|
||||
Args:
|
||||
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||
confirm: Skip confirmation prompt
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state=None)
|
||||
matching = [s for s in simulators if device_type.lower() in s["name"].lower()]
|
||||
|
||||
if not matching:
|
||||
return 0, 0
|
||||
|
||||
if not confirm:
|
||||
count = len(matching)
|
||||
try:
|
||||
response = input(
|
||||
f"Permanently delete {count} {device_type} simulators? "
|
||||
f"(type 'yes' to confirm): "
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
return 0, count
|
||||
except KeyboardInterrupt:
|
||||
return 0, count
|
||||
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in matching:
|
||||
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||
success, _message = deleter.delete(confirm=True)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def delete_old(keep_count: int = 3, confirm: bool = False) -> tuple[int, int]:
|
||||
"""
|
||||
Delete older simulators, keeping most recent versions.
|
||||
|
||||
Useful for cleanup after testing multiple iOS versions.
|
||||
Keeps the most recent N simulators of each type.
|
||||
|
||||
Args:
|
||||
keep_count: Number of recent simulators to keep per type (default: 3)
|
||||
confirm: Skip confirmation prompt
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state=None)
|
||||
|
||||
# Group by device type
|
||||
by_type: dict[str, list] = {}
|
||||
for sim in simulators:
|
||||
dev_type = sim["type"]
|
||||
if dev_type not in by_type:
|
||||
by_type[dev_type] = []
|
||||
by_type[dev_type].append(sim)
|
||||
|
||||
# Find candidates for deletion (older ones)
|
||||
to_delete = []
|
||||
for _dev_type, sims in by_type.items():
|
||||
# Sort by runtime (iOS version) - keep newest
|
||||
sorted_sims = sorted(sims, key=lambda s: s["runtime"], reverse=True)
|
||||
# Mark older ones for deletion
|
||||
to_delete.extend(sorted_sims[keep_count:])
|
||||
|
||||
if not to_delete:
|
||||
return 0, 0
|
||||
|
||||
if not confirm:
|
||||
count = len(to_delete)
|
||||
try:
|
||||
response = input(
|
||||
f"Delete {count} older simulators, keeping {keep_count} per type? "
|
||||
f"(type 'yes' to confirm): "
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
return 0, count
|
||||
except KeyboardInterrupt:
|
||||
return 0, count
|
||||
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in to_delete:
|
||||
deleter = SimulatorDeleter(udid=sim["udid"])
|
||||
success, _message = deleter.delete(confirm=True)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Delete iOS simulators permanently")
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID or name (required unless using batch options)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
help="Device name (alternative to --udid)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Delete all simulators",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--type",
|
||||
help="Delete all simulators of a specific type (e.g., iPhone)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--old",
|
||||
type=int,
|
||||
metavar="KEEP_COUNT",
|
||||
help="Delete older simulators, keeping this many per type (e.g., --old 3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle batch operations
|
||||
if args.all:
|
||||
succeeded, failed = SimulatorDeleter.delete_all(confirm=args.yes)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "delete_all",
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Delete summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.type:
|
||||
succeeded, failed = SimulatorDeleter.delete_by_type(args.type, confirm=args.yes)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "delete_by_type",
|
||||
"type": args.type,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Delete {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.old is not None:
|
||||
succeeded, failed = SimulatorDeleter.delete_old(keep_count=args.old, confirm=args.yes)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "delete_old",
|
||||
"keep_count": args.old,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(
|
||||
f"Delete old summary: {succeeded}/{total} succeeded, "
|
||||
f"{failed} failed (kept {args.old} per type)"
|
||||
)
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
# Delete single device
|
||||
device_id = args.udid or args.name
|
||||
if not device_id:
|
||||
print("Error: Specify --udid, --name, --all, --type, or --old", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
udid = resolve_device_identifier(device_id)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Delete device
|
||||
deleter = SimulatorDeleter(udid=udid)
|
||||
success, message = deleter.delete(confirm=args.yes)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "delete",
|
||||
"device_id": device_id,
|
||||
"udid": udid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Erase iOS simulators (factory reset).
|
||||
|
||||
This script performs a factory reset on simulators, returning them to
|
||||
a clean state while preserving the device UUID. Much faster than
|
||||
delete + create for CI/CD cleanup.
|
||||
|
||||
Key features:
|
||||
- Erase by UDID or device name
|
||||
- Preserve device UUID (faster than delete)
|
||||
- Verify erase completion
|
||||
- Batch erase operations (all, by type)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from common.device_utils import (
|
||||
list_simulators,
|
||||
resolve_device_identifier,
|
||||
)
|
||||
|
||||
|
||||
class SimulatorEraser:
|
||||
"""Erase iOS simulators with optional verification."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize with optional device UDID."""
|
||||
self.udid = udid
|
||||
|
||||
def erase(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||
"""
|
||||
Erase simulator and optionally verify completion.
|
||||
|
||||
Performs a factory reset, clearing all app data and settings
|
||||
while preserving the simulator UUID.
|
||||
|
||||
Args:
|
||||
verify: Wait for erase to complete and verify state
|
||||
timeout_seconds: Maximum seconds to wait for verification
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
if not self.udid:
|
||||
return False, "Error: Device UDID not specified"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Execute erase command
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "erase", self.udid]
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
|
||||
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip()
|
||||
return False, f"Erase failed: {error}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Erase command timed out"
|
||||
except Exception as e:
|
||||
return False, f"Erase error: {e}"
|
||||
|
||||
# Optionally verify erase completion
|
||||
if verify:
|
||||
ready, verify_message = self._verify_erase(timeout_seconds)
|
||||
elapsed = time.time() - start_time
|
||||
if ready:
|
||||
return True, (
|
||||
f"Device erased: {self.udid} " f"[factory reset complete, {elapsed:.1f}s]"
|
||||
)
|
||||
return False, verify_message
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Device erase initiated: {self.udid} [{elapsed:.1f}s] "
|
||||
"(use --verify to wait for completion)"
|
||||
)
|
||||
|
||||
def _verify_erase(self, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify erase has completed.
|
||||
|
||||
Polls device state to confirm erase finished successfully.
|
||||
|
||||
Args:
|
||||
timeout_seconds: Maximum seconds to wait
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
start_time = time.time()
|
||||
poll_interval = 0.5
|
||||
checks = 0
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
try:
|
||||
checks += 1
|
||||
# Check if device can be queried (indicates boot status)
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "spawn", self.udid, "launchctl", "list"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# Device responding = erase likely complete
|
||||
if result.returncode == 0:
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Erase verified: {self.udid} " f"[{elapsed:.1f}s, {checks} checks]"
|
||||
)
|
||||
except (subprocess.TimeoutExpired, RuntimeError):
|
||||
pass # Not ready yet, keep polling
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return False, (
|
||||
f"Erase verification timeout: Device did not respond "
|
||||
f"within {elapsed:.1f}s ({checks} checks)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def erase_all() -> tuple[int, int]:
|
||||
"""
|
||||
Erase all simulators (factory reset).
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state=None)
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
eraser = SimulatorEraser(udid=sim["udid"])
|
||||
success, _message = eraser.erase(verify=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def erase_by_type(device_type: str) -> tuple[int, int]:
|
||||
"""
|
||||
Erase all simulators of a specific type.
|
||||
|
||||
Args:
|
||||
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state=None)
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
if device_type.lower() in sim["name"].lower():
|
||||
eraser = SimulatorEraser(udid=sim["udid"])
|
||||
success, _message = eraser.erase(verify=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def erase_booted() -> tuple[int, int]:
|
||||
"""
|
||||
Erase all currently booted simulators.
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state="booted")
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
eraser = SimulatorEraser(udid=sim["udid"])
|
||||
success, _message = eraser.erase(verify=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Erase iOS simulators (factory reset)")
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID or name (required unless using --all, --type, or --booted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
help="Device name (alternative to --udid)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="Wait for erase to complete and verify state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Timeout for --verify in seconds (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Erase all simulators (factory reset)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--type",
|
||||
help="Erase all simulators of a specific type (e.g., iPhone)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--booted",
|
||||
action="store_true",
|
||||
help="Erase all currently booted simulators",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle batch operations
|
||||
if args.all:
|
||||
succeeded, failed = SimulatorEraser.erase_all()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "erase_all",
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Erase summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.type:
|
||||
succeeded, failed = SimulatorEraser.erase_by_type(args.type)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "erase_by_type",
|
||||
"type": args.type,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Erase {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.booted:
|
||||
succeeded, failed = SimulatorEraser.erase_booted()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "erase_booted",
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Erase booted summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
# Erase single device
|
||||
device_id = args.udid or args.name
|
||||
if not device_id:
|
||||
print("Error: Specify --udid, --name, --all, --type, or --booted", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
udid = resolve_device_identifier(device_id)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Erase device
|
||||
eraser = SimulatorEraser(udid=udid)
|
||||
success, message = eraser.erase(verify=args.verify, timeout_seconds=args.timeout)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "erase",
|
||||
"device_id": device_id,
|
||||
"udid": udid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shutdown iOS simulators with optional state verification.
|
||||
|
||||
This script shuts down one or more running simulators and optionally
|
||||
verifies completion. Supports batch operations for efficient cleanup.
|
||||
|
||||
Key features:
|
||||
- Shutdown by UDID or device name
|
||||
- Verify shutdown completion with timeout
|
||||
- Batch shutdown operations (all, by type)
|
||||
- Progress reporting for CI/CD pipelines
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from common.device_utils import (
|
||||
list_simulators,
|
||||
resolve_device_identifier,
|
||||
)
|
||||
|
||||
|
||||
class SimulatorShutdown:
|
||||
"""Shutdown iOS simulators with optional verification."""
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize with optional device UDID."""
|
||||
self.udid = udid
|
||||
|
||||
def shutdown(self, verify: bool = True, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||
"""
|
||||
Shutdown simulator and optionally verify completion.
|
||||
|
||||
Args:
|
||||
verify: Wait for shutdown to complete
|
||||
timeout_seconds: Maximum seconds to wait for shutdown
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
if not self.udid:
|
||||
return False, "Error: Device UDID not specified"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Check if already shutdown
|
||||
simulators = list_simulators(state="booted")
|
||||
if not any(s["udid"] == self.udid for s in simulators):
|
||||
elapsed = time.time() - start_time
|
||||
return True, (f"Device already shutdown: {self.udid} " f"[checked in {elapsed:.1f}s]")
|
||||
|
||||
# Execute shutdown command
|
||||
try:
|
||||
cmd = ["xcrun", "simctl", "shutdown", self.udid]
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip()
|
||||
return False, f"Shutdown failed: {error}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Shutdown command timed out"
|
||||
except Exception as e:
|
||||
return False, f"Shutdown error: {e}"
|
||||
|
||||
# Optionally verify shutdown
|
||||
if verify:
|
||||
ready, verify_message = self._verify_shutdown(timeout_seconds)
|
||||
elapsed = time.time() - start_time
|
||||
if ready:
|
||||
return True, (f"Device shutdown confirmed: {self.udid} " f"[{elapsed:.1f}s total]")
|
||||
return False, verify_message
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Device shutdown: {self.udid} [{elapsed:.1f}s] "
|
||||
"(use --verify to wait for confirmation)"
|
||||
)
|
||||
|
||||
def _verify_shutdown(self, timeout_seconds: int = 30) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify device has fully shutdown.
|
||||
|
||||
Args:
|
||||
timeout_seconds: Maximum seconds to wait
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
start_time = time.time()
|
||||
poll_interval = 0.5
|
||||
checks = 0
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
try:
|
||||
checks += 1
|
||||
# Check booted devices
|
||||
simulators = list_simulators(state="booted")
|
||||
if not any(s["udid"] == self.udid for s in simulators):
|
||||
elapsed = time.time() - start_time
|
||||
return True, (
|
||||
f"Device shutdown verified: {self.udid} "
|
||||
f"[{elapsed:.1f}s, {checks} checks]"
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # Error checking, retry
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
return False, (
|
||||
f"Shutdown verification timeout: Device did not fully shutdown "
|
||||
f"within {elapsed:.1f}s ({checks} checks)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def shutdown_all() -> tuple[int, int]:
|
||||
"""
|
||||
Shutdown all booted simulators.
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state="booted")
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
shutdown = SimulatorShutdown(udid=sim["udid"])
|
||||
success, _message = shutdown.shutdown(verify=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
@staticmethod
|
||||
def shutdown_by_type(device_type: str) -> tuple[int, int]:
|
||||
"""
|
||||
Shutdown all booted simulators of a specific type.
|
||||
|
||||
Args:
|
||||
device_type: Device type filter (e.g., "iPhone", "iPad")
|
||||
|
||||
Returns:
|
||||
(succeeded, failed) tuple with counts
|
||||
"""
|
||||
simulators = list_simulators(state="booted")
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
|
||||
for sim in simulators:
|
||||
if device_type.lower() in sim["name"].lower():
|
||||
shutdown = SimulatorShutdown(udid=sim["udid"])
|
||||
success, _message = shutdown.shutdown(verify=False)
|
||||
if success:
|
||||
succeeded += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return succeeded, failed
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Shutdown iOS simulators with optional verification"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID or name (required unless using --all or --type)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
help="Device name (alternative to --udid)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="Wait for shutdown to complete and verify state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Timeout for --verify in seconds (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Shutdown all booted simulators",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--type",
|
||||
help="Shutdown all booted simulators of a specific type (e.g., iPhone)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle batch operations
|
||||
if args.all:
|
||||
succeeded, failed = SimulatorShutdown.shutdown_all()
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "shutdown_all",
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(f"Shutdown summary: {succeeded}/{total} succeeded, " f"{failed} failed")
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
if args.type:
|
||||
succeeded, failed = SimulatorShutdown.shutdown_by_type(args.type)
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "shutdown_by_type",
|
||||
"type": args.type,
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
"total": succeeded + failed,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
total = succeeded + failed
|
||||
print(
|
||||
f"Shutdown {args.type} summary: {succeeded}/{total} succeeded, " f"{failed} failed"
|
||||
)
|
||||
sys.exit(0 if failed == 0 else 1)
|
||||
|
||||
# Resolve device identifier
|
||||
device_id = args.udid or args.name
|
||||
if not device_id:
|
||||
print("Error: Specify --udid, --name, --all, or --type", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
udid = resolve_device_identifier(device_id)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Shutdown device
|
||||
shutdown = SimulatorShutdown(udid=udid)
|
||||
success, message = shutdown.shutdown(verify=args.verify, timeout_seconds=args.timeout)
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"action": "shutdown",
|
||||
"device_id": device_id,
|
||||
"udid": udid,
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(message)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Intelligent Simulator Selector
|
||||
|
||||
Suggests the best available iOS simulators based on:
|
||||
- Recently used (from config)
|
||||
- Latest iOS version
|
||||
- Common models for testing
|
||||
- Boot status
|
||||
|
||||
Usage Examples:
|
||||
# Get suggestions for user selection
|
||||
python scripts/simulator_selector.py --suggest
|
||||
|
||||
# List all available simulators
|
||||
python scripts/simulator_selector.py --list
|
||||
|
||||
# Boot a specific simulator
|
||||
python scripts/simulator_selector.py --boot "67A99DF0-27BD-4507-A3DE-B7D8C38F764A"
|
||||
|
||||
# Get suggestions as JSON for programmatic use
|
||||
python scripts/simulator_selector.py --suggest --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Try to import config from build_and_test if available
|
||||
try:
|
||||
from xcode.config import Config
|
||||
except ImportError:
|
||||
Config = None
|
||||
|
||||
|
||||
class SimulatorInfo:
|
||||
"""Information about an iOS simulator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
udid: str,
|
||||
ios_version: str,
|
||||
status: str,
|
||||
):
|
||||
"""Initialize simulator info."""
|
||||
self.name = name
|
||||
self.udid = udid
|
||||
self.ios_version = ios_version
|
||||
self.status = status
|
||||
self.reasons: list[str] = []
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"device": self.name,
|
||||
"udid": self.udid,
|
||||
"ios": self.ios_version,
|
||||
"status": self.status,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
class SimulatorSelector:
|
||||
"""Intelligent simulator selection."""
|
||||
|
||||
# Common iPhone models ranked by testing priority
|
||||
COMMON_MODELS = [
|
||||
"iPhone 16 Pro",
|
||||
"iPhone 16",
|
||||
"iPhone 15 Pro",
|
||||
"iPhone 15",
|
||||
"iPhone SE (3rd generation)",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize selector."""
|
||||
self.simulators: list[SimulatorInfo] = []
|
||||
self.config: dict | None = None
|
||||
self.last_used_simulator: str | None = None
|
||||
|
||||
# Load config if available
|
||||
if Config:
|
||||
try:
|
||||
config = Config.load()
|
||||
self.last_used_simulator = config.get_preferred_simulator()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def list_simulators(self) -> list[SimulatorInfo]:
|
||||
"""
|
||||
List all available simulators.
|
||||
|
||||
Returns:
|
||||
List of SimulatorInfo objects
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
simulators = []
|
||||
|
||||
# Parse devices by iOS version
|
||||
for runtime, devices in data.get("devices", {}).items():
|
||||
# Extract iOS version from runtime (e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-0")
|
||||
ios_version_match = re.search(r"iOS-(\d+-\d+)", runtime)
|
||||
if not ios_version_match:
|
||||
continue
|
||||
|
||||
ios_version = ios_version_match.group(1).replace("-", ".")
|
||||
|
||||
for device in devices:
|
||||
name = device.get("name", "")
|
||||
udid = device.get("udid", "")
|
||||
is_available = device.get("isAvailable", False)
|
||||
|
||||
if not is_available or "iPhone" not in name:
|
||||
continue
|
||||
|
||||
status = device.get("state", "").capitalize()
|
||||
sim_info = SimulatorInfo(name, udid, ios_version, status)
|
||||
simulators.append(sim_info)
|
||||
|
||||
self.simulators = simulators
|
||||
return simulators
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error listing simulators: {e.stderr}", file=sys.stderr)
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing simulator list: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
def get_suggestions(self, count: int = 4) -> list[SimulatorInfo]:
|
||||
"""
|
||||
Get top N suggested simulators.
|
||||
|
||||
Ranking factors:
|
||||
1. Recently used (from config)
|
||||
2. Latest iOS version
|
||||
3. Common models
|
||||
4. Boot status (Booted preferred)
|
||||
|
||||
Args:
|
||||
count: Number of suggestions to return
|
||||
|
||||
Returns:
|
||||
List of suggested SimulatorInfo objects
|
||||
"""
|
||||
if not self.simulators:
|
||||
return []
|
||||
|
||||
# Score each simulator
|
||||
scored = []
|
||||
for sim in self.simulators:
|
||||
score = self._score_simulator(sim)
|
||||
scored.append((score, sim))
|
||||
|
||||
# Sort by score (descending)
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# Return top N
|
||||
suggestions = [sim for _, sim in scored[:count]]
|
||||
|
||||
# Add reasons to each suggestion
|
||||
for i, sim in enumerate(suggestions, 1):
|
||||
if i == 1:
|
||||
sim.reasons.append("Recommended")
|
||||
|
||||
# Check if recently used
|
||||
if self.last_used_simulator and self.last_used_simulator == sim.name:
|
||||
sim.reasons.append("Recently used")
|
||||
|
||||
# Check if latest iOS
|
||||
latest_ios = max(s.ios_version for s in self.simulators)
|
||||
if sim.ios_version == latest_ios:
|
||||
sim.reasons.append("Latest iOS")
|
||||
|
||||
# Check if common model
|
||||
for j, model in enumerate(self.COMMON_MODELS):
|
||||
if model in sim.name:
|
||||
sim.reasons.append(f"#{j+1} common model")
|
||||
break
|
||||
|
||||
# Check if booted
|
||||
if sim.status == "Booted":
|
||||
sim.reasons.append("Currently running")
|
||||
|
||||
return suggestions
|
||||
|
||||
def _score_simulator(self, sim: SimulatorInfo) -> float:
|
||||
"""
|
||||
Score a simulator for ranking.
|
||||
|
||||
Higher score = better recommendation.
|
||||
|
||||
Args:
|
||||
sim: Simulator to score
|
||||
|
||||
Returns:
|
||||
Score value
|
||||
"""
|
||||
score = 0.0
|
||||
|
||||
# Recently used gets highest priority (100 points)
|
||||
if self.last_used_simulator and self.last_used_simulator == sim.name:
|
||||
score += 100
|
||||
|
||||
# Latest iOS version (50 points)
|
||||
latest_ios = max(s.ios_version for s in self.simulators)
|
||||
if sim.ios_version == latest_ios:
|
||||
score += 50
|
||||
|
||||
# Common models (30-20 points based on ranking)
|
||||
for i, model in enumerate(self.COMMON_MODELS):
|
||||
if model in sim.name:
|
||||
score += 30 - (i * 2) # Higher ranking models get more points
|
||||
break
|
||||
|
||||
# Currently booted (10 points)
|
||||
if sim.status == "Booted":
|
||||
score += 10
|
||||
|
||||
# iOS version number (minor factor for breaking ties)
|
||||
ios_numeric = float(sim.ios_version.replace(".", ""))
|
||||
score += ios_numeric * 0.1
|
||||
|
||||
return score
|
||||
|
||||
def boot_simulator(self, udid: str) -> bool:
|
||||
"""
|
||||
Boot a simulator.
|
||||
|
||||
Args:
|
||||
udid: Simulator UDID
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["xcrun", "simctl", "boot", udid],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error booting simulator: {e.stderr}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def format_suggestions(suggestions: list[SimulatorInfo], json_format: bool = False) -> str:
|
||||
"""
|
||||
Format suggestions for output.
|
||||
|
||||
Args:
|
||||
suggestions: List of suggestions
|
||||
json_format: If True, output as JSON
|
||||
|
||||
Returns:
|
||||
Formatted string
|
||||
"""
|
||||
if json_format:
|
||||
data = {"suggestions": [s.to_dict() for s in suggestions]}
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
if not suggestions:
|
||||
return "No simulators available"
|
||||
|
||||
lines = ["Available Simulators:\n"]
|
||||
for i, sim in enumerate(suggestions, 1):
|
||||
lines.append(f"{i}. {sim.name} (iOS {sim.ios_version})")
|
||||
if sim.reasons:
|
||||
lines.append(f" {', '.join(sim.reasons)}")
|
||||
lines.append(f" UDID: {sim.udid}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Intelligent iOS simulator selector",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Get suggestions for user selection
|
||||
python scripts/simulator_selector.py --suggest
|
||||
|
||||
# List all available simulators
|
||||
python scripts/simulator_selector.py --list
|
||||
|
||||
# Boot a specific simulator
|
||||
python scripts/simulator_selector.py --boot <UDID>
|
||||
|
||||
# Get suggestions as JSON
|
||||
python scripts/simulator_selector.py --suggest --json
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--suggest",
|
||||
action="store_true",
|
||||
help="Get top simulator suggestions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List all available simulators",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--boot",
|
||||
metavar="UDID",
|
||||
help="Boot specific simulator by UDID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of suggestions (default: 4)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
selector = SimulatorSelector()
|
||||
|
||||
if args.boot:
|
||||
# Boot specific simulator
|
||||
success = selector.boot_simulator(args.boot)
|
||||
if success:
|
||||
print(f"Booted simulator: {args.boot}")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
if args.list:
|
||||
# List all simulators
|
||||
simulators = selector.list_simulators()
|
||||
output = format_suggestions(simulators, args.json)
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
if args.suggest:
|
||||
# Get suggestions
|
||||
selector.list_simulators()
|
||||
suggestions = selector.get_suggestions(args.count)
|
||||
output = format_suggestions(suggestions, args.json)
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
# Default: show suggestions
|
||||
selector.list_simulators()
|
||||
suggestions = selector.get_suggestions(args.count)
|
||||
output = format_suggestions(suggestions, args.json)
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
iOS Status Bar Controller
|
||||
|
||||
Override simulator status bar for clean screenshots and testing.
|
||||
Control time, network, wifi, battery display.
|
||||
|
||||
Usage: python scripts/status_bar.py --preset clean
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from common import resolve_udid
|
||||
|
||||
|
||||
class StatusBarController:
|
||||
"""Controls iOS simulator status bar appearance."""
|
||||
|
||||
# Preset configurations
|
||||
PRESETS = {
|
||||
"clean": {
|
||||
"time": "9:41",
|
||||
"data_network": "5g",
|
||||
"wifi_mode": "active",
|
||||
"battery_state": "charged",
|
||||
"battery_level": 100,
|
||||
},
|
||||
"testing": {
|
||||
"time": "11:11",
|
||||
"data_network": "4g",
|
||||
"wifi_mode": "active",
|
||||
"battery_state": "discharging",
|
||||
"battery_level": 50,
|
||||
},
|
||||
"low_battery": {
|
||||
"time": "9:41",
|
||||
"data_network": "5g",
|
||||
"wifi_mode": "active",
|
||||
"battery_state": "discharging",
|
||||
"battery_level": 20,
|
||||
},
|
||||
"airplane": {
|
||||
"time": "9:41",
|
||||
"data_network": "none",
|
||||
"wifi_mode": "failed",
|
||||
"battery_state": "charged",
|
||||
"battery_level": 100,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, udid: str | None = None):
|
||||
"""Initialize status bar controller.
|
||||
|
||||
Args:
|
||||
udid: Optional device UDID (auto-detects booted simulator if None)
|
||||
"""
|
||||
self.udid = udid
|
||||
|
||||
def override(
|
||||
self,
|
||||
time: str | None = None,
|
||||
data_network: str | None = None,
|
||||
wifi_mode: str | None = None,
|
||||
battery_state: str | None = None,
|
||||
battery_level: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Override status bar appearance.
|
||||
|
||||
Args:
|
||||
time: Time in HH:MM format (e.g., "9:41")
|
||||
data_network: Network type (none, 1x, 3g, 4g, 5g, lte, lte-a)
|
||||
wifi_mode: WiFi state (active, searching, failed)
|
||||
battery_state: Battery state (charging, charged, discharging)
|
||||
battery_level: Battery percentage (0-100)
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = ["xcrun", "simctl", "status_bar"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.append("override")
|
||||
|
||||
# Add parameters if provided
|
||||
if time:
|
||||
cmd.extend(["--time", time])
|
||||
if data_network:
|
||||
cmd.extend(["--dataNetwork", data_network])
|
||||
if wifi_mode:
|
||||
cmd.extend(["--wifiMode", wifi_mode])
|
||||
if battery_state:
|
||||
cmd.extend(["--batteryState", battery_state])
|
||||
if battery_level is not None:
|
||||
cmd.extend(["--batteryLevel", str(battery_level)])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def clear(self) -> bool:
|
||||
"""
|
||||
Clear status bar override and restore defaults.
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
cmd = ["xcrun", "simctl", "status_bar"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.append("clear")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Override iOS simulator status bar for screenshots and testing"
|
||||
)
|
||||
|
||||
# Preset option
|
||||
parser.add_argument(
|
||||
"--preset",
|
||||
choices=list(StatusBarController.PRESETS.keys()),
|
||||
help="Use preset configuration (clean, testing, low-battery, airplane)",
|
||||
)
|
||||
|
||||
# Custom options
|
||||
parser.add_argument(
|
||||
"--time",
|
||||
help="Override time (HH:MM format, e.g., '9:41')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data-network",
|
||||
choices=["none", "1x", "3g", "4g", "5g", "lte", "lte-a"],
|
||||
help="Data network type",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wifi-mode",
|
||||
choices=["active", "searching", "failed"],
|
||||
help="WiFi state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--battery-state",
|
||||
choices=["charging", "charged", "discharging"],
|
||||
help="Battery state",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--battery-level",
|
||||
type=int,
|
||||
help="Battery level 0-100",
|
||||
)
|
||||
|
||||
# Other options
|
||||
parser.add_argument(
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Clear status bar override and restore defaults",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
controller = StatusBarController(udid=udid)
|
||||
|
||||
# Clear mode
|
||||
if args.clear:
|
||||
if controller.clear():
|
||||
print("Status bar override cleared - defaults restored")
|
||||
else:
|
||||
print("Failed to clear status bar override")
|
||||
sys.exit(1)
|
||||
|
||||
# Preset mode
|
||||
elif args.preset:
|
||||
preset = StatusBarController.PRESETS[args.preset]
|
||||
if controller.override(**preset):
|
||||
print(f"Status bar: {args.preset} preset applied")
|
||||
print(
|
||||
f" Time: {preset['time']}, "
|
||||
f"Network: {preset['data_network']}, "
|
||||
f"Battery: {preset['battery_level']}%"
|
||||
)
|
||||
else:
|
||||
print(f"Failed to apply {args.preset} preset")
|
||||
sys.exit(1)
|
||||
|
||||
# Custom mode
|
||||
elif any(
|
||||
[
|
||||
args.time,
|
||||
args.data_network,
|
||||
args.wifi_mode,
|
||||
args.battery_state,
|
||||
args.battery_level is not None,
|
||||
]
|
||||
):
|
||||
if controller.override(
|
||||
time=args.time,
|
||||
data_network=args.data_network,
|
||||
wifi_mode=args.wifi_mode,
|
||||
battery_state=args.battery_state,
|
||||
battery_level=args.battery_level,
|
||||
):
|
||||
output = "Status bar override applied:"
|
||||
if args.time:
|
||||
output += f" Time={args.time}"
|
||||
if args.data_network:
|
||||
output += f" Network={args.data_network}"
|
||||
if args.battery_level is not None:
|
||||
output += f" Battery={args.battery_level}%"
|
||||
print(output)
|
||||
else:
|
||||
print("Failed to override status bar")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Recorder for iOS Simulator Testing
|
||||
|
||||
Records test execution with automatic screenshots and documentation.
|
||||
Optimized for minimal token output during execution.
|
||||
|
||||
Usage:
|
||||
As a script: python scripts/test_recorder.py --test-name "Test Name" --output dir/
|
||||
As a module: from scripts.test_recorder import TestRecorder
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common import (
|
||||
capture_screenshot,
|
||||
count_elements,
|
||||
generate_screenshot_name,
|
||||
get_accessibility_tree,
|
||||
resolve_udid,
|
||||
)
|
||||
|
||||
|
||||
class TestRecorder:
|
||||
"""Records test execution with screenshots and accessibility snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
test_name: str,
|
||||
output_dir: str = "test-artifacts",
|
||||
udid: str | None = None,
|
||||
inline: bool = False,
|
||||
screenshot_size: str = "half",
|
||||
app_name: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize test recorder.
|
||||
|
||||
Args:
|
||||
test_name: Name of the test being recorded
|
||||
output_dir: Directory for test artifacts
|
||||
udid: Optional device UDID (uses booted if not specified)
|
||||
inline: If True, return screenshots as base64 (for vision-based automation)
|
||||
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
|
||||
app_name: App name for semantic screenshot naming
|
||||
"""
|
||||
self.test_name = test_name
|
||||
self.udid = udid
|
||||
self.inline = inline
|
||||
self.screenshot_size = screenshot_size
|
||||
self.app_name = app_name
|
||||
self.start_time = time.time()
|
||||
self.steps: list[dict] = []
|
||||
self.current_step = 0
|
||||
|
||||
# Create timestamped output directory
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
safe_name = test_name.lower().replace(" ", "-")
|
||||
self.output_dir = Path(output_dir) / f"{safe_name}-{timestamp}"
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create subdirectories (only if not in inline mode)
|
||||
if not inline:
|
||||
self.screenshots_dir = self.output_dir / "screenshots"
|
||||
self.screenshots_dir.mkdir(exist_ok=True)
|
||||
else:
|
||||
self.screenshots_dir = None
|
||||
|
||||
self.accessibility_dir = self.output_dir / "accessibility"
|
||||
self.accessibility_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Token-efficient output
|
||||
mode_str = "(inline mode)" if inline else ""
|
||||
print(f"Recording: {test_name} {mode_str}")
|
||||
print(f"Output: {self.output_dir}/")
|
||||
|
||||
def step(
|
||||
self,
|
||||
description: str,
|
||||
screen_name: str | None = None,
|
||||
state: str | None = None,
|
||||
assertion: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
):
|
||||
"""
|
||||
Record a test step with automatic screenshot.
|
||||
|
||||
Args:
|
||||
description: Step description
|
||||
screen_name: Screen name for semantic naming
|
||||
state: State description for semantic naming
|
||||
assertion: Optional assertion to verify
|
||||
metadata: Optional metadata for the step
|
||||
"""
|
||||
self.current_step += 1
|
||||
step_time = time.time() - self.start_time
|
||||
|
||||
# Capture screenshot using new utility
|
||||
screenshot_result = capture_screenshot(
|
||||
self.udid,
|
||||
size=self.screenshot_size,
|
||||
inline=self.inline,
|
||||
app_name=self.app_name,
|
||||
screen_name=screen_name or description,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Capture accessibility tree
|
||||
accessibility_path = (
|
||||
self.accessibility_dir
|
||||
/ f"{self.current_step:03d}-{description.lower().replace(' ', '-')[:20]}.json"
|
||||
)
|
||||
element_count = self._capture_accessibility(accessibility_path)
|
||||
|
||||
# Store step data
|
||||
step_data = {
|
||||
"number": self.current_step,
|
||||
"description": description,
|
||||
"timestamp": step_time,
|
||||
"element_count": element_count,
|
||||
"accessibility": accessibility_path.name,
|
||||
"screenshot_mode": screenshot_result["mode"],
|
||||
"screenshot_size": self.screenshot_size,
|
||||
}
|
||||
|
||||
# Handle screenshot data based on mode
|
||||
if screenshot_result["mode"] == "file":
|
||||
step_data["screenshot"] = screenshot_result["file_path"]
|
||||
step_data["screenshot_name"] = Path(screenshot_result["file_path"]).name
|
||||
else:
|
||||
# Inline mode
|
||||
step_data["screenshot_base64"] = screenshot_result["base64_data"]
|
||||
step_data["screenshot_dimensions"] = (
|
||||
screenshot_result["width"],
|
||||
screenshot_result["height"],
|
||||
)
|
||||
|
||||
if assertion:
|
||||
step_data["assertion"] = assertion
|
||||
step_data["assertion_passed"] = True
|
||||
|
||||
if metadata:
|
||||
step_data["metadata"] = metadata
|
||||
|
||||
self.steps.append(step_data)
|
||||
|
||||
# Token-efficient output (single line)
|
||||
status = "✓" if not assertion or step_data.get("assertion_passed") else "✗"
|
||||
screenshot_info = (
|
||||
f" [{screenshot_result['width']}x{screenshot_result['height']}]" if self.inline else ""
|
||||
)
|
||||
print(
|
||||
f"{status} Step {self.current_step}: {description} ({step_time:.1f}s){screenshot_info}"
|
||||
)
|
||||
|
||||
def _capture_screenshot(self, output_path: Path) -> bool:
|
||||
"""Capture screenshot using simctl."""
|
||||
cmd = ["xcrun", "simctl", "io"]
|
||||
|
||||
if self.udid:
|
||||
cmd.append(self.udid)
|
||||
else:
|
||||
cmd.append("booted")
|
||||
|
||||
cmd.extend(["screenshot", str(output_path)])
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def _capture_accessibility(self, output_path: Path) -> int:
|
||||
"""Capture accessibility tree and return element count."""
|
||||
try:
|
||||
# Use shared utility to fetch tree
|
||||
tree = get_accessibility_tree(self.udid, nested=True)
|
||||
|
||||
# Save tree
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(tree, f, indent=2)
|
||||
|
||||
# Count elements using shared utility
|
||||
return count_elements(tree)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def generate_report(self) -> dict[str, str]:
|
||||
"""
|
||||
Generate markdown test report.
|
||||
|
||||
Returns:
|
||||
Dictionary with paths to generated files
|
||||
"""
|
||||
duration = time.time() - self.start_time
|
||||
report_path = self.output_dir / "report.md"
|
||||
|
||||
# Generate markdown
|
||||
with open(report_path, "w") as f:
|
||||
f.write(f"# Test Report: {self.test_name}\n\n")
|
||||
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"**Duration:** {duration:.1f} seconds\n")
|
||||
f.write(f"**Steps:** {len(self.steps)}\n\n")
|
||||
|
||||
# Steps section
|
||||
f.write("## Test Steps\n\n")
|
||||
for step in self.steps:
|
||||
f.write(
|
||||
f"### Step {step['number']}: {step['description']} ({step['timestamp']:.1f}s)\n\n"
|
||||
)
|
||||
f.write(f"\n\n")
|
||||
|
||||
if step.get("assertion"):
|
||||
status = "✓" if step.get("assertion_passed") else "✗"
|
||||
f.write(f"**Assertion:** {step['assertion']} {status}\n\n")
|
||||
|
||||
if step.get("metadata"):
|
||||
f.write("**Metadata:**\n")
|
||||
for key, value in step["metadata"].items():
|
||||
f.write(f"- {key}: {value}\n")
|
||||
f.write("\n")
|
||||
|
||||
f.write(f"**Accessibility Elements:** {step['element_count']}\n\n")
|
||||
f.write("---\n\n")
|
||||
|
||||
# Summary
|
||||
f.write("## Summary\n\n")
|
||||
f.write(f"- Total steps: {len(self.steps)}\n")
|
||||
f.write(f"- Duration: {duration:.1f}s\n")
|
||||
f.write(f"- Screenshots: {len(self.steps)}\n")
|
||||
f.write(f"- Accessibility snapshots: {len(self.steps)}\n")
|
||||
|
||||
# Save metadata JSON
|
||||
metadata_path = self.output_dir / "metadata.json"
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"test_name": self.test_name,
|
||||
"duration": duration,
|
||||
"steps": self.steps,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Token-efficient output
|
||||
print(f"Report: {report_path}")
|
||||
|
||||
return {
|
||||
"markdown_path": str(report_path),
|
||||
"metadata_path": str(metadata_path),
|
||||
"output_dir": str(self.output_dir),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for command-line usage."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Record test execution with screenshots and documentation"
|
||||
)
|
||||
parser.add_argument("--test-name", required=True, help="Name of the test being recorded")
|
||||
parser.add_argument(
|
||||
"--output", default="test-artifacts", help="Output directory for test artifacts"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--udid",
|
||||
help="Device UDID (auto-detects booted simulator if not provided)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--inline",
|
||||
action="store_true",
|
||||
help="Return screenshots as base64 (inline mode for vision-based automation)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
choices=["full", "half", "quarter", "thumb"],
|
||||
default="half",
|
||||
help="Screenshot size for token optimization (default: half)",
|
||||
)
|
||||
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve UDID with auto-detection
|
||||
try:
|
||||
udid = resolve_udid(args.udid)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}")
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
# Create recorder
|
||||
TestRecorder(
|
||||
test_name=args.test_name,
|
||||
output_dir=args.output,
|
||||
udid=udid,
|
||||
inline=args.inline,
|
||||
screenshot_size=args.size,
|
||||
app_name=args.app_name,
|
||||
)
|
||||
|
||||
print("Test recorder initialized. Use the following methods:")
|
||||
print(' recorder.step("description") - Record a test step')
|
||||
print(" recorder.generate_report() - Generate final report")
|
||||
print()
|
||||
print("Example:")
|
||||
print(' recorder.step("Launch app", screen_name="Splash")')
|
||||
print(
|
||||
' recorder.step("Enter credentials", screen_name="Login", state="Empty", metadata={"user": "test"})'
|
||||
)
|
||||
print(' recorder.step("Verify login", assertion="Home screen visible")')
|
||||
print(" recorder.generate_report()")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Visual Diff Tool for iOS Simulator Screenshots
|
||||
|
||||
Compares two screenshots pixel-by-pixel to detect visual changes.
|
||||
Optimized for minimal token output.
|
||||
|
||||
Usage: python scripts/visual_diff.py baseline.png current.png [options]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
except ImportError:
|
||||
print("Error: Pillow not installed. Run: pip3 install pillow")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class VisualDiffer:
|
||||
"""Performs visual comparison between screenshots."""
|
||||
|
||||
def __init__(self, threshold: float = 0.01):
|
||||
"""
|
||||
Initialize differ with threshold.
|
||||
|
||||
Args:
|
||||
threshold: Maximum acceptable difference ratio (0.01 = 1%)
|
||||
"""
|
||||
self.threshold = threshold
|
||||
|
||||
def compare(self, baseline_path: str, current_path: str) -> dict:
|
||||
"""
|
||||
Compare two images and return difference metrics.
|
||||
|
||||
Args:
|
||||
baseline_path: Path to baseline image
|
||||
current_path: Path to current image
|
||||
|
||||
Returns:
|
||||
Dictionary with comparison results
|
||||
"""
|
||||
# Load images
|
||||
try:
|
||||
baseline = Image.open(baseline_path)
|
||||
current = Image.open(current_path)
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: Image not found - {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to load image - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Verify dimensions match
|
||||
if baseline.size != current.size:
|
||||
return {
|
||||
"error": "Image dimensions do not match",
|
||||
"baseline_size": baseline.size,
|
||||
"current_size": current.size,
|
||||
}
|
||||
|
||||
# Convert to RGB if needed
|
||||
if baseline.mode != "RGB":
|
||||
baseline = baseline.convert("RGB")
|
||||
if current.mode != "RGB":
|
||||
current = current.convert("RGB")
|
||||
|
||||
# Calculate difference
|
||||
diff = ImageChops.difference(baseline, current)
|
||||
|
||||
# Calculate metrics
|
||||
total_pixels = baseline.size[0] * baseline.size[1]
|
||||
diff_pixels = self._count_different_pixels(diff)
|
||||
diff_percentage = (diff_pixels / total_pixels) * 100
|
||||
|
||||
# Determine pass/fail
|
||||
passed = diff_percentage <= (self.threshold * 100)
|
||||
|
||||
return {
|
||||
"dimensions": baseline.size,
|
||||
"total_pixels": total_pixels,
|
||||
"different_pixels": diff_pixels,
|
||||
"difference_percentage": round(diff_percentage, 2),
|
||||
"threshold_percentage": self.threshold * 100,
|
||||
"passed": passed,
|
||||
"verdict": "PASS" if passed else "FAIL",
|
||||
}
|
||||
|
||||
def _count_different_pixels(self, diff_image: Image.Image) -> int:
|
||||
"""Count number of pixels that are different."""
|
||||
# Convert to grayscale for easier processing
|
||||
diff_gray = diff_image.convert("L")
|
||||
|
||||
# Count non-zero pixels (different)
|
||||
pixels = diff_gray.getdata()
|
||||
return sum(1 for pixel in pixels if pixel > 10) # Threshold for noise
|
||||
|
||||
def generate_diff_image(self, baseline_path: str, current_path: str, output_path: str) -> None:
|
||||
"""Generate highlighted difference image."""
|
||||
baseline = Image.open(baseline_path).convert("RGB")
|
||||
current = Image.open(current_path).convert("RGB")
|
||||
|
||||
# Create difference image
|
||||
diff = ImageChops.difference(baseline, current)
|
||||
|
||||
# Enhance differences with red overlay
|
||||
diff_enhanced = Image.new("RGB", baseline.size)
|
||||
for x in range(baseline.size[0]):
|
||||
for y in range(baseline.size[1]):
|
||||
diff_pixel = diff.getpixel((x, y))
|
||||
if sum(diff_pixel) > 30: # Threshold for visibility
|
||||
# Highlight in red
|
||||
diff_enhanced.putpixel((x, y), (255, 0, 0))
|
||||
else:
|
||||
# Keep original
|
||||
diff_enhanced.putpixel((x, y), current.getpixel((x, y)))
|
||||
|
||||
diff_enhanced.save(output_path)
|
||||
|
||||
def generate_side_by_side(
|
||||
self, baseline_path: str, current_path: str, output_path: str
|
||||
) -> None:
|
||||
"""Generate side-by-side comparison image."""
|
||||
baseline = Image.open(baseline_path)
|
||||
current = Image.open(current_path)
|
||||
|
||||
# Create combined image
|
||||
width = baseline.size[0] * 2 + 10 # 10px separator
|
||||
height = max(baseline.size[1], current.size[1])
|
||||
combined = Image.new("RGB", (width, height), color=(128, 128, 128))
|
||||
|
||||
# Paste images
|
||||
combined.paste(baseline, (0, 0))
|
||||
combined.paste(current, (baseline.size[0] + 10, 0))
|
||||
|
||||
combined.save(output_path)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description="Compare screenshots for visual differences")
|
||||
parser.add_argument("baseline", help="Path to baseline screenshot")
|
||||
parser.add_argument("current", help="Path to current screenshot")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=".",
|
||||
help="Output directory for diff artifacts (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=0.01,
|
||||
help="Acceptable difference threshold (0.01 = 1%%, default: 0.01)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--details", action="store_true", help="Show detailed output (increases tokens)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create output directory if needed
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize differ
|
||||
differ = VisualDiffer(threshold=args.threshold)
|
||||
|
||||
# Perform comparison
|
||||
result = differ.compare(args.baseline, args.current)
|
||||
|
||||
# Handle dimension mismatch
|
||||
if "error" in result:
|
||||
print(f"Error: {result['error']}")
|
||||
print(f"Baseline: {result['baseline_size']}")
|
||||
print(f"Current: {result['current_size']}")
|
||||
sys.exit(1)
|
||||
|
||||
# Generate artifacts
|
||||
diff_image_path = output_dir / "diff.png"
|
||||
comparison_image_path = output_dir / "side-by-side.png"
|
||||
|
||||
try:
|
||||
differ.generate_diff_image(args.baseline, args.current, str(diff_image_path))
|
||||
differ.generate_side_by_side(args.baseline, args.current, str(comparison_image_path))
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not generate images - {e}")
|
||||
|
||||
# Output results (token-optimized)
|
||||
if args.details:
|
||||
# Detailed output
|
||||
report = {
|
||||
"summary": {
|
||||
"baseline": args.baseline,
|
||||
"current": args.current,
|
||||
"threshold": args.threshold,
|
||||
"passed": result["passed"],
|
||||
},
|
||||
"results": result,
|
||||
"artifacts": {
|
||||
"diff_image": str(diff_image_path),
|
||||
"comparison_image": str(comparison_image_path),
|
||||
},
|
||||
}
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
# Minimal output (default)
|
||||
print(f"Difference: {result['difference_percentage']}% ({result['verdict']})")
|
||||
if result["different_pixels"] > 0:
|
||||
print(f"Changed pixels: {result['different_pixels']:,}")
|
||||
print(f"Artifacts saved to: {output_dir}/")
|
||||
|
||||
# Save JSON report
|
||||
report_path = output_dir / "diff-report.json"
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"baseline": os.path.basename(args.baseline),
|
||||
"current": os.path.basename(args.current),
|
||||
"results": result,
|
||||
"artifacts": {"diff": "diff.png", "comparison": "side-by-side.png"},
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Exit with error if test failed
|
||||
sys.exit(0 if result["passed"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Xcode build automation module.
|
||||
|
||||
Provides structured, modular access to xcodebuild and xcresult functionality.
|
||||
"""
|
||||
|
||||
from .builder import BuildRunner
|
||||
from .cache import XCResultCache
|
||||
from .config import Config
|
||||
from .reporter import OutputFormatter
|
||||
from .xcresult import XCResultParser
|
||||
|
||||
__all__ = ["BuildRunner", "Config", "OutputFormatter", "XCResultCache", "XCResultParser"]
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Xcode build execution.
|
||||
|
||||
Handles xcodebuild command construction and execution with xcresult generation.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import XCResultCache
|
||||
from .config import Config
|
||||
|
||||
|
||||
class BuildRunner:
|
||||
"""
|
||||
Execute xcodebuild commands with xcresult bundle generation.
|
||||
|
||||
Handles scheme auto-detection, command construction, and build/test execution.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str | None = None,
|
||||
workspace_path: str | None = None,
|
||||
scheme: str | None = None,
|
||||
configuration: str = "Debug",
|
||||
simulator: str | None = None,
|
||||
cache: XCResultCache | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize build runner.
|
||||
|
||||
Args:
|
||||
project_path: Path to .xcodeproj
|
||||
workspace_path: Path to .xcworkspace
|
||||
scheme: Build scheme (auto-detected if not provided)
|
||||
configuration: Build configuration (Debug/Release)
|
||||
simulator: Simulator name
|
||||
cache: XCResult cache (creates default if not provided)
|
||||
"""
|
||||
self.project_path = project_path
|
||||
self.workspace_path = workspace_path
|
||||
self.scheme = scheme
|
||||
self.configuration = configuration
|
||||
self.simulator = simulator
|
||||
self.cache = cache or XCResultCache()
|
||||
|
||||
def auto_detect_scheme(self) -> str | None:
|
||||
"""
|
||||
Auto-detect build scheme from project/workspace.
|
||||
|
||||
Returns:
|
||||
Detected scheme name or None
|
||||
"""
|
||||
cmd = ["xcodebuild", "-list"]
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
# Parse schemes from output
|
||||
in_schemes_section = False
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
if "Schemes:" in line:
|
||||
in_schemes_section = True
|
||||
continue
|
||||
|
||||
if in_schemes_section and line and not line.startswith("Build"):
|
||||
# First scheme in list
|
||||
return line
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error auto-detecting scheme: {e}", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
def get_simulator_destination(self) -> str:
|
||||
"""
|
||||
Get xcodebuild destination string.
|
||||
|
||||
Uses config preferences with fallback to auto-detection.
|
||||
|
||||
Priority:
|
||||
1. --simulator CLI flag (self.simulator)
|
||||
2. Config preferred_simulator
|
||||
3. Config last_used_simulator
|
||||
4. Auto-detect first iPhone
|
||||
5. Generic iOS Simulator
|
||||
|
||||
Returns:
|
||||
Destination string for -destination flag
|
||||
"""
|
||||
# Priority 1: CLI flag
|
||||
if self.simulator:
|
||||
return f"platform=iOS Simulator,name={self.simulator}"
|
||||
|
||||
# Priority 2-3: Config preferences
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
preferred = config.get_preferred_simulator()
|
||||
|
||||
if preferred:
|
||||
# Check if preferred simulator exists
|
||||
if self._simulator_exists(preferred):
|
||||
return f"platform=iOS Simulator,name={preferred}"
|
||||
print(f"Warning: Preferred simulator '{preferred}' not available", file=sys.stderr)
|
||||
if config.should_fallback_to_any_iphone():
|
||||
print("Falling back to auto-detection...", file=sys.stderr)
|
||||
else:
|
||||
# Strict mode: don't fallback
|
||||
return f"platform=iOS Simulator,name={preferred}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||
|
||||
# Priority 4-5: Auto-detect
|
||||
return self._auto_detect_simulator()
|
||||
|
||||
def _simulator_exists(self, name: str) -> bool:
|
||||
"""
|
||||
Check if simulator with given name exists and is available.
|
||||
|
||||
Args:
|
||||
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||
|
||||
Returns:
|
||||
True if simulator exists and is available
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Check if simulator name appears in available devices
|
||||
return any(name in line and "(" in line for line in result.stdout.split("\n"))
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def _extract_simulator_name_from_destination(self, destination: str) -> str | None:
|
||||
"""
|
||||
Extract simulator name from destination string.
|
||||
|
||||
Args:
|
||||
destination: Destination string (e.g., "platform=iOS Simulator,name=iPhone 16 Pro")
|
||||
|
||||
Returns:
|
||||
Simulator name or None
|
||||
"""
|
||||
# Pattern: name=<simulator name>
|
||||
match = re.search(r"name=([^,]+)", destination)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def _auto_detect_simulator(self) -> str:
|
||||
"""
|
||||
Auto-detect best available iOS simulator.
|
||||
|
||||
Returns:
|
||||
Destination string for -destination flag
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xcrun", "simctl", "list", "devices", "available", "iOS"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse available simulators, prefer latest iPhone
|
||||
# Looking for lines like: "iPhone 16 Pro (12345678-1234-1234-1234-123456789012) (Shutdown)"
|
||||
for line in result.stdout.split("\n"):
|
||||
if "iPhone" in line and "(" in line:
|
||||
# Extract device name
|
||||
name = line.split("(")[0].strip()
|
||||
if name:
|
||||
return f"platform=iOS Simulator,name={name}"
|
||||
|
||||
# Fallback to generic iOS Simulator if no iPhone found
|
||||
return "generic/platform=iOS Simulator"
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Warning: Could not auto-detect simulator: {e}", file=sys.stderr)
|
||||
return "generic/platform=iOS Simulator"
|
||||
|
||||
def build(self, clean: bool = False) -> tuple[bool, str, str]:
|
||||
"""
|
||||
Build the project.
|
||||
|
||||
Args:
|
||||
clean: Perform clean build
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||
"""
|
||||
# Auto-detect scheme if needed
|
||||
if not self.scheme:
|
||||
self.scheme = self.auto_detect_scheme()
|
||||
if not self.scheme:
|
||||
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
# Generate xcresult ID and path
|
||||
xcresult_id = self.cache.generate_id()
|
||||
xcresult_path = self.cache.get_path(xcresult_id)
|
||||
|
||||
# Build command
|
||||
cmd = ["xcodebuild", "-quiet"] # Suppress verbose output
|
||||
|
||||
if clean:
|
||||
cmd.append("clean")
|
||||
|
||||
cmd.append("build")
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
print("Error: No project or workspace specified", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-scheme",
|
||||
self.scheme,
|
||||
"-configuration",
|
||||
self.configuration,
|
||||
"-destination",
|
||||
self.get_simulator_destination(),
|
||||
"-resultBundlePath",
|
||||
str(xcresult_path),
|
||||
]
|
||||
)
|
||||
|
||||
# Execute build
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False # Don't raise on non-zero exit
|
||||
)
|
||||
|
||||
success = result.returncode == 0
|
||||
|
||||
# xcresult bundle should be created even on failure
|
||||
if not xcresult_path.exists():
|
||||
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||
return (success, "", result.stderr)
|
||||
|
||||
# Auto-update config with last used simulator (on success only)
|
||||
if success:
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
destination = self.get_simulator_destination()
|
||||
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||
|
||||
if simulator_name:
|
||||
config.update_last_used_simulator(simulator_name)
|
||||
config.save()
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail build if config update fails
|
||||
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||
|
||||
return (success, xcresult_id, result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error executing build: {e}", file=sys.stderr)
|
||||
return (False, "", str(e))
|
||||
|
||||
def test(self, test_suite: str | None = None) -> tuple[bool, str, str]:
|
||||
"""
|
||||
Run tests.
|
||||
|
||||
Args:
|
||||
test_suite: Specific test suite to run
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, xcresult_id: str, stderr: str)
|
||||
"""
|
||||
# Auto-detect scheme if needed
|
||||
if not self.scheme:
|
||||
self.scheme = self.auto_detect_scheme()
|
||||
if not self.scheme:
|
||||
print("Error: Could not auto-detect scheme. Use --scheme", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
# Generate xcresult ID and path
|
||||
xcresult_id = self.cache.generate_id()
|
||||
xcresult_path = self.cache.get_path(xcresult_id)
|
||||
|
||||
# Build command
|
||||
cmd = ["xcodebuild", "-quiet", "test"]
|
||||
|
||||
if self.workspace_path:
|
||||
cmd.extend(["-workspace", self.workspace_path])
|
||||
elif self.project_path:
|
||||
cmd.extend(["-project", self.project_path])
|
||||
else:
|
||||
print("Error: No project or workspace specified", file=sys.stderr)
|
||||
return (False, "", "")
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-scheme",
|
||||
self.scheme,
|
||||
"-destination",
|
||||
self.get_simulator_destination(),
|
||||
"-resultBundlePath",
|
||||
str(xcresult_path),
|
||||
]
|
||||
)
|
||||
|
||||
if test_suite:
|
||||
cmd.extend(["-only-testing", test_suite])
|
||||
|
||||
# Execute tests
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
success = result.returncode == 0
|
||||
|
||||
# xcresult bundle should be created even on failure
|
||||
if not xcresult_path.exists():
|
||||
print("Warning: xcresult bundle was not created", file=sys.stderr)
|
||||
return (success, "", result.stderr)
|
||||
|
||||
# Auto-update config with last used simulator (on success only)
|
||||
if success:
|
||||
try:
|
||||
# Determine project directory from project/workspace path
|
||||
project_dir = None
|
||||
if self.project_path:
|
||||
project_dir = Path(self.project_path).parent
|
||||
elif self.workspace_path:
|
||||
project_dir = Path(self.workspace_path).parent
|
||||
|
||||
config = Config.load(project_dir=project_dir)
|
||||
destination = self.get_simulator_destination()
|
||||
simulator_name = self._extract_simulator_name_from_destination(destination)
|
||||
|
||||
if simulator_name:
|
||||
config.update_last_used_simulator(simulator_name)
|
||||
config.save()
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail test if config update fails
|
||||
print(f"Warning: Could not update config: {e}", file=sys.stderr)
|
||||
|
||||
return (success, xcresult_id, result.stderr)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error executing tests: {e}", file=sys.stderr)
|
||||
return (False, "", str(e))
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
XCResult cache management.
|
||||
|
||||
Handles storage, retrieval, and lifecycle of xcresult bundles for progressive disclosure.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class XCResultCache:
|
||||
"""
|
||||
Manage xcresult bundle cache for progressive disclosure.
|
||||
|
||||
Stores xcresult bundles with timestamp-based IDs and provides
|
||||
retrieval and cleanup operations.
|
||||
"""
|
||||
|
||||
# Default cache directory
|
||||
DEFAULT_CACHE_DIR = Path.home() / ".ios-simulator-skill" / "xcresults"
|
||||
|
||||
def __init__(self, cache_dir: Path | None = None):
|
||||
"""
|
||||
Initialize cache manager.
|
||||
|
||||
Args:
|
||||
cache_dir: Custom cache directory (uses default if not specified)
|
||||
"""
|
||||
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_id(self, prefix: str = "xcresult") -> str:
|
||||
"""
|
||||
Generate timestamped xcresult ID.
|
||||
|
||||
Args:
|
||||
prefix: ID prefix (default: "xcresult")
|
||||
|
||||
Returns:
|
||||
ID string like "xcresult-20251018-143052"
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return f"{prefix}-{timestamp}"
|
||||
|
||||
def get_path(self, xcresult_id: str) -> Path:
|
||||
"""
|
||||
Get full path for xcresult ID.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
Path to xcresult bundle
|
||||
"""
|
||||
# Handle both with and without .xcresult extension
|
||||
if xcresult_id.endswith(".xcresult"):
|
||||
return self.cache_dir / xcresult_id
|
||||
return self.cache_dir / f"{xcresult_id}.xcresult"
|
||||
|
||||
def exists(self, xcresult_id: str) -> bool:
|
||||
"""
|
||||
Check if xcresult bundle exists.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
True if bundle exists
|
||||
"""
|
||||
return self.get_path(xcresult_id).exists()
|
||||
|
||||
def save(self, source_path: Path, xcresult_id: str | None = None) -> str:
|
||||
"""
|
||||
Save xcresult bundle to cache.
|
||||
|
||||
Args:
|
||||
source_path: Source xcresult bundle path
|
||||
xcresult_id: Optional custom ID (generates if not provided)
|
||||
|
||||
Returns:
|
||||
xcresult ID
|
||||
"""
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Source xcresult not found: {source_path}")
|
||||
|
||||
# Generate ID if not provided
|
||||
if not xcresult_id:
|
||||
xcresult_id = self.generate_id()
|
||||
|
||||
# Get destination path
|
||||
dest_path = self.get_path(xcresult_id)
|
||||
|
||||
# Copy xcresult bundle (it's a directory)
|
||||
if dest_path.exists():
|
||||
shutil.rmtree(dest_path)
|
||||
|
||||
shutil.copytree(source_path, dest_path)
|
||||
|
||||
return xcresult_id
|
||||
|
||||
def list(self, limit: int = 10) -> list[dict]:
|
||||
"""
|
||||
List recent xcresult bundles.
|
||||
|
||||
Args:
|
||||
limit: Maximum number to return
|
||||
|
||||
Returns:
|
||||
List of xcresult metadata dicts
|
||||
"""
|
||||
if not self.cache_dir.exists():
|
||||
return []
|
||||
|
||||
results = []
|
||||
for path in sorted(
|
||||
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)[:limit]:
|
||||
# Calculate bundle size
|
||||
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": path.stem,
|
||||
"path": str(path),
|
||||
"created": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
|
||||
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def cleanup(self, keep_recent: int = 20) -> int:
|
||||
"""
|
||||
Clean up old xcresult bundles.
|
||||
|
||||
Args:
|
||||
keep_recent: Number of recent bundles to keep
|
||||
|
||||
Returns:
|
||||
Number of bundles removed
|
||||
"""
|
||||
if not self.cache_dir.exists():
|
||||
return 0
|
||||
|
||||
# Get all bundles sorted by modification time
|
||||
all_bundles = sorted(
|
||||
self.cache_dir.glob("*.xcresult"), key=lambda p: p.stat().st_mtime, reverse=True
|
||||
)
|
||||
|
||||
# Remove old bundles
|
||||
removed = 0
|
||||
for bundle_path in all_bundles[keep_recent:]:
|
||||
shutil.rmtree(bundle_path)
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
def get_size_mb(self, xcresult_id: str) -> float:
|
||||
"""
|
||||
Get size of xcresult bundle in MB.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
Size in MB
|
||||
"""
|
||||
path = self.get_path(xcresult_id)
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
|
||||
size_bytes = sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
return round(size_bytes / (1024 * 1024), 2)
|
||||
|
||||
def save_stderr(self, xcresult_id: str, stderr: str) -> None:
|
||||
"""
|
||||
Save stderr output alongside xcresult bundle.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
stderr: stderr output from xcodebuild
|
||||
"""
|
||||
if not stderr:
|
||||
return
|
||||
|
||||
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||
stderr_path.write_text(stderr, encoding="utf-8")
|
||||
|
||||
def get_stderr(self, xcresult_id: str) -> str:
|
||||
"""
|
||||
Retrieve cached stderr output.
|
||||
|
||||
Args:
|
||||
xcresult_id: XCResult ID
|
||||
|
||||
Returns:
|
||||
stderr content or empty string if not found
|
||||
"""
|
||||
stderr_path = self.cache_dir / f"{xcresult_id}.stderr"
|
||||
if not stderr_path.exists():
|
||||
return ""
|
||||
|
||||
return stderr_path.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Configuration management for iOS Simulator Skill.
|
||||
|
||||
Handles loading, validation, and auto-updating of project-local config files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Project-local configuration with auto-learning.
|
||||
|
||||
Config file location: .claude/skills/<skill-directory-name>/config.json
|
||||
|
||||
The skill directory name is auto-detected from the installation location,
|
||||
so configs work regardless of what users name the skill directory.
|
||||
|
||||
Auto-updates last_used_simulator after successful builds.
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"device": {
|
||||
"preferred_simulator": None,
|
||||
"preferred_os_version": None,
|
||||
"fallback_to_any_iphone": True,
|
||||
"last_used_simulator": None,
|
||||
"last_used_at": None,
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, data: dict[str, Any], config_path: Path):
|
||||
"""
|
||||
Initialize config.
|
||||
|
||||
Args:
|
||||
data: Config data dict
|
||||
config_path: Path to config file
|
||||
"""
|
||||
self.data = data
|
||||
self.config_path = config_path
|
||||
|
||||
@staticmethod
|
||||
def load(project_dir: Path | None = None) -> "Config":
|
||||
"""
|
||||
Load config from project directory.
|
||||
|
||||
Args:
|
||||
project_dir: Project root (defaults to cwd)
|
||||
|
||||
Returns:
|
||||
Config instance (creates default if not found)
|
||||
|
||||
Note:
|
||||
The skill directory name is auto-detected from the installation location,
|
||||
so configs work regardless of what users name the skill directory.
|
||||
"""
|
||||
if project_dir is None:
|
||||
project_dir = Path.cwd()
|
||||
|
||||
# Auto-detect skill directory name from actual installation location
|
||||
# This file is at: skill/scripts/xcode/config.py
|
||||
# Navigate up to skill/ directory and use its name
|
||||
skill_root = Path(__file__).parent.parent.parent # xcode/ -> scripts/ -> skill/
|
||||
skill_name = skill_root.name
|
||||
|
||||
config_path = project_dir / ".claude" / "skills" / skill_name / "config.json"
|
||||
|
||||
# Load existing config
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Merge with defaults (in case new fields added)
|
||||
merged = Config._merge_with_defaults(data)
|
||||
return Config(merged, config_path)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Warning: Invalid JSON in {config_path}: {e}", file=sys.stderr)
|
||||
print("Using default config", file=sys.stderr)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load config: {e}", file=sys.stderr)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
|
||||
# Return default config (will be created on first save)
|
||||
return Config(Config.DEFAULT_CONFIG.copy(), config_path)
|
||||
|
||||
@staticmethod
|
||||
def _merge_with_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Merge user config with defaults.
|
||||
|
||||
Args:
|
||||
data: User config data
|
||||
|
||||
Returns:
|
||||
Merged config with all default fields
|
||||
"""
|
||||
merged = Config.DEFAULT_CONFIG.copy()
|
||||
|
||||
# Deep merge device section
|
||||
if "device" in data:
|
||||
merged["device"].update(data["device"])
|
||||
|
||||
return merged
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
Save config to file atomically.
|
||||
|
||||
Uses temp file + rename for atomic writes.
|
||||
Creates parent directories if needed.
|
||||
"""
|
||||
try:
|
||||
# Create parent directories
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Atomic write: temp file + rename
|
||||
temp_path = self.config_path.with_suffix(".tmp")
|
||||
|
||||
with open(temp_path, "w") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
f.write("\n") # Trailing newline
|
||||
|
||||
# Atomic rename
|
||||
temp_path.replace(self.config_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save config: {e}", file=sys.stderr)
|
||||
|
||||
def update_last_used_simulator(self, name: str) -> None:
|
||||
"""
|
||||
Update last used simulator and timestamp.
|
||||
|
||||
Args:
|
||||
name: Simulator name (e.g., "iPhone 16 Pro")
|
||||
"""
|
||||
self.data["device"]["last_used_simulator"] = name
|
||||
self.data["device"]["last_used_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
def get_preferred_simulator(self) -> str | None:
|
||||
"""
|
||||
Get preferred simulator.
|
||||
|
||||
Returns:
|
||||
Simulator name or None
|
||||
|
||||
Priority:
|
||||
1. preferred_simulator (manual preference)
|
||||
2. last_used_simulator (auto-learned)
|
||||
3. None (use auto-detection)
|
||||
"""
|
||||
device = self.data.get("device", {})
|
||||
|
||||
# Manual preference takes priority
|
||||
if device.get("preferred_simulator"):
|
||||
return device["preferred_simulator"]
|
||||
|
||||
# Auto-learned preference
|
||||
if device.get("last_used_simulator"):
|
||||
return device["last_used_simulator"]
|
||||
|
||||
return None
|
||||
|
||||
def should_fallback_to_any_iphone(self) -> bool:
|
||||
"""
|
||||
Check if fallback to any iPhone is enabled.
|
||||
|
||||
Returns:
|
||||
True if should fallback, False otherwise
|
||||
"""
|
||||
return self.data.get("device", {}).get("fallback_to_any_iphone", True)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Build/test output formatting.
|
||||
|
||||
Provides multiple output formats with progressive disclosure support.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class OutputFormatter:
|
||||
"""
|
||||
Format build/test results for display.
|
||||
|
||||
Supports ultra-minimal default output, verbose mode, and JSON output.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def format_minimal(
|
||||
status: str,
|
||||
error_count: int,
|
||||
warning_count: int,
|
||||
xcresult_id: str,
|
||||
test_info: dict | None = None,
|
||||
hints: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format ultra-minimal output (5-10 tokens).
|
||||
|
||||
Args:
|
||||
status: Build status (SUCCESS/FAILED)
|
||||
error_count: Number of errors
|
||||
warning_count: Number of warnings
|
||||
xcresult_id: XCResult bundle ID
|
||||
test_info: Optional test results dict
|
||||
hints: Optional list of actionable hints
|
||||
|
||||
Returns:
|
||||
Minimal formatted string
|
||||
|
||||
Example:
|
||||
Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
|
||||
Tests: PASS (12/12 passed, 4.2s) [xcresult-20251018-143052]
|
||||
"""
|
||||
lines = []
|
||||
|
||||
if test_info:
|
||||
# Test mode
|
||||
total = test_info.get("total", 0)
|
||||
passed = test_info.get("passed", 0)
|
||||
failed = test_info.get("failed", 0)
|
||||
duration = test_info.get("duration", 0.0)
|
||||
|
||||
test_status = "PASS" if failed == 0 else "FAIL"
|
||||
lines.append(
|
||||
f"Tests: {test_status} ({passed}/{total} passed, {duration:.1f}s) [{xcresult_id}]"
|
||||
)
|
||||
else:
|
||||
# Build mode
|
||||
lines.append(
|
||||
f"Build: {status} ({error_count} errors, {warning_count} warnings) [{xcresult_id}]"
|
||||
)
|
||||
|
||||
# Add hints if provided and build failed
|
||||
if hints and status == "FAILED":
|
||||
lines.append("")
|
||||
lines.extend(hints)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_errors(errors: list[dict], limit: int = 10) -> str:
|
||||
"""
|
||||
Format error details.
|
||||
|
||||
Args:
|
||||
errors: List of error dicts
|
||||
limit: Maximum errors to show
|
||||
|
||||
Returns:
|
||||
Formatted error list
|
||||
"""
|
||||
if not errors:
|
||||
return "No errors found."
|
||||
|
||||
lines = [f"Errors ({len(errors)}):"]
|
||||
lines.append("")
|
||||
|
||||
for i, error in enumerate(errors[:limit], 1):
|
||||
message = error.get("message", "Unknown error")
|
||||
location = error.get("location", {})
|
||||
|
||||
# Format location
|
||||
loc_parts = []
|
||||
if location.get("file"):
|
||||
file_path = location["file"].replace("file://", "")
|
||||
loc_parts.append(file_path)
|
||||
if location.get("line"):
|
||||
loc_parts.append(f"line {location['line']}")
|
||||
|
||||
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||
|
||||
lines.append(f"{i}. {message}")
|
||||
lines.append(f" Location: {location_str}")
|
||||
lines.append("")
|
||||
|
||||
if len(errors) > limit:
|
||||
lines.append(f"... and {len(errors) - limit} more errors")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_warnings(warnings: list[dict], limit: int = 10) -> str:
|
||||
"""
|
||||
Format warning details.
|
||||
|
||||
Args:
|
||||
warnings: List of warning dicts
|
||||
limit: Maximum warnings to show
|
||||
|
||||
Returns:
|
||||
Formatted warning list
|
||||
"""
|
||||
if not warnings:
|
||||
return "No warnings found."
|
||||
|
||||
lines = [f"Warnings ({len(warnings)}):"]
|
||||
lines.append("")
|
||||
|
||||
for i, warning in enumerate(warnings[:limit], 1):
|
||||
message = warning.get("message", "Unknown warning")
|
||||
location = warning.get("location", {})
|
||||
|
||||
# Format location
|
||||
loc_parts = []
|
||||
if location.get("file"):
|
||||
file_path = location["file"].replace("file://", "")
|
||||
loc_parts.append(file_path)
|
||||
if location.get("line"):
|
||||
loc_parts.append(f"line {location['line']}")
|
||||
|
||||
location_str = ":".join(loc_parts) if loc_parts else "unknown location"
|
||||
|
||||
lines.append(f"{i}. {message}")
|
||||
lines.append(f" Location: {location_str}")
|
||||
lines.append("")
|
||||
|
||||
if len(warnings) > limit:
|
||||
lines.append(f"... and {len(warnings) - limit} more warnings")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_log(log: str, lines: int = 50) -> str:
|
||||
"""
|
||||
Format build log (show last N lines).
|
||||
|
||||
Args:
|
||||
log: Full build log
|
||||
lines: Number of lines to show
|
||||
|
||||
Returns:
|
||||
Formatted log excerpt
|
||||
"""
|
||||
if not log:
|
||||
return "No build log available."
|
||||
|
||||
log_lines = log.strip().split("\n")
|
||||
|
||||
if len(log_lines) <= lines:
|
||||
return log
|
||||
|
||||
# Show last N lines
|
||||
excerpt = log_lines[-lines:]
|
||||
return f"... (showing last {lines} lines of {len(log_lines)})\n\n" + "\n".join(excerpt)
|
||||
|
||||
@staticmethod
|
||||
def format_json(data: dict) -> str:
|
||||
"""
|
||||
Format data as JSON.
|
||||
|
||||
Args:
|
||||
data: Data to format
|
||||
|
||||
Returns:
|
||||
Pretty-printed JSON string
|
||||
"""
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def generate_hints(errors: list[dict]) -> list[str]:
|
||||
"""
|
||||
Generate actionable hints based on error types.
|
||||
|
||||
Args:
|
||||
errors: List of error dicts
|
||||
|
||||
Returns:
|
||||
List of hint strings
|
||||
"""
|
||||
hints = []
|
||||
error_types: set[str] = set()
|
||||
|
||||
# Collect error types
|
||||
for error in errors:
|
||||
error_type = error.get("type", "unknown")
|
||||
error_types.add(error_type)
|
||||
|
||||
# Generate hints based on error types
|
||||
if "provisioning" in error_types:
|
||||
hints.append("Provisioning profile issue detected:")
|
||||
hints.append(" • Ensure you have a valid provisioning profile for iOS Simulator")
|
||||
hints.append(
|
||||
' • For simulator builds, use CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO'
|
||||
)
|
||||
hints.append(" • Or specify simulator explicitly: --simulator 'iPhone 16 Pro'")
|
||||
|
||||
if "signing" in error_types:
|
||||
hints.append("Code signing issue detected:")
|
||||
hints.append(" • For simulator builds, code signing is not required")
|
||||
hints.append(" • Ensure build settings target iOS Simulator, not physical device")
|
||||
hints.append(" • Check destination: platform=iOS Simulator,name=<device>")
|
||||
|
||||
if not error_types or "build" in error_types:
|
||||
# Generic hints when error type is unknown
|
||||
if any("destination" in error.get("message", "").lower() for error in errors):
|
||||
hints.append("Device selection issue detected:")
|
||||
hints.append(" • List available simulators: xcrun simctl list devices available")
|
||||
hints.append(" • Specify simulator: --simulator 'iPhone 16 Pro'")
|
||||
|
||||
return hints
|
||||
|
||||
@staticmethod
|
||||
def format_verbose(
|
||||
status: str,
|
||||
error_count: int,
|
||||
warning_count: int,
|
||||
xcresult_id: str,
|
||||
errors: list[dict] | None = None,
|
||||
warnings: list[dict] | None = None,
|
||||
test_info: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format verbose output with error/warning details.
|
||||
|
||||
Args:
|
||||
status: Build status
|
||||
error_count: Error count
|
||||
warning_count: Warning count
|
||||
xcresult_id: XCResult ID
|
||||
errors: Optional error list
|
||||
warnings: Optional warning list
|
||||
test_info: Optional test results
|
||||
|
||||
Returns:
|
||||
Verbose formatted output
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
if test_info:
|
||||
total = test_info.get("total", 0)
|
||||
passed = test_info.get("passed", 0)
|
||||
failed = test_info.get("failed", 0)
|
||||
duration = test_info.get("duration", 0.0)
|
||||
|
||||
test_status = "PASS" if failed == 0 else "FAIL"
|
||||
lines.append(f"Tests: {test_status}")
|
||||
lines.append(f" Total: {total}")
|
||||
lines.append(f" Passed: {passed}")
|
||||
lines.append(f" Failed: {failed}")
|
||||
lines.append(f" Duration: {duration:.1f}s")
|
||||
else:
|
||||
lines.append(f"Build: {status}")
|
||||
|
||||
lines.append(f"XCResult: {xcresult_id}")
|
||||
lines.append("")
|
||||
|
||||
# Errors
|
||||
if errors and len(errors) > 0:
|
||||
lines.append(OutputFormatter.format_errors(errors, limit=5))
|
||||
lines.append("")
|
||||
|
||||
# Warnings
|
||||
if warnings and len(warnings) > 0:
|
||||
lines.append(OutputFormatter.format_warnings(warnings, limit=5))
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append(f"Summary: {error_count} errors, {warning_count} warnings")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
XCResult bundle parser.
|
||||
|
||||
Extracts structured data from xcresult bundles using xcresulttool.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class XCResultParser:
|
||||
"""
|
||||
Parse xcresult bundles to extract build/test data.
|
||||
|
||||
Uses xcresulttool to extract structured JSON data from Apple's
|
||||
xcresult bundle format.
|
||||
"""
|
||||
|
||||
def __init__(self, xcresult_path: Path, stderr: str = ""):
|
||||
"""
|
||||
Initialize parser.
|
||||
|
||||
Args:
|
||||
xcresult_path: Path to xcresult bundle
|
||||
stderr: Optional stderr output for fallback parsing
|
||||
"""
|
||||
self.xcresult_path = xcresult_path
|
||||
self.stderr = stderr
|
||||
|
||||
if xcresult_path and not xcresult_path.exists():
|
||||
raise FileNotFoundError(f"XCResult bundle not found: {xcresult_path}")
|
||||
|
||||
def get_build_results(self) -> dict | None:
|
||||
"""
|
||||
Get build results as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None on error
|
||||
"""
|
||||
return self._run_xcresulttool(["get", "build-results"])
|
||||
|
||||
def get_test_results(self) -> dict | None:
|
||||
"""
|
||||
Get test results summary as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict or None on error
|
||||
"""
|
||||
return self._run_xcresulttool(["get", "test-results", "summary"])
|
||||
|
||||
def get_build_log(self) -> str | None:
|
||||
"""
|
||||
Get build log as plain text.
|
||||
|
||||
Returns:
|
||||
Build log string or None on error
|
||||
"""
|
||||
result = self._run_xcresulttool(["get", "log", "--type", "build"], parse_json=False)
|
||||
return result if result else None
|
||||
|
||||
def count_issues(self) -> tuple[int, int]:
|
||||
"""
|
||||
Count errors and warnings from build results.
|
||||
|
||||
Returns:
|
||||
Tuple of (error_count, warning_count)
|
||||
"""
|
||||
error_count = 0
|
||||
warning_count = 0
|
||||
|
||||
build_results = self.get_build_results()
|
||||
|
||||
if build_results:
|
||||
try:
|
||||
# Try top-level errors/warnings first (newer xcresult format)
|
||||
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||
error_count = len(build_results["errors"])
|
||||
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||
warning_count = len(build_results["warnings"])
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if error_count == 0 and warning_count == 0:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if actions:
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
|
||||
# Count errors
|
||||
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||
error_count = len(error_summaries)
|
||||
|
||||
# Count warnings
|
||||
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||
warning_count = len(warning_summaries)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse issue counts from xcresult: {e}", file=sys.stderr)
|
||||
|
||||
# If no errors found in xcresult but stderr available, count stderr errors
|
||||
if error_count == 0 and self.stderr:
|
||||
stderr_errors = self._parse_stderr_errors()
|
||||
error_count = len(stderr_errors)
|
||||
|
||||
return (error_count, warning_count)
|
||||
|
||||
def get_errors(self) -> list[dict]:
|
||||
"""
|
||||
Get detailed error information.
|
||||
|
||||
Returns:
|
||||
List of error dicts with message, file, line info
|
||||
"""
|
||||
build_results = self.get_build_results()
|
||||
errors = []
|
||||
|
||||
# Try to get errors from xcresult
|
||||
if build_results:
|
||||
try:
|
||||
# Try top-level errors first (newer xcresult format)
|
||||
if "errors" in build_results and isinstance(build_results.get("errors"), list):
|
||||
for error in build_results["errors"]:
|
||||
errors.append(
|
||||
{
|
||||
"message": error.get("message", "Unknown error"),
|
||||
"type": error.get("issueType", "error"),
|
||||
"location": self._extract_location_from_url(error.get("sourceURL")),
|
||||
}
|
||||
)
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if not errors:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if actions:
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
error_summaries = issues.get("errorSummaries", {}).get("_values", [])
|
||||
|
||||
for error in error_summaries:
|
||||
errors.append(
|
||||
{
|
||||
"message": error.get("message", {}).get(
|
||||
"_value", "Unknown error"
|
||||
),
|
||||
"type": error.get("issueType", {}).get("_value", "error"),
|
||||
"location": self._extract_location(error),
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse errors from xcresult: {e}", file=sys.stderr)
|
||||
|
||||
# If no errors found in xcresult but stderr available, parse stderr
|
||||
if not errors and self.stderr:
|
||||
errors = self._parse_stderr_errors()
|
||||
|
||||
return errors
|
||||
|
||||
def get_warnings(self) -> list[dict]:
|
||||
"""
|
||||
Get detailed warning information.
|
||||
|
||||
Returns:
|
||||
List of warning dicts with message, file, line info
|
||||
"""
|
||||
build_results = self.get_build_results()
|
||||
if not build_results:
|
||||
return []
|
||||
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
# Try top-level warnings first (newer xcresult format)
|
||||
if "warnings" in build_results and isinstance(build_results.get("warnings"), list):
|
||||
for warning in build_results["warnings"]:
|
||||
warnings.append(
|
||||
{
|
||||
"message": warning.get("message", "Unknown warning"),
|
||||
"type": warning.get("issueType", "warning"),
|
||||
"location": self._extract_location_from_url(warning.get("sourceURL")),
|
||||
}
|
||||
)
|
||||
|
||||
# If not found, try legacy format: actions[0].buildResult.issues
|
||||
if not warnings:
|
||||
actions = build_results.get("actions", {}).get("_values", [])
|
||||
if not actions:
|
||||
return []
|
||||
|
||||
build_result = actions[0].get("buildResult", {})
|
||||
issues = build_result.get("issues", {})
|
||||
warning_summaries = issues.get("warningSummaries", {}).get("_values", [])
|
||||
|
||||
for warning in warning_summaries:
|
||||
warnings.append(
|
||||
{
|
||||
"message": warning.get("message", {}).get("_value", "Unknown warning"),
|
||||
"type": warning.get("issueType", {}).get("_value", "warning"),
|
||||
"location": self._extract_location(warning),
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
print(f"Warning: Could not parse warnings: {e}", file=sys.stderr)
|
||||
|
||||
return warnings
|
||||
|
||||
def _extract_location(self, issue: dict) -> dict:
|
||||
"""
|
||||
Extract file location from issue.
|
||||
|
||||
Args:
|
||||
issue: Issue dict from xcresult
|
||||
|
||||
Returns:
|
||||
Location dict with file, line, column
|
||||
"""
|
||||
location = {"file": None, "line": None, "column": None}
|
||||
|
||||
try:
|
||||
doc_location = issue.get("documentLocationInCreatingWorkspace", {})
|
||||
location["file"] = doc_location.get("url", {}).get("_value")
|
||||
location["line"] = doc_location.get("startingLineNumber", {}).get("_value")
|
||||
location["column"] = doc_location.get("startingColumnNumber", {}).get("_value")
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
return location
|
||||
|
||||
def _extract_location_from_url(self, source_url: str | None) -> dict:
|
||||
"""
|
||||
Extract file location from sourceURL (newer xcresult format).
|
||||
|
||||
Args:
|
||||
source_url: Source URL like "file:///path/to/file.swift#StartingLineNumber=134&..."
|
||||
|
||||
Returns:
|
||||
Location dict with file, line, column
|
||||
"""
|
||||
location = {"file": None, "line": None, "column": None}
|
||||
|
||||
if not source_url:
|
||||
return location
|
||||
|
||||
try:
|
||||
# Split URL and fragment
|
||||
if "#" in source_url:
|
||||
file_part, fragment = source_url.split("#", 1)
|
||||
|
||||
# Extract file path
|
||||
location["file"] = file_part.replace("file://", "")
|
||||
|
||||
# Parse fragment parameters
|
||||
params = {}
|
||||
for param in fragment.split("&"):
|
||||
if "=" in param:
|
||||
key, value = param.split("=", 1)
|
||||
params[key] = value
|
||||
|
||||
# Extract line and column
|
||||
location["line"] = (
|
||||
int(params.get("StartingLineNumber", 0)) + 1
|
||||
if "StartingLineNumber" in params
|
||||
else None
|
||||
)
|
||||
location["column"] = (
|
||||
int(params.get("StartingColumnNumber", 0)) + 1
|
||||
if "StartingColumnNumber" in params
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# No fragment, just file path
|
||||
location["file"] = source_url.replace("file://", "")
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return location
|
||||
|
||||
def _run_xcresulttool(self, args: list[str], parse_json: bool = True) -> Any | None:
|
||||
"""
|
||||
Run xcresulttool command.
|
||||
|
||||
Args:
|
||||
args: Command arguments (after 'xcresulttool')
|
||||
parse_json: Whether to parse output as JSON
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict, plain text, or None on error
|
||||
"""
|
||||
if not self.xcresult_path:
|
||||
return None
|
||||
|
||||
cmd = ["xcrun", "xcresulttool"] + args + ["--path", str(self.xcresult_path)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
if parse_json:
|
||||
return json.loads(result.stdout)
|
||||
return result.stdout
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running xcresulttool: {e}", file=sys.stderr)
|
||||
print(f"stderr: {e.stderr}", file=sys.stderr)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON from xcresulttool: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def _parse_stderr_errors(self) -> list[dict]:
|
||||
"""
|
||||
Parse common errors from stderr output as fallback.
|
||||
|
||||
Returns:
|
||||
List of error dicts parsed from stderr
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not self.stderr:
|
||||
return errors
|
||||
|
||||
# Pattern 0: Swift/Clang compilation errors (e.g., "/path/file.swift:135:59: error: message")
|
||||
compilation_error_pattern = (
|
||||
r"^(?P<file>[^:]+):(?P<line>\d+):(?P<column>\d+):\s*error:\s*(?P<message>.+?)$"
|
||||
)
|
||||
for match in re.finditer(compilation_error_pattern, self.stderr, re.MULTILINE):
|
||||
errors.append(
|
||||
{
|
||||
"message": match.group("message").strip(),
|
||||
"type": "compilation",
|
||||
"location": {
|
||||
"file": match.group("file"),
|
||||
"line": int(match.group("line")),
|
||||
"column": int(match.group("column")),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 1: xcodebuild top-level errors (e.g., "xcodebuild: error: Unable to find...")
|
||||
xcodebuild_error_pattern = r"xcodebuild:\s*error:\s*(?P<message>.*?)(?:\n\n|\Z)"
|
||||
for match in re.finditer(xcodebuild_error_pattern, self.stderr, re.DOTALL):
|
||||
message = match.group("message").strip()
|
||||
# Clean up multi-line messages
|
||||
message = " ".join(line.strip() for line in message.split("\n") if line.strip())
|
||||
errors.append(
|
||||
{
|
||||
"message": message,
|
||||
"type": "build",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 2: Provisioning profile errors
|
||||
provisioning_pattern = r"error:.*?provisioning profile.*?(?:doesn't|does not|cannot).*?(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(provisioning_pattern, self.stderr, re.IGNORECASE):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"Provisioning profile error: {match.group('message').strip()}",
|
||||
"type": "provisioning",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 3: Code signing errors
|
||||
signing_pattern = r"error:.*?(?:code sign|signing).*?(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(signing_pattern, self.stderr, re.IGNORECASE):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"Code signing error: {match.group('message').strip()}",
|
||||
"type": "signing",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 4: Generic compilation errors (but not if already captured)
|
||||
if not errors:
|
||||
generic_error_pattern = r"^(?:\*\*\s)?(?:error|❌):\s*(?P<message>.*?)(?:\n|$)"
|
||||
for match in re.finditer(generic_error_pattern, self.stderr, re.MULTILINE):
|
||||
message = match.group("message").strip()
|
||||
errors.append(
|
||||
{
|
||||
"message": message,
|
||||
"type": "build",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
# Pattern 5: Specific "No profiles" error
|
||||
if "No profiles for" in self.stderr:
|
||||
no_profile_pattern = r"No profiles for '(?P<bundle_id>.*?)' were found"
|
||||
for match in re.finditer(no_profile_pattern, self.stderr):
|
||||
errors.append(
|
||||
{
|
||||
"message": f"No provisioning profile found for bundle ID '{match.group('bundle_id')}'",
|
||||
"type": "provisioning",
|
||||
"location": {"file": None, "line": None, "column": None},
|
||||
}
|
||||
)
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: mobile-simulator
|
||||
description: Build, launch, and verify Sauci on iOS or Android simulators.
|
||||
---
|
||||
|
||||
Read `.codex/skills/mobile-simulator/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: promote-learning
|
||||
description: Record or promote a reproduced Sauci workflow lesson into a durable artifact.
|
||||
---
|
||||
|
||||
Read `.codex/skills/promote-learning/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: question-pack
|
||||
description: Create or review Sauci question-pack content and inverse relationships.
|
||||
---
|
||||
|
||||
Read `.codex/skills/question-pack/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: release-mobile
|
||||
description: Prepare and verify Sauci iOS or Android releases and store uploads.
|
||||
---
|
||||
|
||||
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: verify
|
||||
description: Prove a completed Sauci engineering task works before a PR.
|
||||
---
|
||||
|
||||
Read `.codex/skills/verify/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "architecture_reviewer"
|
||||
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Evaluate the delegated decision against current system boundaries, data flow,
|
||||
migration path, operability, reversibility, and verification. Prefer the smallest
|
||||
coherent change. Return explicit trade-offs, risks, and a recommendation with
|
||||
file-backed evidence. Do not edit files or review unrelated local refactors.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "database_reviewer"
|
||||
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md, database migration skill, and migration safety
|
||||
documentation. Review only the delegated diff for migration tracking,
|
||||
idempotency, authorization and tenant isolation, portability, rollback impact,
|
||||
types, and integration coverage. Never mutate a database or edit files. Return
|
||||
prioritized line-specific findings with concrete evidence and counterevidence.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "implementation"
|
||||
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||
model = "gpt-5.6-terra"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||
skill. Implement only the delegated objective and preserve unrelated work. Update
|
||||
maintained contract documentation and add focused tests when the contract changes.
|
||||
Run the required focused checks and return changed files, exact results,
|
||||
assumptions, and blockers. Stop for missing authority or a materially ambiguous
|
||||
product choice. Do not expand scope or perform external, destructive, production,
|
||||
release, or provider writes unless the task explicitly authorizes that action.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "repo_scout"
|
||||
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
|
||||
model = "gpt-5.6-luna"
|
||||
model_reasoning_effort = "low"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
|
||||
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
|
||||
and uncertainties. Do not edit files, make design decisions, or propose broad
|
||||
refactors. Do not claim runtime behavior you did not observe.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "security_reviewer"
|
||||
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Perform a scoped threat-boundary review of the delegated change. Trace callers
|
||||
through authentication and authorization to the data sink. Prioritize exploitable
|
||||
correctness and security risks, include counterevidence, and cite exact files and
|
||||
lines. Do not edit files or expand into a repository-wide audit unless explicitly
|
||||
requested.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "test_writer"
|
||||
description = "Focused Sauci regression and contract-test author for already-defined behavior."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and testing documentation. Test the intended
|
||||
observable contract at the lowest useful layer while preserving real auth,
|
||||
authorization, tenant, and couple boundaries. Do not weaken assertions to
|
||||
accommodate a failure. Run the focused test and return files changed, exact
|
||||
commands, results, and any product defect exposed by the test.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "verifier"
|
||||
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
|
||||
exercise the delegated acceptance criteria against the running non-production
|
||||
product. You may write generated proof only under evidence/. Do not edit source,
|
||||
tests, configuration, or acceptance criteria. Return expected behavior, observed
|
||||
behavior, evidence paths, environment limitations, and a works or broken verdict.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
[agents]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 3
|
||||
default_subagent_model = "gpt-5.6-terra"
|
||||
default_subagent_reasoning_effort = "medium"
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: commit
|
||||
description: Write conventional commit messages with type, scope, and subject when the user wants to commit changes or save work.
|
||||
---
|
||||
|
||||
# Git Commit
|
||||
|
||||
Creates git commits following Conventional Commits format with proper type, scope, and subject.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Stage changes
|
||||
git add <files> # or: git add -A
|
||||
|
||||
# 2. Create commit (branch commit format)
|
||||
git commit -m "type(scope): subject
|
||||
|
||||
Body explaining HOW and WHY.
|
||||
Reference: Task X.Y, Req N"
|
||||
```
|
||||
|
||||
## Commit Types
|
||||
|
||||
### Regular Branch Commits (During Development)
|
||||
|
||||
**Format**: `type(scope): subject`
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `feat` | New feature or functionality |
|
||||
| `fix` | Bug fix or issue resolution |
|
||||
| `refactor` | Code refactoring without behavior change |
|
||||
| `perf` | Performance improvements |
|
||||
| `test` | Test additions or modifications |
|
||||
| `ci` | CI/CD configuration changes |
|
||||
| `docs` | Documentation updates |
|
||||
| `chore` | Maintenance, dependencies, tooling |
|
||||
| `style` | Code formatting, linting (non-functional) |
|
||||
| `security` | Security vulnerability fixes or hardening |
|
||||
|
||||
### Scope (Required, kebab-case)
|
||||
|
||||
Examples: `validation`, `auth`, `cookie-service`, `template`, `config`, `tests`, `api`
|
||||
|
||||
### Subject Line Rules
|
||||
|
||||
- Max 50 characters after colon
|
||||
- Present tense imperative: add, implement, fix, improve, enhance, refactor, remove, prevent
|
||||
- NO period at the end
|
||||
- Specific and descriptive - state WHAT, not WHY
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1. Review Changes
|
||||
|
||||
```bash
|
||||
git status
|
||||
git diff --staged # if already staged
|
||||
git diff # if not staged
|
||||
```
|
||||
|
||||
### 2. Stage Files
|
||||
|
||||
```bash
|
||||
git add <specific-files> # preferred
|
||||
# or
|
||||
git add -A # all changes
|
||||
```
|
||||
|
||||
**NEVER commit**:
|
||||
- `.env`, `credentials.json`, secrets
|
||||
- `node_modules/`, `__pycache__/`, `.venv/`
|
||||
- Large binary files without explicit approval
|
||||
|
||||
### 3. Create Commit
|
||||
|
||||
**Simple change**:
|
||||
```bash
|
||||
git commit -m "fix(auth): use hmac.compare_digest for secure comparison"
|
||||
```
|
||||
|
||||
**Complex change (with body)**:
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(validation): add URLValidator with domain whitelist
|
||||
|
||||
Implement URLValidator class supporting:
|
||||
- Domain whitelist enforcement (youtube.com, youtu.be)
|
||||
- Dangerous scheme blocking (javascript, data, file)
|
||||
- URL parsing with embedded credentials handling
|
||||
|
||||
Addresses Requirement 31: Input validation
|
||||
Part of Task 5.1: Input Validation Utilities
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 4. Verify Commit
|
||||
|
||||
```bash
|
||||
git log -1 --format="%h %s"
|
||||
git show --stat HEAD
|
||||
```
|
||||
|
||||
## Body Format (Recommended for Complex Changes)
|
||||
|
||||
```
|
||||
<blank line>
|
||||
Explain HOW and WHY the change was made.
|
||||
- Use bullet points for multiple items
|
||||
- Wrap at 72 characters
|
||||
|
||||
Reference: Task X.Y
|
||||
Addresses: Req N
|
||||
```
|
||||
|
||||
## Git Trailers
|
||||
|
||||
| Trailer | Purpose |
|
||||
|---------|---------|
|
||||
| `Fixes #N` | Links and closes issue on merge |
|
||||
| `Closes #N` | Same as Fixes |
|
||||
| `Co-authored-by: Name <email>` | Credit co-contributors |
|
||||
|
||||
Place trailers at end of body after blank line. See `references/commit_examples.md` for examples.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
For incompatible API/behavior changes, use `!` after scope OR `BREAKING CHANGE:` footer:
|
||||
|
||||
```
|
||||
feat(api)!: change response format to JSON:API
|
||||
|
||||
BREAKING CHANGE: Response envelope changed from `{ data }` to `{ data: { type, id, attributes } }`.
|
||||
```
|
||||
|
||||
Triggers major version bump in semantic-release.
|
||||
|
||||
## Merge Commits (PR Closure)
|
||||
|
||||
For PRs, use extended description with sections:
|
||||
|
||||
```bash
|
||||
gh pr create --title "feat(security): implement input validation (Task 5)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Input validation utilities (URLValidator, FormatValidator)
|
||||
- Secure template processor with path traversal prevention
|
||||
- API key authentication middleware
|
||||
|
||||
## Task Breakdown
|
||||
Task 5.1: Input Validation - URLValidator, FormatValidator
|
||||
Task 5.2: Template Processing - Path traversal prevention
|
||||
Task 5.3: API Key Auth - Multi-key support, excluded paths
|
||||
Task 5.4: Security Tests - 102 path traversal tests
|
||||
|
||||
## Requirements Covered
|
||||
Req 7, Req 9, Req 31, Req 33
|
||||
|
||||
## Test Coverage
|
||||
- All 473 tests passing
|
||||
- Coverage: 93%
|
||||
- Pre-commit checks: passing
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
### From github-pr-review
|
||||
|
||||
When fixing review comments, use this format:
|
||||
|
||||
```bash
|
||||
git commit -m "fix(scope): address review comment #ID
|
||||
|
||||
Brief explanation of what was wrong and how it's fixed.
|
||||
Addresses review comment #123456789."
|
||||
```
|
||||
|
||||
### From github-pr-creation
|
||||
|
||||
Before creating PR, ensure all commits follow this format. The PR skill will:
|
||||
1. Analyze commits for proper format
|
||||
2. Extract types for PR labels
|
||||
3. Build PR description from commit bodies
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **ALWAYS** include scope in parentheses
|
||||
- **ALWAYS** use present tense imperative verb
|
||||
- **NEVER** end subject with period
|
||||
- **NEVER** commit secrets or credentials
|
||||
- **NEVER** use generic messages ("update code", "fix bug", "changes")
|
||||
- **NEVER** exceed 50 chars in subject line
|
||||
- Group related changes -> single focused commit
|
||||
|
||||
## Examples
|
||||
|
||||
**Good**:
|
||||
```
|
||||
feat(validation): add URLValidator with domain whitelist
|
||||
fix(auth): use hmac.compare_digest for secure key comparison
|
||||
refactor(template): consolidate filename sanitization logic
|
||||
test(security): add 102 path traversal prevention tests
|
||||
```
|
||||
|
||||
**Bad**:
|
||||
```
|
||||
update validation code # no type, no scope, vague
|
||||
feat: add stuff # missing scope, too vague
|
||||
fix(auth): fix bug # circular, not specific
|
||||
chore: make changes # missing scope, vague
|
||||
feat(security): improve things. # has period, vague
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `references/commit_examples.md` - Extended examples by type
|
||||
@@ -0,0 +1,292 @@
|
||||
# Commit Examples by Type
|
||||
|
||||
Extended examples for each commit type with body content.
|
||||
|
||||
## feat - New Features
|
||||
|
||||
```
|
||||
feat(validation): add URLValidator with domain whitelist
|
||||
|
||||
Implement URLValidator class supporting:
|
||||
- Domain whitelist enforcement (youtube.com, youtu.be, m.youtube.com)
|
||||
- Dangerous scheme blocking (javascript, data, file)
|
||||
- URL parsing with embedded credentials handling
|
||||
- Port number validation (1-65535)
|
||||
|
||||
Addresses Requirement 31: Input validation
|
||||
Part of Task 5.1: Input Validation Utilities
|
||||
```
|
||||
|
||||
```
|
||||
feat(api): add video metadata endpoint
|
||||
|
||||
New GET /api/v1/videos/{id}/metadata endpoint:
|
||||
- Returns title, duration, formats, thumbnails
|
||||
- Supports format filtering via query params
|
||||
- Implements caching with 5-minute TTL
|
||||
|
||||
Part of Task 6.2: API Endpoints
|
||||
```
|
||||
|
||||
## fix - Bug Fixes
|
||||
|
||||
```
|
||||
fix(auth): use hmac.compare_digest for secure key comparison
|
||||
|
||||
Replace direct string equality with hmac.compare_digest to prevent
|
||||
timing attacks on API key validation. Ensures constant-time comparison
|
||||
regardless of key length or content.
|
||||
|
||||
Addresses security best practice for sensitive data comparison
|
||||
```
|
||||
|
||||
```
|
||||
fix(download): handle network timeout during video fetch
|
||||
|
||||
Add retry logic with exponential backoff for network failures:
|
||||
- Max 3 attempts with delays [2, 4, 8] seconds
|
||||
- Classify retriable errors (5xx, timeout, connection)
|
||||
- Log each retry attempt with remaining count
|
||||
|
||||
Fixes issue where downloads would fail silently on flaky connections
|
||||
```
|
||||
|
||||
## refactor - Code Improvements
|
||||
|
||||
```
|
||||
refactor(template): consolidate filename sanitization logic
|
||||
|
||||
Extract common sanitization patterns into helper methods:
|
||||
- Path traversal prevention (.., /, absolute paths)
|
||||
- Special character removal (control chars, null bytes)
|
||||
- Windows reserved name handling (CON, PRN, LPT1-9, etc)
|
||||
|
||||
Improves code maintainability and reduces duplication
|
||||
```
|
||||
|
||||
```
|
||||
refactor(providers): extract common yt-dlp options builder
|
||||
|
||||
Move duplicated option building from get_info/download to
|
||||
_build_base_options helper. Reduces code duplication and ensures
|
||||
consistent option handling across all provider methods.
|
||||
|
||||
No behavior change, pure refactoring
|
||||
```
|
||||
|
||||
## test - Test Changes
|
||||
|
||||
```
|
||||
test(security): add 102 path traversal prevention tests
|
||||
|
||||
Comprehensive test coverage for TemplateProcessor including:
|
||||
- Basic path traversal attempts (.., /)
|
||||
- URL-encoded variants (%2e%2e, %2f)
|
||||
- Unicode/UTF-8 bypass attempts
|
||||
- Windows edge cases (backslashes, drive letters)
|
||||
|
||||
Part of Task 5.4: Security Test Suite
|
||||
```
|
||||
|
||||
```
|
||||
test(validation): add parametrized URL validation tests
|
||||
|
||||
Add 25 test cases covering:
|
||||
- Valid YouTube URL formats (watch, shorts, embed, youtu.be)
|
||||
- Invalid domains (vimeo, dailymotion)
|
||||
- Malformed URLs (no scheme, wrong port)
|
||||
- Edge cases (trailing slashes, query params)
|
||||
|
||||
Coverage for URLValidator: 98%
|
||||
```
|
||||
|
||||
## perf - Performance
|
||||
|
||||
```
|
||||
perf(cache): implement LRU eviction for metadata cache
|
||||
|
||||
Replace dict-based cache with LRU implementation:
|
||||
- Max 1000 entries with automatic eviction
|
||||
- 40% memory reduction under high load
|
||||
- Sub-millisecond lookup times maintained
|
||||
|
||||
Addresses memory growth issue in long-running instances
|
||||
```
|
||||
|
||||
## security - Security Fixes
|
||||
|
||||
```
|
||||
security(cookie): validate cookie file integrity before use
|
||||
|
||||
Add SHA256 checksum verification for cookie files:
|
||||
- Compute hash on first load, store in memory
|
||||
- Verify hash before each use
|
||||
- Reject modified files with clear error message
|
||||
|
||||
Prevents use of tampered cookie files
|
||||
Addresses Requirement 33: Security validation
|
||||
```
|
||||
|
||||
## ci - CI/CD Changes
|
||||
|
||||
```
|
||||
ci(github): add security scanning to PR workflow
|
||||
|
||||
Enable Bandit security scanner in GitHub Actions:
|
||||
- Run on all Python files
|
||||
- Fail on HIGH/CRITICAL findings
|
||||
- Cache virtualenv for faster runs
|
||||
|
||||
Part of Task 15.3: Basic security validation
|
||||
```
|
||||
|
||||
## docs - Documentation
|
||||
|
||||
```
|
||||
docs(api): add OpenAPI description for download endpoint
|
||||
|
||||
Document /api/v1/download endpoint:
|
||||
- Request body schema with format options
|
||||
- Response codes (200, 400, 401, 404, 500)
|
||||
- Example requests and responses
|
||||
|
||||
Improves API documentation for consumers
|
||||
```
|
||||
|
||||
## chore - Maintenance
|
||||
|
||||
```
|
||||
chore(deps): update yt-dlp to 2024.12.06
|
||||
|
||||
Update yt-dlp from 2024.11.15 to 2024.12.06:
|
||||
- Fixes YouTube throttling detection
|
||||
- Adds support for new Instagram format
|
||||
- Improves error messages for geo-blocked content
|
||||
|
||||
No breaking changes expected
|
||||
```
|
||||
|
||||
## style - Formatting
|
||||
|
||||
```
|
||||
style(providers): apply black formatting to youtube.py
|
||||
|
||||
Apply black formatter with 88 char line length.
|
||||
No functional changes, formatting only.
|
||||
```
|
||||
|
||||
## Merge Commit Examples
|
||||
|
||||
### Feature Branch to Develop
|
||||
|
||||
```
|
||||
Merge pull request #5 from fvadicamo/feature/input-validation-security
|
||||
|
||||
feat(security): implement input validation and security (Task 5)
|
||||
|
||||
Merges comprehensive security implementation (Task 5) into develop:
|
||||
- Input validation utilities (URLValidator, FormatValidator, ParameterValidator)
|
||||
- Secure template processor with path traversal prevention
|
||||
- API key authentication middleware with multi-key support
|
||||
- 473 tests with 93% coverage
|
||||
|
||||
Task 5.1: Input Validation Utilities
|
||||
- URLValidator: Domain whitelist (youtube.com, youtu.be), dangerous scheme blocking
|
||||
- FormatValidator: yt-dlp format ID validation with regex and selectors
|
||||
- ParameterValidator: Audio quality/format and language code validation
|
||||
|
||||
Task 5.2: Template Processor
|
||||
- Path traversal prevention (.., /, absolute paths, URL encoding)
|
||||
- Filename sanitization (illegal chars, control chars, null bytes)
|
||||
- Windows reserved names handling (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||
- Collision handling with numeric suffix, max length 200 chars
|
||||
|
||||
Task 5.3: API Key Authentication
|
||||
- APIKeyAuth class with multi-key support
|
||||
- Excluded paths for health/doc endpoints
|
||||
- Secure hashing for logging (SHA256 first 8 chars)
|
||||
- FastAPI dependency injection integration
|
||||
|
||||
Task 5.4: Security Tests
|
||||
- 102 path traversal prevention tests with edge cases
|
||||
- URL validation tests with malicious inputs
|
||||
- API key authentication and credential tests
|
||||
- Sensitive data redaction verification
|
||||
|
||||
Requirements Covered:
|
||||
- Req 7: Output template processing with security
|
||||
- Req 9: API key authentication
|
||||
- Req 31: Input validation
|
||||
- Req 33: Security (secure comparison, log redaction)
|
||||
|
||||
Test Coverage:
|
||||
- All 473 tests passing
|
||||
- Coverage: 93% (exceeds 80% minimum)
|
||||
- Pre-commit checks: all passing
|
||||
```
|
||||
|
||||
### Develop to Main (Release)
|
||||
|
||||
```
|
||||
Merge pull request #10 from fvadicamo/develop
|
||||
|
||||
release: v0.1.0 - MVP with YouTube provider
|
||||
|
||||
First stable release with core functionality:
|
||||
- YouTube video info, formats, download, audio extraction
|
||||
- Cookie-based authentication for age-restricted content
|
||||
- API key authentication
|
||||
- Input validation and security hardening
|
||||
- 500+ tests with 92% coverage
|
||||
|
||||
Breaking Changes: None (initial release)
|
||||
|
||||
Features:
|
||||
- GET /api/v1/info - Video metadata
|
||||
- GET /api/v1/formats - Available formats
|
||||
- POST /api/v1/download - Video/audio download
|
||||
- Cookie file support for authenticated requests
|
||||
|
||||
Documentation:
|
||||
- API documentation at /docs (Swagger UI)
|
||||
- OpenAPI spec at /openapi.json
|
||||
```
|
||||
|
||||
## Commits with Trailers
|
||||
|
||||
### Single Issue
|
||||
```
|
||||
fix(validation): prevent XSS in user input
|
||||
|
||||
Escape HTML entities before rendering.
|
||||
|
||||
Fixes #78
|
||||
```
|
||||
|
||||
### Multiple Issues + Co-author
|
||||
```
|
||||
fix(auth): resolve session and token issues
|
||||
|
||||
- Fix session expiry not triggering logout
|
||||
- Fix token refresh race condition
|
||||
|
||||
Fixes #101
|
||||
Fixes #103
|
||||
Co-authored-by: Bob <bob@example.com>
|
||||
```
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### With ! Notation
|
||||
```
|
||||
feat(api)!: migrate to v2 endpoints
|
||||
|
||||
BREAKING CHANGE: /api/v1/* endpoints removed. Update base URL to /api/v2/.
|
||||
```
|
||||
|
||||
### Config Breaking Change
|
||||
```
|
||||
chore(config)!: rename environment variables
|
||||
|
||||
BREAKING CHANGE: DATABASE_URL -> APP_DATABASE_URL, API_KEY -> APP_API_KEY
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: db-migration
|
||||
description: Create, review, and verify Sauci Supabase schema migrations. Use for tables, columns, functions, policies, indexes, triggers, or other database shape changes.
|
||||
---
|
||||
|
||||
# Sauci database migration
|
||||
|
||||
Read `apps/supabase/AGENTS.md` and `docs/operations/database-migrations.md`.
|
||||
|
||||
1. Create the local file with `npm run db:migration:new -- <name>`.
|
||||
2. Edit only `apps/supabase/migrations/`; keep environment-specific URLs dynamic.
|
||||
3. Update affected shared/generated contracts and callers in the same task.
|
||||
4. Reset local Supabase, run function/RLS tests, and independently inspect the
|
||||
resulting schema/behavior.
|
||||
5. Commit the migration file with dependent code. CI performs deployment.
|
||||
|
||||
Never call remote `apply_migration`; never send DDL through MCP SQL execution;
|
||||
never use a remote project for local validation.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: delegate-to-claude
|
||||
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
|
||||
---
|
||||
|
||||
# Delegate to Claude Code
|
||||
|
||||
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
|
||||
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
|
||||
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
|
||||
|
||||
## Work order
|
||||
|
||||
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
|
||||
|
||||
- role: executor or read-only reviewer;
|
||||
- exact objective and boundaries;
|
||||
- working directory and relevant files;
|
||||
- repository instructions Claude must read;
|
||||
- allowed side effects and forbidden actions;
|
||||
- required checks and expected evidence;
|
||||
- requested return format: findings or changed files, commands run, results, and blockers.
|
||||
|
||||
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
|
||||
|
||||
## Run
|
||||
|
||||
From the target repository directory:
|
||||
|
||||
```bash
|
||||
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
|
||||
```
|
||||
|
||||
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
|
||||
|
||||
```bash
|
||||
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
|
||||
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
|
||||
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
|
||||
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
|
||||
|
||||
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
|
||||
|
||||
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
|
||||
|
||||
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
|
||||
|
||||
```bash
|
||||
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
|
||||
```
|
||||
|
||||
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
|
||||
|
||||
## Accept or iterate
|
||||
|
||||
Claude's summary is evidence to inspect, not proof. Codex must:
|
||||
|
||||
1. inspect the actual diff or artifacts;
|
||||
2. run proportionate independent checks;
|
||||
3. compare the result with the original scope and repository rules;
|
||||
4. send a focused follow-up with concrete failures when iteration is needed;
|
||||
5. report Claude's contribution separately from Codex's verification.
|
||||
|
||||
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Delegate to Claude Code"
|
||||
short_description: "Delegate scoped work to Claude Code"
|
||||
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
printf 'claude_delegate: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage:
|
||||
claude_delegate.sh check
|
||||
claude_delegate.sh run <prompt-file> [result-json]
|
||||
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
|
||||
|
||||
if [[ "${1:-}" == "check" ]]; then
|
||||
claude --version
|
||||
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
|
||||
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
|
||||
printf 'authentication: logged in\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
run)
|
||||
[[ $# -ge 2 && $# -le 3 ]] || usage
|
||||
prompt_file="$2"
|
||||
result_file="${3:-}"
|
||||
resume_id=""
|
||||
;;
|
||||
resume)
|
||||
[[ $# -ge 3 && $# -le 4 ]] || usage
|
||||
resume_id="$2"
|
||||
prompt_file="$3"
|
||||
result_file="${4:-}"
|
||||
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
|
||||
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
|
||||
|
||||
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
|
||||
|
||||
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
|
||||
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
|
||||
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
|
||||
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
|
||||
*) die "invalid permission mode: $permission_mode" ;;
|
||||
esac
|
||||
|
||||
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
|
||||
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
|
||||
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
|
||||
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
|
||||
|
||||
if [[ -n "$result_file" ]]; then
|
||||
result_dir="$(dirname "$result_file")"
|
||||
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
|
||||
claude "${args[@]}" "$prompt" | tee "$result_file"
|
||||
else
|
||||
claude "${args[@]}" "$prompt"
|
||||
fi
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: dev-browser
|
||||
description: Browser automation with persistent page state. Use when users ask to navigate websites, fill forms, take screenshots, extract web data, test web apps, or automate browser workflows. Trigger phrases include "go to [url]", "click on", "fill out the form", "take a screenshot", "scrape", "automate", "test the website", "log into", or any browser interaction request.
|
||||
---
|
||||
|
||||
# Dev Browser Skill
|
||||
|
||||
Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. Once you've proven out part of a workflow and there is repeated work to be done, you can write a script to do the repeated work in a single execution.
|
||||
|
||||
## Choosing Your Approach
|
||||
|
||||
- **Local/source-available sites**: Read the source code first to write selectors directly
|
||||
- **Unknown page layouts**: Use `getAISnapshot()` to discover elements and `selectSnapshotRef()` to interact with them
|
||||
- **Visual feedback**: Take screenshots to see what the user sees
|
||||
|
||||
## Setup
|
||||
|
||||
Two modes available. Ask the user if unclear which to use.
|
||||
|
||||
### Standalone Mode (Default)
|
||||
|
||||
Launches a new Chromium browser for fresh automation sessions.
|
||||
|
||||
```bash
|
||||
./skills/dev-browser/server.sh &
|
||||
```
|
||||
|
||||
Add `--headless` flag if user requests it. **Wait for the `Ready` message before running scripts.**
|
||||
|
||||
### Extension Mode
|
||||
|
||||
Connects to user's existing Chrome browser. Use this when:
|
||||
|
||||
- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev.
|
||||
- The user asks you to use the extension
|
||||
|
||||
**Important**: The core flow is still the same. You create named pages inside of their browser.
|
||||
|
||||
**Start the relay server:**
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npm i && npm run start-extension &
|
||||
```
|
||||
|
||||
Wait for `Waiting for extension to connect...` followed by `Extension connected` in the console. To know that a client has connected and the browser is ready to be controlled.
|
||||
**Workflow:**
|
||||
|
||||
1. Scripts call `client.page("name")` just like the normal mode to create new pages / connect to existing ones.
|
||||
2. Automation runs on the user's actual browser session
|
||||
|
||||
If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases
|
||||
|
||||
## Writing Scripts
|
||||
|
||||
> **Run all scripts from `skills/dev-browser/` directory.** The `@/` import alias requires this directory's config.
|
||||
|
||||
Execute scripts inline using heredocs:
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
// Create page with custom viewport size (optional)
|
||||
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
await page.goto("https://example.com");
|
||||
await waitForPageLoad(page);
|
||||
|
||||
console.log({ title: await page.title(), url: page.url() });
|
||||
await client.disconnect();
|
||||
EOF
|
||||
```
|
||||
|
||||
**Write to `tmp/` files only when** the script needs reuse, is complex, or user explicitly requests it.
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check)
|
||||
2. **Evaluate state**: Log/return state at the end to decide next steps
|
||||
3. **Descriptive page names**: Use `"checkout"`, `"login"`, not `"main"`
|
||||
4. **Disconnect to exit**: `await client.disconnect()` - pages persist on server
|
||||
5. **Plain JS in evaluate**: `page.evaluate()` runs in browser - no TypeScript syntax
|
||||
|
||||
## Workflow Loop
|
||||
|
||||
Follow this pattern for complex tasks:
|
||||
|
||||
1. **Write a script** to perform one action
|
||||
2. **Run it** and observe the output
|
||||
3. **Evaluate** - did it work? What's the current state?
|
||||
4. **Decide** - is the task complete or do we need another script?
|
||||
5. **Repeat** until task is done
|
||||
|
||||
### No TypeScript in Browser Context
|
||||
|
||||
Code passed to `page.evaluate()` runs in the browser, which doesn't understand TypeScript:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: plain JavaScript
|
||||
const text = await page.evaluate(() => {
|
||||
return document.body.innerText;
|
||||
});
|
||||
|
||||
// ❌ Wrong: TypeScript syntax will fail at runtime
|
||||
const text = await page.evaluate(() => {
|
||||
const el: HTMLElement = document.body; // Type annotation breaks in browser!
|
||||
return el.innerText;
|
||||
});
|
||||
```
|
||||
|
||||
## Scraping Data
|
||||
|
||||
For scraping large datasets, intercept and replay network requests rather than scrolling the DOM. See [references/scraping.md](references/scraping.md) for the complete guide covering request capture, schema discovery, and paginated API replay.
|
||||
|
||||
## Client API
|
||||
|
||||
```typescript
|
||||
const client = await connect();
|
||||
|
||||
// Get or create named page (viewport only applies to new pages)
|
||||
const page = await client.page("name");
|
||||
const pageWithSize = await client.page("name", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pages = await client.list(); // List all page names
|
||||
await client.close("name"); // Close a page
|
||||
await client.disconnect(); // Disconnect (pages persist)
|
||||
|
||||
// ARIA Snapshot methods
|
||||
const snapshot = await client.getAISnapshot("name"); // Get accessibility tree
|
||||
const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref
|
||||
```
|
||||
|
||||
The `page` object is a standard Playwright Page.
|
||||
|
||||
## Waiting
|
||||
|
||||
```typescript
|
||||
import { waitForPageLoad } from "@/client.js";
|
||||
|
||||
await waitForPageLoad(page); // After navigation
|
||||
await page.waitForSelector(".results"); // For specific elements
|
||||
await page.waitForURL("**/success"); // For specific URL
|
||||
```
|
||||
|
||||
## Inspecting Page State
|
||||
|
||||
### Screenshots
|
||||
|
||||
```typescript
|
||||
await page.screenshot({ path: "tmp/screenshot.png" });
|
||||
await page.screenshot({ path: "tmp/full.png", fullPage: true });
|
||||
```
|
||||
|
||||
### ARIA Snapshot (Element Discovery)
|
||||
|
||||
Use `getAISnapshot()` to discover page elements. Returns YAML-formatted accessibility tree:
|
||||
|
||||
```yaml
|
||||
- banner:
|
||||
- link "Hacker News" [ref=e1]
|
||||
- navigation:
|
||||
- link "new" [ref=e2]
|
||||
- main:
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Article Title" [ref=e8]
|
||||
- link "328 comments" [ref=e9]
|
||||
- contentinfo:
|
||||
- textbox [ref=e10]
|
||||
- /placeholder: "Search"
|
||||
```
|
||||
|
||||
**Interpreting refs:**
|
||||
|
||||
- `[ref=eN]` - Element reference for interaction (visible, clickable elements only)
|
||||
- `[checked]`, `[disabled]`, `[expanded]` - Element states
|
||||
- `[level=N]` - Heading level
|
||||
- `/url:`, `/placeholder:` - Element properties
|
||||
|
||||
**Interacting with refs:**
|
||||
|
||||
```typescript
|
||||
const snapshot = await client.getAISnapshot("hackernews");
|
||||
console.log(snapshot); // Find the ref you need
|
||||
|
||||
const element = await client.selectSnapshotRef("hackernews", "e2");
|
||||
await element.click();
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
Page state persists after failures. Debug with:
|
||||
|
||||
```bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("hackernews");
|
||||
|
||||
await page.screenshot({ path: "tmp/debug.png" });
|
||||
console.log({
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)),
|
||||
});
|
||||
|
||||
await client.disconnect();
|
||||
EOF
|
||||
```
|
||||
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dev-browser",
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"playwright": "^1.49.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^2.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
|
||||
|
||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
|
||||
|
||||
"serve-static/send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"serve-static/send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
}
|
||||
}
|
||||
+2988
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "dev-browser",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"@/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"start-server": "npx tsx scripts/start-server.ts",
|
||||
"start-extension": "npx tsx scripts/start-relay.ts",
|
||||
"dev": "npx tsx --watch src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"express": "^4.21.0",
|
||||
"hono": "^4.11.1",
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# Data Scraping Guide
|
||||
|
||||
For large datasets (followers, posts, search results), **intercept and replay network requests** rather than scrolling and parsing the DOM. This is faster, more reliable, and handles pagination automatically.
|
||||
|
||||
## Why Not Scroll?
|
||||
|
||||
Scrolling is slow, unreliable, and wastes time. APIs return structured data with pagination built in. Always prefer API replay.
|
||||
|
||||
## Start Small, Then Scale
|
||||
|
||||
**Don't try to automate everything at once.** Work incrementally:
|
||||
|
||||
1. **Capture one request** - verify you're intercepting the right endpoint
|
||||
2. **Inspect one response** - understand the schema before writing extraction code
|
||||
3. **Extract a few items** - make sure your parsing logic works
|
||||
4. **Then scale up** - add pagination loop only after the basics work
|
||||
|
||||
This prevents wasting time debugging a complex script when the issue is a simple path like `data.user.timeline` vs `data.user.result.timeline`.
|
||||
|
||||
## Step-by-Step Workflow
|
||||
|
||||
### 1. Capture Request Details
|
||||
|
||||
First, intercept a request to understand URL structure and required headers:
|
||||
|
||||
```typescript
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("site");
|
||||
|
||||
let capturedRequest = null;
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
// Look for API endpoints (adjust pattern for your target site)
|
||||
if (url.includes("/api/") || url.includes("/graphql/")) {
|
||||
capturedRequest = {
|
||||
url: url,
|
||||
headers: request.headers(),
|
||||
method: request.method(),
|
||||
};
|
||||
fs.writeFileSync("tmp/request-details.json", JSON.stringify(capturedRequest, null, 2));
|
||||
console.log("Captured request:", url.substring(0, 80) + "...");
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("https://example.com/profile");
|
||||
await waitForPageLoad(page);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await client.disconnect();
|
||||
```
|
||||
|
||||
### 2. Capture Response to Understand Schema
|
||||
|
||||
Save a raw response to inspect the data structure:
|
||||
|
||||
```typescript
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("UserTweets") || url.includes("/api/data")) {
|
||||
const json = await response.json();
|
||||
fs.writeFileSync("tmp/api-response.json", JSON.stringify(json, null, 2));
|
||||
console.log("Captured response");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Then analyze the structure to find:
|
||||
|
||||
- Where the data array lives (e.g., `data.user.result.timeline.instructions[].entries`)
|
||||
- Where pagination cursors are (e.g., `cursor-bottom` entries)
|
||||
- What fields you need to extract
|
||||
|
||||
### 3. Replay API with Pagination
|
||||
|
||||
Once you understand the schema, replay requests directly:
|
||||
|
||||
```typescript
|
||||
import { connect } from "@/client.js";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("site");
|
||||
|
||||
const results = new Map(); // Use Map for deduplication
|
||||
const headers = JSON.parse(fs.readFileSync("tmp/request-details.json", "utf8")).headers;
|
||||
const baseUrl = "https://example.com/api/data";
|
||||
|
||||
let cursor = null;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// Build URL with pagination cursor
|
||||
const params = { count: 20 };
|
||||
if (cursor) params.cursor = cursor;
|
||||
const url = `${baseUrl}?params=${encodeURIComponent(JSON.stringify(params))}`;
|
||||
|
||||
// Execute fetch in browser context (has auth cookies/headers)
|
||||
const response = await page.evaluate(
|
||||
async ({ url, headers }) => {
|
||||
const res = await fetch(url, { headers });
|
||||
return res.json();
|
||||
},
|
||||
{ url, headers }
|
||||
);
|
||||
|
||||
// Extract data and cursor (adjust paths for your API)
|
||||
const entries = response?.data?.entries || [];
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "cursor-bottom") {
|
||||
cursor = entry.value;
|
||||
} else if (entry.id && !results.has(entry.id)) {
|
||||
results.set(entry.id, {
|
||||
id: entry.id,
|
||||
text: entry.content,
|
||||
timestamp: entry.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fetched page, total: ${results.size}`);
|
||||
|
||||
// Check stop conditions
|
||||
if (!cursor || entries.length === 0) hasMore = false;
|
||||
|
||||
// Rate limiting - be respectful
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Export results
|
||||
const data = Array.from(results.values());
|
||||
fs.writeFileSync("tmp/results.json", JSON.stringify(data, null, 2));
|
||||
console.log(`Saved ${data.length} items`);
|
||||
|
||||
await client.disconnect();
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
| Pattern | Description |
|
||||
| ----------------------- | ------------------------------------------------------ |
|
||||
| `page.on('request')` | Capture outgoing request URL + headers |
|
||||
| `page.on('response')` | Capture response data to understand schema |
|
||||
| `page.evaluate(fetch)` | Replay requests in browser context (inherits auth) |
|
||||
| `Map` for deduplication | APIs often return overlapping data across pages |
|
||||
| Cursor-based pagination | Look for `cursor`, `next_token`, `offset` in responses |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Extension mode**: `page.context().cookies()` doesn't work - capture auth headers from intercepted requests instead
|
||||
- **Rate limiting**: Add 500ms+ delays between requests to avoid blocks
|
||||
- **Stop conditions**: Check for empty results, missing cursor, or reaching a date/ID threshold
|
||||
- **GraphQL APIs**: URL params often include `variables` and `features` JSON objects - capture and reuse them
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Start the CDP relay server for Chrome extension mode
|
||||
*
|
||||
* Usage: npm run start-extension
|
||||
*/
|
||||
|
||||
import { serveRelay } from "@/relay.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "9222", 10);
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
|
||||
async function main() {
|
||||
const server = await serveRelay({
|
||||
port: PORT,
|
||||
host: HOST,
|
||||
});
|
||||
|
||||
// Handle shutdown
|
||||
const shutdown = async () => {
|
||||
console.log("\nShutting down relay server...");
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start relay server:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { serve } from "@/index.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdirSync, existsSync, readdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tmpDir = join(__dirname, "..", "tmp");
|
||||
const profileDir = join(__dirname, "..", "profiles");
|
||||
|
||||
// Create tmp and profile directories if they don't exist
|
||||
console.log("Creating tmp directory...");
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
console.log("Creating profiles directory...");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
|
||||
// Install Playwright browsers if not already installed
|
||||
console.log("Checking Playwright browser installation...");
|
||||
|
||||
function findPackageManager(): { name: string; command: string } | null {
|
||||
const managers = [
|
||||
{ name: "bun", command: "bunx playwright install chromium" },
|
||||
{ name: "pnpm", command: "pnpm exec playwright install chromium" },
|
||||
{ name: "npm", command: "npx playwright install chromium" },
|
||||
];
|
||||
|
||||
for (const manager of managers) {
|
||||
try {
|
||||
execSync(`which ${manager.name}`, { stdio: "ignore" });
|
||||
return manager;
|
||||
} catch {
|
||||
// Package manager not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isChromiumInstalled(): boolean {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const playwrightCacheDir = join(homeDir, ".cache", "ms-playwright");
|
||||
|
||||
if (!existsSync(playwrightCacheDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for chromium directories (e.g., chromium-1148, chromium_headless_shell-1148)
|
||||
try {
|
||||
const entries = readdirSync(playwrightCacheDir);
|
||||
return entries.some((entry) => entry.startsWith("chromium"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isChromiumInstalled()) {
|
||||
console.log("Playwright Chromium not found. Installing (this may take a minute)...");
|
||||
|
||||
const pm = findPackageManager();
|
||||
if (!pm) {
|
||||
throw new Error("No package manager found (tried bun, pnpm, npm)");
|
||||
}
|
||||
|
||||
console.log(`Using ${pm.name} to install Playwright...`);
|
||||
execSync(pm.command, { stdio: "inherit" });
|
||||
console.log("Chromium installed successfully.");
|
||||
} else {
|
||||
console.log("Playwright Chromium already installed.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install Playwright browsers:", error);
|
||||
console.log("You may need to run: npx playwright install chromium");
|
||||
}
|
||||
|
||||
// Check if server is already running
|
||||
console.log("Checking for existing servers...");
|
||||
try {
|
||||
const res = await fetch("http://localhost:9222", {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log("Server already running on port 9222");
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
// Server not running, continue to start
|
||||
}
|
||||
|
||||
// Clean up stale CDP port if HTTP server isn't running (crash recovery)
|
||||
// This handles the case where Node crashed but Chrome is still running on 9223
|
||||
try {
|
||||
const pid = execSync("lsof -ti:9223", { encoding: "utf-8" }).trim();
|
||||
if (pid) {
|
||||
console.log(`Cleaning up stale Chrome process on CDP port 9223 (PID: ${pid})`);
|
||||
execSync(`kill -9 ${pid}`);
|
||||
}
|
||||
} catch {
|
||||
// No process on CDP port, which is expected
|
||||
}
|
||||
|
||||
console.log("Starting dev browser server...");
|
||||
const headless = process.env.HEADLESS === "true";
|
||||
const server = await serve({
|
||||
port: 9222,
|
||||
headless,
|
||||
profileDir,
|
||||
});
|
||||
|
||||
console.log(`Dev browser server started`);
|
||||
console.log(` WebSocket: ${server.wsEndpoint}`);
|
||||
console.log(` Tmp directory: ${tmpDir}`);
|
||||
console.log(` Profile directory: ${profileDir}`);
|
||||
console.log(`\nReady`);
|
||||
console.log(`\nPress Ctrl+C to stop`);
|
||||
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Change to the script directory
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Parse command line arguments
|
||||
HEADLESS=false
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--headless) HEADLESS=true ;;
|
||||
*) echo "Unknown parameter: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
|
||||
echo "Starting dev-browser server..."
|
||||
export HEADLESS=$HEADLESS
|
||||
npx tsx scripts/start-server.ts
|
||||
@@ -0,0 +1,474 @@
|
||||
import { chromium, type Browser, type Page, type ElementHandle } from "playwright";
|
||||
import type {
|
||||
GetPageRequest,
|
||||
GetPageResponse,
|
||||
ListPagesResponse,
|
||||
ServerInfoResponse,
|
||||
ViewportSize,
|
||||
} from "./types";
|
||||
import { getSnapshotScript } from "./snapshot/browser-script";
|
||||
|
||||
/**
|
||||
* Options for waiting for page load
|
||||
*/
|
||||
export interface WaitForPageLoadOptions {
|
||||
/** Maximum time to wait in ms (default: 10000) */
|
||||
timeout?: number;
|
||||
/** How often to check page state in ms (default: 50) */
|
||||
pollInterval?: number;
|
||||
/** Minimum time to wait even if page appears ready in ms (default: 100) */
|
||||
minimumWait?: number;
|
||||
/** Wait for network to be idle (no pending requests) (default: true) */
|
||||
waitForNetworkIdle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for page load
|
||||
*/
|
||||
export interface WaitForPageLoadResult {
|
||||
/** Whether the page is considered loaded */
|
||||
success: boolean;
|
||||
/** Document ready state when finished */
|
||||
readyState: string;
|
||||
/** Number of pending network requests when finished */
|
||||
pendingRequests: number;
|
||||
/** Time spent waiting in ms */
|
||||
waitTimeMs: number;
|
||||
/** Whether timeout was reached */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface PageLoadState {
|
||||
documentReadyState: string;
|
||||
documentLoading: boolean;
|
||||
pendingRequests: PendingRequest[];
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
url: string;
|
||||
loadingDurationMs: number;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a page to finish loading using document.readyState and performance API.
|
||||
*
|
||||
* Uses browser-use's approach of:
|
||||
* - Checking document.readyState for 'complete'
|
||||
* - Monitoring pending network requests via Performance API
|
||||
* - Filtering out ads, tracking, and non-critical resources
|
||||
* - Graceful timeout handling (continues even if timeout reached)
|
||||
*/
|
||||
export async function waitForPageLoad(
|
||||
page: Page,
|
||||
options: WaitForPageLoadOptions = {}
|
||||
): Promise<WaitForPageLoadResult> {
|
||||
const {
|
||||
timeout = 10000,
|
||||
pollInterval = 50,
|
||||
minimumWait = 100,
|
||||
waitForNetworkIdle = true,
|
||||
} = options;
|
||||
|
||||
const startTime = Date.now();
|
||||
let lastState: PageLoadState | null = null;
|
||||
|
||||
// Wait minimum time first
|
||||
if (minimumWait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, minimumWait));
|
||||
}
|
||||
|
||||
// Poll until ready or timeout
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
lastState = await getPageLoadState(page);
|
||||
|
||||
// Check if document is complete
|
||||
const documentReady = lastState.documentReadyState === "complete";
|
||||
|
||||
// Check if network is idle (no pending critical requests)
|
||||
const networkIdle = !waitForNetworkIdle || lastState.pendingRequests.length === 0;
|
||||
|
||||
if (documentReady && networkIdle) {
|
||||
return {
|
||||
success: true,
|
||||
readyState: lastState.documentReadyState,
|
||||
pendingRequests: lastState.pendingRequests.length,
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Page may be navigating, continue polling
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Timeout reached - return current state
|
||||
return {
|
||||
success: false,
|
||||
readyState: lastState?.documentReadyState ?? "unknown",
|
||||
pendingRequests: lastState?.pendingRequests.length ?? 0,
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page load state including document ready state and pending requests.
|
||||
* Filters out ads, tracking, and non-critical resources that shouldn't block loading.
|
||||
*/
|
||||
async function getPageLoadState(page: Page): Promise<PageLoadState> {
|
||||
const result = await page.evaluate(() => {
|
||||
// Access browser globals via globalThis for TypeScript compatibility
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const g = globalThis as { document?: any; performance?: any };
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const perf = g.performance!;
|
||||
const doc = g.document!;
|
||||
|
||||
const now = perf.now();
|
||||
const resources = perf.getEntriesByType("resource");
|
||||
const pending: Array<{ url: string; loadingDurationMs: number; resourceType: string }> = [];
|
||||
|
||||
// Common ad/tracking domains and patterns to filter out
|
||||
const adPatterns = [
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googletagmanager.com",
|
||||
"google-analytics.com",
|
||||
"facebook.net",
|
||||
"connect.facebook.net",
|
||||
"analytics",
|
||||
"ads",
|
||||
"tracking",
|
||||
"pixel",
|
||||
"hotjar.com",
|
||||
"clarity.ms",
|
||||
"mixpanel.com",
|
||||
"segment.com",
|
||||
"newrelic.com",
|
||||
"nr-data.net",
|
||||
"/tracker/",
|
||||
"/collector/",
|
||||
"/beacon/",
|
||||
"/telemetry/",
|
||||
"/log/",
|
||||
"/events/",
|
||||
"/track.",
|
||||
"/metrics/",
|
||||
];
|
||||
|
||||
// Non-critical resource types
|
||||
const nonCriticalTypes = ["img", "image", "icon", "font"];
|
||||
|
||||
for (const entry of resources) {
|
||||
// Resources with responseEnd === 0 are still loading
|
||||
if (entry.responseEnd === 0) {
|
||||
const url = entry.name;
|
||||
|
||||
// Filter out ads and tracking
|
||||
const isAd = adPatterns.some((pattern) => url.includes(pattern));
|
||||
if (isAd) continue;
|
||||
|
||||
// Filter out data: URLs and very long URLs
|
||||
if (url.startsWith("data:") || url.length > 500) continue;
|
||||
|
||||
const loadingDuration = now - entry.startTime;
|
||||
|
||||
// Skip requests loading > 10 seconds (likely stuck/polling)
|
||||
if (loadingDuration > 10000) continue;
|
||||
|
||||
const resourceType = entry.initiatorType || "unknown";
|
||||
|
||||
// Filter out non-critical resources loading > 3 seconds
|
||||
if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue;
|
||||
|
||||
// Filter out image URLs even if type is unknown
|
||||
const isImageUrl = /\.(jpg|jpeg|png|gif|webp|svg|ico)(\?|$)/i.test(url);
|
||||
if (isImageUrl && loadingDuration > 3000) continue;
|
||||
|
||||
pending.push({
|
||||
url,
|
||||
loadingDurationMs: Math.round(loadingDuration),
|
||||
resourceType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documentReadyState: doc.readyState,
|
||||
documentLoading: doc.readyState !== "complete",
|
||||
pendingRequests: pending,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Server mode information */
|
||||
export interface ServerInfo {
|
||||
wsEndpoint: string;
|
||||
mode: "launch" | "extension";
|
||||
extensionConnected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating or getting a page
|
||||
*/
|
||||
export interface PageOptions {
|
||||
/** Viewport size for new pages */
|
||||
viewport?: ViewportSize;
|
||||
}
|
||||
|
||||
export interface DevBrowserClient {
|
||||
page: (name: string, options?: PageOptions) => Promise<Page>;
|
||||
list: () => Promise<string[]>;
|
||||
close: (name: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
/**
|
||||
* Get AI-friendly ARIA snapshot for a page.
|
||||
* Returns YAML format with refs like [ref=e1], [ref=e2].
|
||||
* Refs are stored on window.__devBrowserRefs for cross-connection persistence.
|
||||
*/
|
||||
getAISnapshot: (name: string) => Promise<string>;
|
||||
/**
|
||||
* Get an element handle by its ref from the last getAISnapshot call.
|
||||
* Refs persist across Playwright connections.
|
||||
*/
|
||||
selectSnapshotRef: (name: string, ref: string) => Promise<ElementHandle | null>;
|
||||
/**
|
||||
* Get server information including mode and extension connection status.
|
||||
*/
|
||||
getServerInfo: () => Promise<ServerInfo>;
|
||||
}
|
||||
|
||||
export async function connect(serverUrl = "http://localhost:9222"): Promise<DevBrowserClient> {
|
||||
let browser: Browser | null = null;
|
||||
let wsEndpoint: string | null = null;
|
||||
let connectingPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function ensureConnected(): Promise<Browser> {
|
||||
// Return existing connection if still active
|
||||
if (browser && browser.isConnected()) {
|
||||
return browser;
|
||||
}
|
||||
|
||||
// If already connecting, wait for that connection (prevents race condition)
|
||||
if (connectingPromise) {
|
||||
return connectingPromise;
|
||||
}
|
||||
|
||||
// Start new connection with mutex
|
||||
connectingPromise = (async () => {
|
||||
try {
|
||||
// Fetch wsEndpoint from server
|
||||
const res = await fetch(serverUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const info = (await res.json()) as ServerInfoResponse;
|
||||
wsEndpoint = info.wsEndpoint;
|
||||
|
||||
// Connect to the browser via CDP
|
||||
browser = await chromium.connectOverCDP(wsEndpoint);
|
||||
return browser;
|
||||
} finally {
|
||||
connectingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return connectingPromise;
|
||||
}
|
||||
|
||||
// Find page by CDP targetId - more reliable than JS globals
|
||||
async function findPageByTargetId(b: Browser, targetId: string): Promise<Page | null> {
|
||||
for (const context of b.contexts()) {
|
||||
for (const page of context.pages()) {
|
||||
let cdpSession;
|
||||
try {
|
||||
cdpSession = await context.newCDPSession(page);
|
||||
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
||||
if (targetInfo.targetId === targetId) {
|
||||
return page;
|
||||
}
|
||||
} catch (err) {
|
||||
// Only ignore "target closed" errors, log unexpected ones
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes("Target closed") && !msg.includes("Session closed")) {
|
||||
console.warn(`Unexpected error checking page target: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
if (cdpSession) {
|
||||
try {
|
||||
await cdpSession.detach();
|
||||
} catch {
|
||||
// Ignore detach errors - session may already be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get a page by name (used by multiple methods)
|
||||
async function getPage(name: string, options?: PageOptions): Promise<Page> {
|
||||
// Request the page from server (creates if doesn't exist)
|
||||
const res = await fetch(`${serverUrl}/pages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, viewport: options?.viewport } satisfies GetPageRequest),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get page: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const pageInfo = (await res.json()) as GetPageResponse & { url?: string };
|
||||
const { targetId } = pageInfo;
|
||||
|
||||
// Connect to browser
|
||||
const b = await ensureConnected();
|
||||
|
||||
// Check if we're in extension mode
|
||||
const infoRes = await fetch(serverUrl);
|
||||
const info = (await infoRes.json()) as { mode?: string };
|
||||
const isExtensionMode = info.mode === "extension";
|
||||
|
||||
if (isExtensionMode) {
|
||||
// In extension mode, DON'T use findPageByTargetId as it corrupts page state
|
||||
// Instead, find page by URL or use the only available page
|
||||
const allPages = b.contexts().flatMap((ctx) => ctx.pages());
|
||||
|
||||
if (allPages.length === 0) {
|
||||
throw new Error(`No pages available in browser`);
|
||||
}
|
||||
|
||||
if (allPages.length === 1) {
|
||||
return allPages[0]!;
|
||||
}
|
||||
|
||||
// Multiple pages - try to match by URL if available
|
||||
if (pageInfo.url) {
|
||||
const matchingPage = allPages.find((p) => p.url() === pageInfo.url);
|
||||
if (matchingPage) {
|
||||
return matchingPage;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first page
|
||||
if (!allPages[0]) {
|
||||
throw new Error(`No pages available in browser`);
|
||||
}
|
||||
return allPages[0];
|
||||
}
|
||||
|
||||
// In launch mode, use the original targetId-based lookup
|
||||
const page = await findPageByTargetId(b, targetId);
|
||||
if (!page) {
|
||||
throw new Error(`Page "${name}" not found in browser contexts`);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
return {
|
||||
page: getPage,
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
const res = await fetch(`${serverUrl}/pages`);
|
||||
const data = (await res.json()) as ListPagesResponse;
|
||||
return data.pages;
|
||||
},
|
||||
|
||||
async close(name: string): Promise<void> {
|
||||
const res = await fetch(`${serverUrl}/pages/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to close page: ${await res.text()}`);
|
||||
}
|
||||
},
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Just disconnect the CDP connection - pages persist on server
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
},
|
||||
|
||||
async getAISnapshot(name: string): Promise<string> {
|
||||
// Get the page
|
||||
const page = await getPage(name);
|
||||
|
||||
// Inject the snapshot script and call getAISnapshot
|
||||
const snapshotScript = getSnapshotScript();
|
||||
const snapshot = await page.evaluate((script: string) => {
|
||||
// Inject script if not already present
|
||||
// Note: page.evaluate runs in browser context where window exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
if (!w.__devBrowser_getAISnapshot) {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(script);
|
||||
}
|
||||
return w.__devBrowser_getAISnapshot();
|
||||
}, snapshotScript);
|
||||
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async selectSnapshotRef(name: string, ref: string): Promise<ElementHandle | null> {
|
||||
// Get the page
|
||||
const page = await getPage(name);
|
||||
|
||||
// Find the element using the stored refs
|
||||
const elementHandle = await page.evaluateHandle((refId: string) => {
|
||||
// Note: page.evaluateHandle runs in browser context where globalThis is the window
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
const refs = w.__devBrowserRefs;
|
||||
if (!refs) {
|
||||
throw new Error("No snapshot refs found. Call getAISnapshot first.");
|
||||
}
|
||||
const element = refs[refId];
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
`Ref "${refId}" not found. Available refs: ${Object.keys(refs).join(", ")}`
|
||||
);
|
||||
}
|
||||
return element;
|
||||
}, ref);
|
||||
|
||||
// Check if we got an element
|
||||
const element = elementHandle.asElement();
|
||||
if (!element) {
|
||||
await elementHandle.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return element;
|
||||
},
|
||||
|
||||
async getServerInfo(): Promise<ServerInfo> {
|
||||
const res = await fetch(serverUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const info = (await res.json()) as {
|
||||
wsEndpoint: string;
|
||||
mode?: string;
|
||||
extensionConnected?: boolean;
|
||||
};
|
||||
return {
|
||||
wsEndpoint: info.wsEndpoint,
|
||||
mode: (info.mode as "launch" | "extension") ?? "launch",
|
||||
extensionConnected: info.extensionConnected,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import express, { type Express, type Request, type Response } from "express";
|
||||
import { chromium, type BrowserContext, type Page } from "playwright";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Socket } from "net";
|
||||
import type {
|
||||
ServeOptions,
|
||||
GetPageRequest,
|
||||
GetPageResponse,
|
||||
ListPagesResponse,
|
||||
ServerInfoResponse,
|
||||
} from "./types";
|
||||
|
||||
export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
|
||||
|
||||
export interface DevBrowserServer {
|
||||
wsEndpoint: string;
|
||||
port: number;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper to retry fetch with exponential backoff
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
maxRetries = 5,
|
||||
delayMs = 500
|
||||
): Promise<globalThis.Response> {
|
||||
let lastError: Error | null = null;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
|
||||
const port = options.port ?? 9222;
|
||||
const headless = options.headless ?? false;
|
||||
const cdpPort = options.cdpPort ?? 9223;
|
||||
const profileDir = options.profileDir;
|
||||
|
||||
// Validate port numbers
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (cdpPort < 1 || cdpPort > 65535) {
|
||||
throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
|
||||
}
|
||||
if (port === cdpPort) {
|
||||
throw new Error("port and cdpPort must be different");
|
||||
}
|
||||
|
||||
// Determine user data directory for persistent context
|
||||
const userDataDir = profileDir
|
||||
? join(profileDir, "browser-data")
|
||||
: join(process.cwd(), ".browser-data");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
console.log(`Using persistent browser profile: ${userDataDir}`);
|
||||
|
||||
console.log("Launching browser with persistent context...");
|
||||
|
||||
// Launch persistent context - this persists cookies, localStorage, cache, etc.
|
||||
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless,
|
||||
args: [`--remote-debugging-port=${cdpPort}`],
|
||||
});
|
||||
console.log("Browser launched with persistent profile...");
|
||||
|
||||
// Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
|
||||
const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
|
||||
const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
|
||||
const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
|
||||
console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
|
||||
|
||||
// Registry entry type for page tracking
|
||||
interface PageEntry {
|
||||
page: Page;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
// Registry: name -> PageEntry
|
||||
const registry = new Map<string, PageEntry>();
|
||||
|
||||
// Helper to get CDP targetId for a page
|
||||
async function getTargetId(page: Page): Promise<string> {
|
||||
const cdpSession = await context.newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
||||
return targetInfo.targetId;
|
||||
} finally {
|
||||
await cdpSession.detach();
|
||||
}
|
||||
}
|
||||
|
||||
// Express server for page management
|
||||
const app: Express = express();
|
||||
app.use(express.json());
|
||||
|
||||
// GET / - server info
|
||||
app.get("/", (_req: Request, res: Response) => {
|
||||
const response: ServerInfoResponse = { wsEndpoint };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// GET /pages - list all pages
|
||||
app.get("/pages", (_req: Request, res: Response) => {
|
||||
const response: ListPagesResponse = {
|
||||
pages: Array.from(registry.keys()),
|
||||
};
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// POST /pages - get or create page
|
||||
app.post("/pages", async (req: Request, res: Response) => {
|
||||
const body = req.body as GetPageRequest;
|
||||
const { name, viewport } = body;
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
res.status(400).json({ error: "name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length > 256) {
|
||||
res.status(400).json({ error: "name must be 256 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page already exists
|
||||
let entry = registry.get(name);
|
||||
if (!entry) {
|
||||
// Create new page in the persistent context (with timeout to prevent hangs)
|
||||
const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
|
||||
|
||||
// Apply viewport if provided
|
||||
if (viewport) {
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
const targetId = await getTargetId(page);
|
||||
entry = { page, targetId };
|
||||
registry.set(name, entry);
|
||||
|
||||
// Clean up registry when page is closed (e.g., user clicks X)
|
||||
page.on("close", () => {
|
||||
registry.delete(name);
|
||||
});
|
||||
}
|
||||
|
||||
const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// DELETE /pages/:name - close a page
|
||||
app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
|
||||
const name = decodeURIComponent(req.params.name);
|
||||
const entry = registry.get(name);
|
||||
|
||||
if (entry) {
|
||||
await entry.page.close();
|
||||
registry.delete(name);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).json({ error: "page not found" });
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`HTTP API server running on port ${port}`);
|
||||
});
|
||||
|
||||
// Track active connections for clean shutdown
|
||||
const connections = new Set<Socket>();
|
||||
server.on("connection", (socket: Socket) => {
|
||||
connections.add(socket);
|
||||
socket.on("close", () => connections.delete(socket));
|
||||
});
|
||||
|
||||
// Track if cleanup has been called to avoid double cleanup
|
||||
let cleaningUp = false;
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
if (cleaningUp) return;
|
||||
cleaningUp = true;
|
||||
|
||||
console.log("\nShutting down...");
|
||||
|
||||
// Close all active HTTP connections
|
||||
for (const socket of connections) {
|
||||
socket.destroy();
|
||||
}
|
||||
connections.clear();
|
||||
|
||||
// Close all pages
|
||||
for (const entry of registry.values()) {
|
||||
try {
|
||||
await entry.page.close();
|
||||
} catch {
|
||||
// Page might already be closed
|
||||
}
|
||||
}
|
||||
registry.clear();
|
||||
|
||||
// Close context (this also closes the browser)
|
||||
try {
|
||||
await context.close();
|
||||
} catch {
|
||||
// Context might already be closed
|
||||
}
|
||||
|
||||
server.close();
|
||||
console.log("Server stopped.");
|
||||
};
|
||||
|
||||
// Synchronous cleanup for forced exits
|
||||
const syncCleanup = () => {
|
||||
try {
|
||||
context.close();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
};
|
||||
|
||||
// Signal handlers (consolidated to reduce duplication)
|
||||
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
||||
|
||||
const signalHandler = async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const errorHandler = async (err: unknown) => {
|
||||
console.error("Unhandled error:", err);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// Register handlers
|
||||
signals.forEach((sig) => process.on(sig, signalHandler));
|
||||
process.on("uncaughtException", errorHandler);
|
||||
process.on("unhandledRejection", errorHandler);
|
||||
process.on("exit", syncCleanup);
|
||||
|
||||
// Helper to remove all handlers
|
||||
const removeHandlers = () => {
|
||||
signals.forEach((sig) => process.off(sig, signalHandler));
|
||||
process.off("uncaughtException", errorHandler);
|
||||
process.off("unhandledRejection", errorHandler);
|
||||
process.off("exit", syncCleanup);
|
||||
};
|
||||
|
||||
return {
|
||||
wsEndpoint,
|
||||
port,
|
||||
async stop() {
|
||||
removeHandlers();
|
||||
await cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* CDP Relay Server for Chrome Extension mode
|
||||
*
|
||||
* This server acts as a bridge between Playwright clients and a Chrome extension.
|
||||
* Instead of launching a browser, it waits for the extension to connect and
|
||||
* forwards CDP commands/events between them.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createNodeWebSocket } from "@hono/node-ws";
|
||||
import type { WSContext } from "hono/ws";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface RelayOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface RelayServer {
|
||||
wsEndpoint: string;
|
||||
port: number;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
interface TargetInfo {
|
||||
targetId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
interface ConnectedTarget {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
targetInfo: TargetInfo;
|
||||
}
|
||||
|
||||
interface PlaywrightClient {
|
||||
id: string;
|
||||
ws: WSContext;
|
||||
knownTargets: Set<string>; // targetIds this client has received attachedToTarget for
|
||||
}
|
||||
|
||||
// Message types for extension communication
|
||||
interface ExtensionCommandMessage {
|
||||
id: number;
|
||||
method: "forwardCDPCommand";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExtensionResponseMessage {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ExtensionEventMessage {
|
||||
method: "forwardCDPEvent";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ExtensionMessage =
|
||||
| ExtensionResponseMessage
|
||||
| ExtensionEventMessage
|
||||
| { method: "log"; params: { level: string; args: string[] } };
|
||||
|
||||
// CDP message types
|
||||
interface CDPCommand {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface CDPResponse {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
result?: unknown;
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
interface CDPEvent {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Relay Server Implementation
|
||||
// ============================================================================
|
||||
|
||||
export async function serveRelay(options: RelayOptions = {}): Promise<RelayServer> {
|
||||
const port = options.port ?? 9222;
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
|
||||
// State
|
||||
const connectedTargets = new Map<string, ConnectedTarget>();
|
||||
const namedPages = new Map<string, string>(); // name -> sessionId
|
||||
const playwrightClients = new Map<string, PlaywrightClient>();
|
||||
let extensionWs: WSContext | null = null;
|
||||
|
||||
// Pending requests to extension
|
||||
const extensionPendingRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
let extensionMessageId = 0;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function log(...args: unknown[]) {
|
||||
console.log("[relay]", ...args);
|
||||
}
|
||||
|
||||
function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
if (clientId) {
|
||||
const client = playwrightClients.get(clientId);
|
||||
if (client) {
|
||||
client.ws.send(messageStr);
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.send(messageStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Target.attachedToTarget event with deduplication.
|
||||
* Tracks which targets each client has seen to prevent "Duplicate target" errors.
|
||||
*/
|
||||
function sendAttachedToTarget(
|
||||
target: ConnectedTarget,
|
||||
clientId?: string,
|
||||
waitingForDebugger = false
|
||||
) {
|
||||
const event: CDPEvent = {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: target.sessionId,
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
waitingForDebugger,
|
||||
},
|
||||
};
|
||||
|
||||
if (clientId) {
|
||||
const client = playwrightClients.get(clientId);
|
||||
if (client && !client.knownTargets.has(target.targetId)) {
|
||||
client.knownTargets.add(target.targetId);
|
||||
client.ws.send(JSON.stringify(event));
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients that don't know about this target yet
|
||||
for (const client of playwrightClients.values()) {
|
||||
if (!client.knownTargets.has(target.targetId)) {
|
||||
client.knownTargets.add(target.targetId);
|
||||
client.ws.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToExtension({
|
||||
method,
|
||||
params,
|
||||
timeout = 30000,
|
||||
}: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
}): Promise<unknown> {
|
||||
if (!extensionWs) {
|
||||
throw new Error("Extension not connected");
|
||||
}
|
||||
|
||||
const id = ++extensionMessageId;
|
||||
const message = { id, method, params };
|
||||
|
||||
extensionWs.send(JSON.stringify(message));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
extensionPendingRequests.delete(id);
|
||||
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
|
||||
}, timeout);
|
||||
|
||||
extensionPendingRequests.set(id, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function routeCdpCommand({
|
||||
method,
|
||||
params,
|
||||
sessionId,
|
||||
}: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
}): Promise<unknown> {
|
||||
// Handle some CDP commands locally
|
||||
switch (method) {
|
||||
case "Browser.getVersion":
|
||||
return {
|
||||
protocolVersion: "1.3",
|
||||
product: "Chrome/Extension-Bridge",
|
||||
revision: "1.0.0",
|
||||
userAgent: "dev-browser-relay/1.0.0",
|
||||
jsVersion: "V8",
|
||||
};
|
||||
|
||||
case "Browser.setDownloadBehavior":
|
||||
return {};
|
||||
|
||||
case "Target.setAutoAttach":
|
||||
if (sessionId) {
|
||||
break; // Forward to extension for child frames
|
||||
}
|
||||
return {};
|
||||
|
||||
case "Target.setDiscoverTargets":
|
||||
return {};
|
||||
|
||||
case "Target.attachToBrowserTarget":
|
||||
// Browser-level session - return a fake session since we only proxy tabs
|
||||
return { sessionId: "browser" };
|
||||
|
||||
case "Target.detachFromTarget":
|
||||
// If detaching from our fake "browser" session, just return success
|
||||
if (sessionId === "browser" || params?.sessionId === "browser") {
|
||||
return {};
|
||||
}
|
||||
// Otherwise forward to extension
|
||||
break;
|
||||
|
||||
case "Target.attachToTarget": {
|
||||
const targetId = params?.targetId as string;
|
||||
if (!targetId) {
|
||||
throw new Error("targetId is required for Target.attachToTarget");
|
||||
}
|
||||
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { sessionId: target.sessionId };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Target ${targetId} not found in connected targets`);
|
||||
}
|
||||
|
||||
case "Target.getTargetInfo": {
|
||||
const targetId = params?.targetId as string;
|
||||
|
||||
if (targetId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === targetId) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const target = connectedTargets.get(sessionId);
|
||||
if (target) {
|
||||
return { targetInfo: target.targetInfo };
|
||||
}
|
||||
}
|
||||
|
||||
// Return first target if no specific one requested
|
||||
const firstTarget = Array.from(connectedTargets.values())[0];
|
||||
return { targetInfo: firstTarget?.targetInfo };
|
||||
}
|
||||
|
||||
case "Target.getTargets":
|
||||
return {
|
||||
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
||||
...t.targetInfo,
|
||||
attached: true,
|
||||
})),
|
||||
};
|
||||
|
||||
case "Target.createTarget":
|
||||
case "Target.closeTarget":
|
||||
// Forward to extension
|
||||
return await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { method, params },
|
||||
});
|
||||
}
|
||||
|
||||
// Forward all other commands to extension
|
||||
return await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { sessionId, method, params },
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP/WebSocket Server
|
||||
// ============================================================================
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
// Health check / server info
|
||||
app.get("/", (c) => {
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
extensionConnected: extensionWs !== null,
|
||||
mode: "extension",
|
||||
});
|
||||
});
|
||||
|
||||
// List named pages
|
||||
app.get("/pages", (c) => {
|
||||
return c.json({
|
||||
pages: Array.from(namedPages.keys()),
|
||||
});
|
||||
});
|
||||
|
||||
// Get or create a named page
|
||||
app.post("/pages", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = body.name as string;
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: "name is required" }, 400);
|
||||
}
|
||||
|
||||
// Check if page already exists by name
|
||||
const existingSessionId = namedPages.get(name);
|
||||
if (existingSessionId) {
|
||||
const target = connectedTargets.get(existingSessionId);
|
||||
if (target) {
|
||||
// Activate the tab so it becomes the active tab
|
||||
await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Target.activateTarget",
|
||||
params: { targetId: target.targetId },
|
||||
},
|
||||
});
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
name,
|
||||
targetId: target.targetId,
|
||||
url: target.targetInfo.url,
|
||||
});
|
||||
}
|
||||
// Session no longer valid, remove it
|
||||
namedPages.delete(name);
|
||||
}
|
||||
|
||||
// Create a new tab
|
||||
if (!extensionWs) {
|
||||
return c.json({ error: "Extension not connected" }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = (await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: { method: "Target.createTarget", params: { url: "about:blank" } },
|
||||
})) as { targetId: string };
|
||||
|
||||
// Wait for Target.attachedToTarget event to register the new target
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Find and name the new target
|
||||
for (const [sessionId, target] of connectedTargets) {
|
||||
if (target.targetId === result.targetId) {
|
||||
namedPages.set(name, sessionId);
|
||||
// Activate the tab so it becomes the active tab
|
||||
await sendToExtension({
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Target.activateTarget",
|
||||
params: { targetId: target.targetId },
|
||||
},
|
||||
});
|
||||
return c.json({
|
||||
wsEndpoint: `ws://${host}:${port}/cdp`,
|
||||
name,
|
||||
targetId: target.targetId,
|
||||
url: target.targetInfo.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Target created but not found in registry");
|
||||
} catch (err) {
|
||||
log("Error creating tab:", err);
|
||||
return c.json({ error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a named page (removes the name, doesn't close the tab)
|
||||
app.delete("/pages/:name", (c) => {
|
||||
const name = c.req.param("name");
|
||||
const deleted = namedPages.delete(name);
|
||||
return c.json({ success: deleted });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Playwright Client WebSocket
|
||||
// ============================================================================
|
||||
|
||||
app.get(
|
||||
"/cdp/:clientId?",
|
||||
upgradeWebSocket((c) => {
|
||||
const clientId =
|
||||
c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (playwrightClients.has(clientId)) {
|
||||
log(`Rejecting duplicate client ID: ${clientId}`);
|
||||
ws.close(1000, "Client ID already connected");
|
||||
return;
|
||||
}
|
||||
|
||||
playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() });
|
||||
log(`Playwright client connected: ${clientId}`);
|
||||
},
|
||||
|
||||
async onMessage(event, _ws) {
|
||||
let message: CDPCommand;
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, sessionId, method, params } = message;
|
||||
|
||||
if (!extensionWs) {
|
||||
sendToPlaywright(
|
||||
{
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: "Extension not connected" },
|
||||
},
|
||||
clientId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await routeCdpCommand({ method, params, sessionId });
|
||||
|
||||
// After Target.setAutoAttach, send attachedToTarget for existing targets
|
||||
// Uses deduplication to prevent "Duplicate target" errors
|
||||
if (method === "Target.setAutoAttach" && !sessionId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
sendAttachedToTarget(target, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// After Target.setDiscoverTargets, send targetCreated events
|
||||
if (
|
||||
method === "Target.setDiscoverTargets" &&
|
||||
(params as { discover?: boolean })?.discover
|
||||
) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
sendToPlaywright(
|
||||
{
|
||||
method: "Target.targetCreated",
|
||||
params: {
|
||||
targetInfo: { ...target.targetInfo, attached: true },
|
||||
},
|
||||
},
|
||||
clientId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After Target.attachToTarget, send attachedToTarget event (with deduplication)
|
||||
if (
|
||||
method === "Target.attachToTarget" &&
|
||||
(result as { sessionId?: string })?.sessionId
|
||||
) {
|
||||
const targetId = params?.targetId as string;
|
||||
const target = Array.from(connectedTargets.values()).find(
|
||||
(t) => t.targetId === targetId
|
||||
);
|
||||
if (target) {
|
||||
sendAttachedToTarget(target, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
sendToPlaywright({ id, sessionId, result }, clientId);
|
||||
} catch (e) {
|
||||
log("Error handling CDP command:", method, e);
|
||||
sendToPlaywright(
|
||||
{
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: (e as Error).message },
|
||||
},
|
||||
clientId
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
onClose() {
|
||||
playwrightClients.delete(clientId);
|
||||
log(`Playwright client disconnected: ${clientId}`);
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
log(`Playwright WebSocket error [${clientId}]:`, event);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Extension WebSocket
|
||||
// ============================================================================
|
||||
|
||||
app.get(
|
||||
"/extension",
|
||||
upgradeWebSocket(() => {
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (extensionWs) {
|
||||
log("Closing existing extension connection");
|
||||
extensionWs.close(4001, "Extension Replaced");
|
||||
|
||||
// Clear state
|
||||
connectedTargets.clear();
|
||||
namedPages.clear();
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error("Extension connection replaced"));
|
||||
}
|
||||
extensionPendingRequests.clear();
|
||||
}
|
||||
|
||||
extensionWs = ws;
|
||||
log("Extension connected");
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
let message: ExtensionMessage;
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data.toString());
|
||||
} catch {
|
||||
ws.close(1000, "Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle response to our request
|
||||
if ("id" in message && typeof message.id === "number") {
|
||||
const pending = extensionPendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
log("Unexpected response with id:", message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
extensionPendingRequests.delete(message.id);
|
||||
|
||||
if ((message as ExtensionResponseMessage).error) {
|
||||
pending.reject(new Error((message as ExtensionResponseMessage).error));
|
||||
} else {
|
||||
pending.resolve((message as ExtensionResponseMessage).result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle log messages
|
||||
if ("method" in message && message.method === "log") {
|
||||
const { level, args } = message.params;
|
||||
console.log(`[extension:${level}]`, ...args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle CDP events from extension
|
||||
if ("method" in message && message.method === "forwardCDPEvent") {
|
||||
const eventMsg = message as ExtensionEventMessage;
|
||||
const { method, params, sessionId } = eventMsg.params;
|
||||
|
||||
// Handle target lifecycle events
|
||||
if (method === "Target.attachedToTarget") {
|
||||
const targetParams = params as {
|
||||
sessionId: string;
|
||||
targetInfo: TargetInfo;
|
||||
};
|
||||
|
||||
const target: ConnectedTarget = {
|
||||
sessionId: targetParams.sessionId,
|
||||
targetId: targetParams.targetInfo.targetId,
|
||||
targetInfo: targetParams.targetInfo,
|
||||
};
|
||||
connectedTargets.set(targetParams.sessionId, target);
|
||||
|
||||
log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`);
|
||||
|
||||
// Use deduplication helper - only sends to clients that don't know about this target
|
||||
sendAttachedToTarget(target);
|
||||
} else if (method === "Target.detachedFromTarget") {
|
||||
const detachParams = params as { sessionId: string };
|
||||
connectedTargets.delete(detachParams.sessionId);
|
||||
|
||||
// Also remove any name mapping
|
||||
for (const [name, sid] of namedPages) {
|
||||
if (sid === detachParams.sessionId) {
|
||||
namedPages.delete(name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log(`Target detached: ${detachParams.sessionId}`);
|
||||
|
||||
sendToPlaywright({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: detachParams,
|
||||
});
|
||||
} else if (method === "Target.targetInfoChanged") {
|
||||
const infoParams = params as { targetInfo: TargetInfo };
|
||||
for (const target of connectedTargets.values()) {
|
||||
if (target.targetId === infoParams.targetInfo.targetId) {
|
||||
target.targetInfo = infoParams.targetInfo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sendToPlaywright({
|
||||
method: "Target.targetInfoChanged",
|
||||
params: infoParams,
|
||||
});
|
||||
} else {
|
||||
// Forward other CDP events to Playwright
|
||||
sendToPlaywright({
|
||||
sessionId,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClose(_event, ws) {
|
||||
if (extensionWs && extensionWs !== ws) {
|
||||
log("Old extension connection closed");
|
||||
return;
|
||||
}
|
||||
|
||||
log("Extension disconnected");
|
||||
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error("Extension connection closed"));
|
||||
}
|
||||
extensionPendingRequests.clear();
|
||||
|
||||
extensionWs = null;
|
||||
connectedTargets.clear();
|
||||
namedPages.clear();
|
||||
|
||||
// Close all Playwright clients
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.close(1000, "Extension disconnected");
|
||||
}
|
||||
playwrightClients.clear();
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
log("Extension WebSocket error:", event);
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Start Server
|
||||
// ============================================================================
|
||||
|
||||
const server = serve({ fetch: app.fetch, port, hostname: host });
|
||||
injectWebSocket(server);
|
||||
|
||||
const wsEndpoint = `ws://${host}:${port}/cdp`;
|
||||
|
||||
log("CDP relay server started");
|
||||
log(` HTTP: http://${host}:${port}`);
|
||||
log(` CDP endpoint: ${wsEndpoint}`);
|
||||
log(` Extension endpoint: ws://${host}:${port}/extension`);
|
||||
log("");
|
||||
log("Waiting for extension to connect...");
|
||||
|
||||
return {
|
||||
wsEndpoint,
|
||||
port,
|
||||
async stop() {
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.close(1000, "Server stopped");
|
||||
}
|
||||
playwrightClients.clear();
|
||||
extensionWs?.close(1000, "Server stopped");
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { chromium } from "playwright";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from "vitest";
|
||||
import { getSnapshotScript, clearSnapshotScriptCache } from "../browser-script";
|
||||
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
clearSnapshotScriptCache(); // Start fresh for each test
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
async function setContent(html: string): Promise<void> {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function getSnapshot(): Promise<string> {
|
||||
const script = getSnapshotScript();
|
||||
return await page.evaluate((s: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
if (!w.__devBrowser_getAISnapshot) {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(s);
|
||||
}
|
||||
return w.__devBrowser_getAISnapshot();
|
||||
}, script);
|
||||
}
|
||||
|
||||
async function selectRef(ref: string): Promise<unknown> {
|
||||
return await page.evaluate((refId: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
const element = w.__devBrowser_selectSnapshotRef(refId);
|
||||
return {
|
||||
tagName: element.tagName,
|
||||
textContent: element.textContent?.trim(),
|
||||
};
|
||||
}, ref);
|
||||
}
|
||||
|
||||
describe("ARIA Snapshot", () => {
|
||||
test("generates snapshot for simple page", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<button>Click me</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("heading");
|
||||
expect(snapshot).toContain("Hello World");
|
||||
expect(snapshot).toContain("button");
|
||||
expect(snapshot).toContain("Click me");
|
||||
});
|
||||
|
||||
test("assigns refs to interactive elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
// Should have refs
|
||||
expect(snapshot).toMatch(/\[ref=e\d+\]/);
|
||||
});
|
||||
|
||||
test("refs persist on window.__devBrowserRefs", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button>Test Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
await getSnapshot();
|
||||
|
||||
// Check that refs are stored
|
||||
const hasRefs = await page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const w = globalThis as any;
|
||||
return typeof w.__devBrowserRefs === "object" && Object.keys(w.__devBrowserRefs).length > 0;
|
||||
});
|
||||
|
||||
expect(hasRefs).toBe(true);
|
||||
});
|
||||
|
||||
test("selectSnapshotRef returns element for valid ref", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button>My Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
// Extract a ref from the snapshot
|
||||
const refMatch = snapshot.match(/\[ref=(e\d+)\]/);
|
||||
expect(refMatch).toBeTruthy();
|
||||
expect(refMatch![1]).toBeDefined();
|
||||
const ref = refMatch![1] as string;
|
||||
|
||||
// Select the element by ref
|
||||
const result = (await selectRef(ref)) as { tagName: string; textContent: string };
|
||||
expect(result.tagName).toBe("BUTTON");
|
||||
expect(result.textContent).toBe("My Button");
|
||||
});
|
||||
|
||||
test("includes links with URLs", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://example.com">Example Link</a>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("link");
|
||||
expect(snapshot).toContain("Example Link");
|
||||
// URL should be included as a prop
|
||||
expect(snapshot).toContain("/url:");
|
||||
});
|
||||
|
||||
test("includes form elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<input type="text" placeholder="Enter name" />
|
||||
<input type="checkbox" />
|
||||
<select>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
</select>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("textbox");
|
||||
expect(snapshot).toContain("checkbox");
|
||||
expect(snapshot).toContain("combobox");
|
||||
});
|
||||
|
||||
test("renders nested structure correctly", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/home">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("navigation");
|
||||
expect(snapshot).toContain("list");
|
||||
expect(snapshot).toContain("listitem");
|
||||
expect(snapshot).toContain("link");
|
||||
});
|
||||
|
||||
test("handles disabled elements", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button disabled>Disabled Button</button>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("[disabled]");
|
||||
});
|
||||
|
||||
test("handles checked checkboxes", async () => {
|
||||
await setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<input type="checkbox" checked />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const snapshot = await getSnapshot();
|
||||
|
||||
expect(snapshot).toContain("[checked]");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 985 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user