@ihyeon/flow-qa 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +72 -0
  3. package/README.md +71 -2
  4. package/THIRD_PARTY_NOTICES.md +9 -0
  5. package/dist/bin/flow-qa.mjs +3 -0
  6. package/dist/examples/demo/server.js +31 -0
  7. package/dist/src/cli.js +285 -0
  8. package/dist/src/compiler.js +62 -0
  9. package/dist/src/engine.js +246 -0
  10. package/dist/src/i18n.js +98 -0
  11. package/dist/src/planner.js +112 -0
  12. package/dist/src/report.js +315 -0
  13. package/dist/src/schema.js +71 -0
  14. package/dist/src/ui/entry.js +109 -0
  15. package/dist/src/ui/evidence.js +85 -0
  16. package/dist/src/ui/public/app.js +360 -0
  17. package/dist/src/ui/public/display.js +56 -0
  18. package/dist/src/ui/public/fonts/dm-sans.woff2 +0 -0
  19. package/dist/src/ui/public/fonts/noto-kr-subset.woff2 +0 -0
  20. package/dist/src/ui/public/i18n.js +290 -0
  21. package/dist/src/ui/public/index.html +117 -0
  22. package/dist/src/ui/public/style.css +200 -0
  23. package/dist/src/ui/server.js +785 -0
  24. package/dist/src/workspace.js +88 -0
  25. package/docs/architecture.md +38 -0
  26. package/docs/releases.md +53 -0
  27. package/docs/ui.md +71 -0
  28. package/licenses/DM-Sans-LICENSE.txt +93 -0
  29. package/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  30. package/node_modules/@hyeonse/design-system/.github/workflows/ci.yml +21 -0
  31. package/node_modules/@hyeonse/design-system/.npmignore +5 -0
  32. package/node_modules/@hyeonse/design-system/AGENTS.md +10 -0
  33. package/node_modules/@hyeonse/design-system/LICENSE +21 -0
  34. package/node_modules/@hyeonse/design-system/README.md +67 -0
  35. package/node_modules/@hyeonse/design-system/THIRD_PARTY_NOTICES.md +5 -0
  36. package/node_modules/@hyeonse/design-system/dist/components.css +105 -0
  37. package/node_modules/@hyeonse/design-system/dist/preferences.d.ts +21 -0
  38. package/node_modules/@hyeonse/design-system/dist/preferences.js +69 -0
  39. package/node_modules/@hyeonse/design-system/dist/react.d.ts +21 -0
  40. package/node_modules/@hyeonse/design-system/dist/react.js +100 -0
  41. package/node_modules/@hyeonse/design-system/dist/tokens.css +75 -0
  42. package/node_modules/@hyeonse/design-system/docs/api.md +33 -0
  43. package/node_modules/@hyeonse/design-system/docs/assets/fonts/dm-sans.woff2 +0 -0
  44. package/node_modules/@hyeonse/design-system/docs/assets/fonts/noto-kr-subset.woff2 +0 -0
  45. package/node_modules/@hyeonse/design-system/docs/audit.md +29 -0
  46. package/node_modules/@hyeonse/design-system/docs/foundations.md +30 -0
  47. package/node_modules/@hyeonse/design-system/docs/verification.md +13 -0
  48. package/node_modules/@hyeonse/design-system/index.html +51 -0
  49. package/node_modules/@hyeonse/design-system/licenses/DM-Sans-LICENSE.txt +93 -0
  50. package/node_modules/@hyeonse/design-system/licenses/Noto-Sans-KR-LICENSE.txt +93 -0
  51. package/node_modules/@hyeonse/design-system/licenses/arla6ka-MIT.txt +21 -0
  52. package/node_modules/@hyeonse/design-system/package.json +35 -0
  53. package/node_modules/@hyeonse/design-system/scripts/audit-tokens.mjs +48 -0
  54. package/node_modules/@hyeonse/design-system/scripts/build.mjs +12 -0
  55. package/node_modules/@hyeonse/design-system/showcase.css +75 -0
  56. package/node_modules/@hyeonse/design-system/showcase.mjs +63 -0
  57. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/SKILL.md +18 -0
  58. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/api.md +7 -0
  59. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/build.md +7 -0
  60. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/foundations.md +7 -0
  61. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/mapping.md +7 -0
  62. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/migration.md +5 -0
  63. package/node_modules/@hyeonse/design-system/skills/hyeonse-design-system/references/review-docs.md +5 -0
  64. package/node_modules/@hyeonse/design-system/src/components.css +105 -0
  65. package/node_modules/@hyeonse/design-system/src/preferences.ts +87 -0
  66. package/node_modules/@hyeonse/design-system/src/react.tsx +161 -0
  67. package/node_modules/@hyeonse/design-system/src/tokens.css +75 -0
  68. package/node_modules/@hyeonse/design-system/tests/audit.test.mjs +30 -0
  69. package/node_modules/@hyeonse/design-system/tests/preferences.test.mjs +86 -0
  70. package/node_modules/@hyeonse/design-system/tsconfig.json +16 -0
  71. package/package.json +73 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hyeonse Im
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,72 @@
1
+ # Flow QA
2
+
3
+ [English](https://github.com/hyeonse-swing/flow-qa/blob/main/README.md) · **한국어**
4
+
5
+ 사용자 플로우를 검토 가능한 테스트 케이스로 바꾸고, 실제 브라우저 실행 결과를 증거가 연결된 QA 리포트로 남기는 로컬 도구입니다. 플로우의 기대 결과가 모호하면 추측해 통과시키지 않고 질문과 차단 상태로 남깁니다.
6
+
7
+ **공개 알파:** CLI와 `127.0.0.1` 전용 웹 UI를 제공합니다. UI는 영어로 시작하며 상단 언어 전환에서 한국어를 선택할 수 있습니다. 선택은 이 브라우저에 저장됩니다. 테스트 대상의 URL과 화면 계약을 사용하며 앱 소스 또는 데이터베이스 접근은 필수가 아닙니다. 설치 없이 구조를 보려면 [아키텍처](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/architecture.md)와 [UI 안내](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/ui.md)를 참고하세요.
8
+
9
+ ![로컬 데모 화면](https://raw.githubusercontent.com/hyeonse-swing/flow-qa/main/docs/media/flow-qa-demo.png)
10
+
11
+ [1분 데모 영상](https://github.com/hyeonse-swing/flow-qa/releases/download/v0.1.0-alpha.1/flow-qa-demo.mp4) · 로컬 할 일 앱에서 예제 불러오기 → 케이스 검토 → 실제 Chromium 실행 → 리포트 확인. 영상은 모델 없이 고정된 세 케이스를 실행합니다.
12
+
13
+ ## 로컬 데모 시작
14
+
15
+ Node.js 24 이상이 필요합니다. 패키지는 `@ihyeon/flow-qa@0.1.0-alpha.1`입니다. 브라우저는 첫 실행 전에 설치합니다.
16
+
17
+ ```sh
18
+ npm install -g @ihyeon/flow-qa@0.1.0-alpha.1
19
+ flow-qa browser install
20
+ flow-qa ui --demo
21
+ ```
22
+
23
+ 출력된 로컬 UI 주소에서 **케이스 보관함 → 예제 불러오기 → 검토 → 실행** 순서로 진행합니다. 데모 앱과 별도 작업 공간이 함께 시작되며 API 키가 필요하지 않습니다. 결과는 현재 디렉터리의 `.flowqa/ui-demo-en/`에 저장됩니다. 기존 한국어 데모 데이터는 별도로 유지되며, 언어 전환은 예제·사용자 문장을 자동 번역하지 않습니다. 문제가 있으면 `flow-qa browser install --with-deps`로 Chromium의 시스템 의존성을 설치할 수 있습니다. 자세한 화면 동작은 [UI 안내](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/ui.md)에 있습니다.
24
+
25
+ 소스에서 개발하거나 검증을 재현하려면:
26
+
27
+ ```sh
28
+ npm ci
29
+ npx playwright install chromium
30
+ npm run ui:demo
31
+ npm run check
32
+ npm run demo:smoke
33
+ ```
34
+
35
+ `demo:smoke`는 정상 케이스, 의도적으로 실패하는 assertion, 앱 접속 불가를 실제 Chromium으로 확인합니다. 모델 호출 품질이나 외부 서비스 인증은 이 검사 범위에 포함되지 않습니다. 패키지 검증과 공개 절차는 [릴리스 기록](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/releases.md)에 따로 적습니다.
36
+
37
+ ## 내 서비스 연결
38
+
39
+ 테스트용 작업 공간에서 시작합니다. 연결한 앱에 실제 클릭과 입력이 발생하므로 테스트 환경과 데이터를 사용하세요.
40
+
41
+ ```sh
42
+ flow-qa init my-qa
43
+ flow-qa connect preview --url https://preview.example.test --workspace my-qa
44
+ flow-qa ui --workspace my-qa
45
+ ```
46
+
47
+ CLI만으로도 `flow-qa validate cases/my-flow.json --workspace my-qa`, `flow-qa generate cases/my-flow.json --workspace my-qa`, `flow-qa run cases/my-flow.json --target preview --workspace my-qa` 순서로 사용할 수 있습니다. `flow-qa --help`는 전체 명령을 보여줍니다. 대상과 모델 서버는 HTTPS를 사용하며 HTTP는 루프백 주소에서만 허용합니다.
48
+
49
+ CLI 제품 문구는 영어가 기본이며 `--lang en|ko`가 선택적 환경 변수 `FLOW_QA_LANG`보다 우선합니다. 저장된 리포트 파일은 영어 제품 문구와 기존 상태 코드를 유지하고, UI에서는 선택한 언어의 제품 라벨로 Markdown 리포트를 내려받을 수 있습니다.
50
+
51
+ `flows/my-flow.md`에는 시작 상태, 행동, 화면 요소, 기대 결과를 적습니다. 모델 없이 설계 프롬프트를 생성해 기존 AI에 전달하려면 `flow-qa plan flows/my-flow.md --prompt-only --workspace my-qa`를 실행합니다. 응답을 `cases/my-flow.json`에 저장한 뒤 검토하고 검증합니다. OpenAI 호환 모델을 직접 연결할 때는 다음처럼 설계 모델을 설정합니다.
52
+
53
+ ```sh
54
+ flow-qa model planner --url http://localhost:11434/v1 --model your-model-id --workspace my-qa
55
+ flow-qa plan flows/my-flow.md --workspace my-qa
56
+ ```
57
+
58
+ 인증이 필요하면 모델 설정에 `--key-env FLOW_QA_API_KEY`를 추가하고 실행 환경에 그 값을 제공하세요. 설정 파일에는 환경 변수 이름만 저장합니다. 플로우 문서는 지정한 설계 모델 서버로 전송됩니다. 브라우저의 자연어 조작 `act` 또는 모델 판정 `assertAgent`를 쓰려면 도구 호출·이미지 입력을 지원하는 **별도** `agent` 모델 설정이 필요합니다. 고정된 클릭·입력·검증 단계는 모델 없이 실행됩니다.
59
+
60
+ ## 설계 원칙과 결과 해석
61
+
62
+ - 케이스는 요구사항 ID와 명시적인 assertion을 가집니다. `open`, `click`, `fill`, `act`, `assertVisible`, `assertText`, `assertUrl`, `assertAgent` 등 허용된 단계만 스키마로 검증해 컴파일합니다. 모델 응답의 임의 JavaScript나 shell 명령은 실행하지 않습니다.
63
+ - 확인되지 않은 전제는 `questions`에 남기고 해당 케이스를 차단합니다. `preconditions`는 설명이며 계정·재고·로그인 상태를 자동 준비하지 않습니다.
64
+ - 생성된 케이스는 원본 플로우의 해시를 기록합니다. 원본이 바뀌면 검증·생성·실행을 멈추고 다시 설계하도록 합니다. 생성과 실행은 별도 동작이며 입력 변경만으로 모델을 호출하지 않습니다.
65
+ - 실행마다 `.flowqa/runs/<run-id>/`에 새 리포트와 엔진 결과를 만듭니다. `qa-report.json`과 `qa-report.md`는 통과, 실패, 재시도 후 통과, 차단, 오류, 미실행을 구분합니다. assertion 실패는 관찰 결과이며 제품 버그 확정은 아닙니다.
66
+ - 취소한 실행은 중단으로 기록합니다. UI의 생성 취소는 늦게 도착한 응답을 반영하지 않지만 모델 제공자의 처리나 과금 취소를 보장하지 않습니다. 엔진의 토큰·추정 비용만 표시하며 전체 비용 상한은 없습니다.
67
+
68
+ 리포트와 원본 증거에는 테스트 앱의 데이터가 포함될 수 있습니다. `.flowqa/`는 Git에서 제외되지만 외부 공유 전에는 내용을 확인하세요. 마스킹이 모든 개인정보를 탐지한다고 보장하지 않습니다. 세션 준비, 모바일 기기, 예약 CI 실행, 팀 권한과 공유 서버는 이 알파의 지원 범위 밖입니다.
69
+
70
+ ## 구현과 출처
71
+
72
+ Flow QA가 구현한 부분은 플로우·케이스 스키마, 설계 모델 연결, 컴파일, 실행 조율, 결과 해석과 로컬 UI입니다. 실제 브라우저 테스트 엔진은 [tester-army/e2e](https://github.com/tester-army/e2e)와 관련 패키지를 의존성으로 사용합니다. UI는 [Hyeonse Design System](https://github.com/hyeonse-swing/hyeonse-design-system)의 스타일 토큰과 선호 설정 유틸을 사용하며 화면 구성은 이 프로젝트에서 작성했습니다. [제3자 고지](https://github.com/hyeonse-swing/flow-qa/blob/main/THIRD_PARTY_NOTICES.md)와 [아키텍처](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/architecture.md)에 경계를 자세히 적었습니다.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # Flow QA
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **English** · [한국어](https://github.com/hyeonse-swing/flow-qa/blob/main/README.ko.md)
4
+
5
+ Flow QA turns a user flow into reviewable test cases, runs them in a real browser, and ties the results to evidence in a QA report. When an expected outcome is unclear, it keeps the question visible and blocks that case instead of guessing.
6
+
7
+ **Public alpha:** a CLI and a web UI bound to `127.0.0.1`. Provide a target URL and describe the screen behavior; access to the application's source code or database is optional. The UI starts in English. Use the language switch in the top bar for Korean; the choice is saved in this browser. See the [architecture](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/architecture.md) and [UI guide](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/ui.md) for the details.
8
+
9
+ ![Flow QA local demo](https://raw.githubusercontent.com/hyeonse-swing/flow-qa/main/docs/media/flow-qa-demo.png)
10
+
11
+ [Watch the one-minute demo](https://github.com/hyeonse-swing/flow-qa/releases/download/v0.1.0-alpha.1/flow-qa-demo.mp4) · Load a local to-do app example, review its cases, run them in Chromium, and inspect the report. The 67-second recording uses the English UI, runs three fixed cases without a model, and demonstrates switching to Korean.
12
+
13
+ ## Try the local demo
14
+
15
+ Requires Node.js 24 or later. Install Chromium before the first run:
16
+
17
+ ```sh
18
+ npm install -g @ihyeon/flow-qa@0.1.0-alpha.1
19
+ flow-qa browser install
20
+ flow-qa ui --demo
21
+ ```
22
+
23
+ Open the printed local UI address, then follow **Case library → Load → Review → Run QA**. The command starts a separate local demo app and workspace; no API key is needed. Results are saved under `.flowqa/ui-demo-en/` in the current directory. If Chromium lacks system libraries, use `flow-qa browser install --with-deps`. See the [UI guide](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/ui.md).
24
+
25
+ To work from source or reproduce the checks:
26
+
27
+ ```sh
28
+ npm ci
29
+ npx playwright install chromium
30
+ npm run ui:demo
31
+ npm run check
32
+ npm run demo:smoke
33
+ ```
34
+
35
+ `demo:smoke` uses real Chromium to check a passing run, an intentionally wrong assertion, and an unreachable app. It does not establish model quality or authenticated behavior in another service. Package checks and release evidence are recorded in the [release notes](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/releases.md).
36
+
37
+ ## Connect your own app
38
+
39
+ Start with a test workspace and test data: a run can click and enter text in the connected app.
40
+
41
+ ```sh
42
+ flow-qa init my-qa
43
+ flow-qa connect preview --url https://preview.example.test --workspace my-qa
44
+ flow-qa ui --workspace my-qa
45
+ ```
46
+
47
+ The CLI also supports `flow-qa validate cases/my-flow.json --workspace my-qa`, `flow-qa generate cases/my-flow.json --workspace my-qa`, then `flow-qa run cases/my-flow.json --target preview --workspace my-qa`. Run `flow-qa --help` for all commands. Target and model URLs require HTTPS; HTTP is allowed only on loopback.
48
+
49
+ CLI product messages default to English; `--lang en|ko` overrides the optional `FLOW_QA_LANG` environment variable. Saved report files keep English product wording and unchanged status codes; the UI can download report Markdown with labels in the selected UI language.
50
+
51
+ Describe the initial state, actions, screen elements, and expected result in `flows/my-flow.md`. To prepare a prompt for an AI you already use, run `flow-qa plan flows/my-flow.md --prompt-only --workspace my-qa`, save its JSON response as `cases/my-flow.json`, then review and validate it. To connect an OpenAI-compatible planner directly:
52
+
53
+ ```sh
54
+ flow-qa model planner --url http://localhost:11434/v1 --model your-model-id --workspace my-qa
55
+ flow-qa plan flows/my-flow.md --workspace my-qa
56
+ ```
57
+
58
+ For authenticated models, add `--key-env FLOW_QA_API_KEY` to the model command and provide that environment variable to the process. The config stores the variable's name, not its value. The flow is sent to the planner endpoint you chose. Natural-language browser action (`act`) and model judgment (`assertAgent`) require a **separately configured** `agent` model that supports tools and images. Fixed clicks, inputs, and assertions run without a model.
59
+
60
+ ## Design choices and result interpretation
61
+
62
+ - Every case references requirement IDs and a runnable case needs an explicit assertion. The schema accepts a fixed vocabulary including `open`, `click`, `fill`, `act`, `assertVisible`, `assertText`, `assertUrl`, and `assertAgent`. It never executes arbitrary JavaScript or shell commands returned by a model.
63
+ - Unresolved assumptions go into `questions` and block that case. `preconditions` are descriptive; Flow QA does not prepare accounts, inventory, or login state automatically.
64
+ - Generated cases record a hash of their source flow. If the flow changes, validation, compilation, and execution stop until the cases are redesigned. Planning and execution are separate actions; editing inputs does not call a model.
65
+ - Each run gets a fresh `.flowqa/runs/<run-id>/` directory with engine output and `qa-report.json`/`qa-report.md`. Reports distinguish passed, failed, passed after retry, blocked, error, and skipped cases. A failed assertion is an observation, not proof of a product defect.
66
+ - Cancellation records an interrupted run. Canceling generation prevents a late response from changing the draft, but does not guarantee cancellation of a provider's processing or charges. The UI shows token use and estimated cost only when the engine supplies them; there is no overall cost cap.
67
+
68
+ Reports and raw evidence can contain data from the tested app. `.flowqa/` is Git-ignored, but inspect files before sharing them. Redaction cannot be assumed to catch every personal detail. Session setup, mobile devices, scheduled CI runs, team permissions, and a shared server are outside this alpha's scope.
69
+
70
+ ## Implementation and credits
71
+
72
+ Flow QA owns the flow and case schema, planner integration, compilation, run orchestration, result interpretation, and local UI. [tester-army/e2e](https://github.com/tester-army/e2e) and related packages provide the browser test engine. The UI uses style tokens and preference utilities from [Hyeonse Design System](https://github.com/hyeonse-swing/hyeonse-design-system); this project's workflow screens are its own implementation. See the [third-party notices](https://github.com/hyeonse-swing/flow-qa/blob/main/THIRD_PARTY_NOTICES.md) and [architecture](https://github.com/hyeonse-swing/flow-qa/blob/main/docs/architecture.md) for those boundaries.
@@ -0,0 +1,9 @@
1
+ # Third-party components and fonts
2
+
3
+ Flow QA uses [tester-army/e2e](https://github.com/tester-army/e2e) and `@e2e-dev/web` for browser test execution. Those packages are licensed under Apache-2.0 and retain their upstream LICENSE and NOTICE files in their installed distributions. Flow QA adds the flow specification, schema validation, orchestration, report interpretation, and local UI; it does not claim authorship of the execution engine.
4
+
5
+ Flow QA uses `@hyeonse/design-system` from [Hyeonse Design System](https://github.com/hyeonse-swing/hyeonse-design-system), pinned to commit `38978c6a86f422fc0e63e919f600bc2dd3451e5c`. The npm tarball bundles this dependency so consumers do not need Git or GitHub credentials. Its MIT license and notices are preserved in the bundled package. Source checkout installation fetches the pinned public source archive over HTTPS.
6
+
7
+ The local DM Sans and Noto Sans KR font files were copied from that repository's `docs/assets/fonts/` at the same commit. Their licenses are preserved in [DM Sans license](licenses/DM-Sans-LICENSE.txt) and [Noto Sans KR license](licenses/Noto-Sans-KR-LICENSE.txt).
8
+
9
+ The Korean font is a subset and does not cover every Korean character. The UI falls back to installed Korean/system fonts for missing glyphs. No external font service is contacted.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+ await main(process.argv.slice(2));
@@ -0,0 +1,31 @@
1
+ import { createServer } from 'node:http';
2
+ import { pathToFileURL } from 'node:url';
3
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Flow QA · Demo</title>
4
+ <style>body{font:17px system-ui;margin:60px auto;max-width:620px;padding:20px;color:#20302c;background:#f5f8f6}h1{font-size:32px}button,input{font:inherit;padding:10px;margin:4px;border:1px solid #bcc9c0;border-radius:6px}button{cursor:pointer;background:white}form{margin:30px 0}ul{padding:0}li{display:flex;align-items:center;justify-content:space-between;padding:12px;background:white;border-bottom:1px solid #ddd}#error{color:#b12525}</style>
5
+ <main><p>FLOW QA / LOCAL FIXTURE</p><h1>Simple task list</h1><p>Try adding a task, validating the title, and filtering the list in a real browser.</p>
6
+ <form id="form"><label for="title">Task title</label><input id="title" autocomplete="off"><button type="submit">Add</button></form>
7
+ <p id="error" role="alert" hidden></p><nav aria-label="Filters"><button id="all">All</button><button id="active">Active</button></nav><ul data-testid="task-list" id="list"></ul></main>
8
+ <script>
9
+ const tasks=[];let activeOnly=false;
10
+ const list=document.querySelector('#list');const error=document.querySelector('#error');
11
+ function render(){list.replaceChildren();tasks.filter(t=>!activeOnly||!t.done).forEach(t=>{const li=document.createElement('li');const text=document.createElement('span');text.textContent=t.title;li.append(text);if(!t.done){const button=document.createElement('button');button.textContent='Complete';button.onclick=()=>{t.done=true;render()};li.append(button)}list.append(li)})}
12
+ document.querySelector('#form').onsubmit=e=>{e.preventDefault();const input=document.querySelector('#title');const title=input.value.trim();if(!title){error.textContent='Enter a task title.';error.hidden=false;return}error.hidden=true;tasks.push({title,done:false});input.value='';render()};
13
+ document.querySelector('#all').onclick=()=>{activeOnly=false;render()};document.querySelector('#active').onclick=()=>{activeOnly=true;render()};
14
+ </script></html>`;
15
+ export async function startDemo(port = 4178) {
16
+ const server = createServer((_request, response) => {
17
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
18
+ response.end(html);
19
+ });
20
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
21
+ const address = server.address();
22
+ if (!address || typeof address === 'string')
23
+ throw new Error('No listening address');
24
+ return { server, url: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())) };
25
+ }
26
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
27
+ const demo = await startDemo(Number(process.env.PORT ?? 4178));
28
+ console.log(`Flow QA demo: ${demo.url}`);
29
+ process.once('SIGINT', () => { void demo.close(); });
30
+ process.once('SIGTERM', () => { void demo.close(); });
31
+ }
@@ -0,0 +1,285 @@
1
+ import { constants } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile, writeFile, mkdir, access, stat } from 'node:fs/promises';
4
+ import { resolve, dirname, basename } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseArgs } from 'node:util';
7
+ import { chromium } from 'playwright';
8
+ import { localeFromEnv, localized, localizeProductMessage } from "./i18n.js";
9
+ import { configSchema, idSchema, modelSchema, targetSchema } from "./schema.js";
10
+ import { CONFIG_FILE, initWorkspace, readConfig, readSuite, sha256, verifySuiteSource, writeJSON } from "./workspace.js";
11
+ import { buildPlanningPrompt, planFlow } from "./planner.js";
12
+ import { compileSuite } from "./compiler.js";
13
+ import { runSuite } from "./engine.js";
14
+ import { renderMarkdown } from "./report.js";
15
+ const help = {
16
+ en: `Flow QA — turn user flows into repeatable tests and evidence-backed QA reports
17
+
18
+ Usage: flow-qa <command> [arguments] [options]
19
+
20
+ init [directory] Create configuration and a local example (preserve existing files)
21
+ connect <name> --url <url> Register a test target
22
+ model <planner|agent> --url <base> --model <id> [--key-env ENV_NAME]
23
+ plan <flow.md> [--out cases.json] Plan cases with a model; produce a prompt if none is configured
24
+ plan <flow.md> --prompt-only Create a prompt for your current AI tool
25
+ validate <cases.json> Check case structure and assertions
26
+ generate <cases.json> [--out test.ts] Generate e2e test code
27
+ run <cases.json> --target <name> Run against a registered target and save a QA report
28
+ report <run-directory> View a saved QA report
29
+ compare <run-a> <run-b> Compare case results
30
+ ui [--demo] [--port 4310] Start the local QA UI
31
+ browser install [--with-deps] Install this package's Playwright Chromium
32
+ doctor Check the local engine and connections
33
+
34
+ Common: --workspace <directory> (default: current directory), --lang en|ko, --help
35
+ First step: flow-qa init
36
+ Local example: flow-qa ui --demo
37
+ `,
38
+ ko: `Flow QA — 유저 플로우에서 반복 가능한 테스트와 QA 리포트까지
39
+
40
+ 사용법: flow-qa <command> [arguments] [options]
41
+
42
+ init [directory] 설정과 로컬 예제 생성 (기존 파일 보존)
43
+ connect <name> --url <url> 테스트 대상 등록
44
+ model <planner|agent> --url <base> --model <id> [--key-env ENV_NAME]
45
+ plan <flow.md> [--out cases.json] 모델로 케이스 설계; 미연결 시 실행용 프롬프트 생성
46
+ plan <flow.md> --prompt-only 현재 사용하는 AI에 전달할 프롬프트 생성
47
+ validate <cases.json> 케이스 구조와 검증 조건 확인
48
+ generate <cases.json> [--out test.ts] e2e 테스트 코드 생성
49
+ run <cases.json> --target <name> 연결한 대상에서 실행하고 QA 리포트 저장
50
+ report <run-directory> 저장된 QA 리포트 보기
51
+ compare <run-a> <run-b> 케이스별 결과 비교
52
+ ui [--demo] [--port 4310] 로컬 QA 화면 실행
53
+ browser install [--with-deps] 패키지의 Playwright Chromium 설치
54
+ doctor 로컬 엔진과 연결 설정 확인
55
+
56
+ 공통: --workspace <directory> (기본: 현재 폴더), --lang en|ko, --help
57
+ 첫 시작: flow-qa init
58
+ 로컬 예제: flow-qa ui --demo
59
+ `,
60
+ };
61
+ function required(value, label, locale) {
62
+ if (!value)
63
+ throw new Error(localized(locale, `${label} is required. See --help.`, `${label}이 필요합니다. --help를 참고하세요.`));
64
+ return value;
65
+ }
66
+ function safeText(value) { return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '); }
67
+ async function writeOutput(path, content) {
68
+ await mkdir(dirname(path), { recursive: true });
69
+ await writeFile(path, content, { flag: 'wx', mode: 0o600 });
70
+ }
71
+ export function playwrightInstallInvocation(withDeps = false) {
72
+ const packageFile = fileURLToPath(import.meta.resolve('playwright/package.json'));
73
+ return { command: process.execPath, args: [resolve(dirname(packageFile), 'cli.js'), 'install', 'chromium', ...(withDeps ? ['--with-deps'] : [])] };
74
+ }
75
+ async function installBrowser(withDeps, locale) {
76
+ const { command, args } = playwrightInstallInvocation(withDeps);
77
+ if (!(await stat(args[0])).isFile())
78
+ throw new Error(localized(locale, 'Could not find the installed Playwright CLI.', '설치된 Playwright CLI를 찾지 못했습니다.'));
79
+ await new Promise((done, reject) => {
80
+ const child = spawn(command, args, { stdio: 'inherit', shell: false });
81
+ child.once('error', reject);
82
+ child.once('close', (code, signal) => code === 0 ? done() : reject(new Error(localized(locale, `Chromium installation did not complete (exit: ${code ?? signal ?? 'unknown'}).`, `Chromium 설치가 완료되지 않았습니다 (종료: ${code ?? signal ?? 'unknown'}).`))));
83
+ });
84
+ }
85
+ async function installedVersion(name, locale) {
86
+ let directory = dirname(fileURLToPath(import.meta.resolve(name)));
87
+ for (let depth = 0; depth < 6; depth++) {
88
+ try {
89
+ const value = JSON.parse(await readFile(resolve(directory, 'package.json'), 'utf8'));
90
+ if (value.name === name && typeof value.version === 'string')
91
+ return value.version;
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'ENOENT')
95
+ throw error;
96
+ }
97
+ const parent = dirname(directory);
98
+ if (parent === directory)
99
+ break;
100
+ directory = parent;
101
+ }
102
+ throw new Error(localized(locale, `Could not find installed ${name} package information.`, `설치된 ${name} 패키지 정보를 찾지 못했습니다.`));
103
+ }
104
+ async function chromiumInstalled() {
105
+ try {
106
+ const executable = chromium.executablePath();
107
+ if (!(await stat(executable)).isFile())
108
+ return false;
109
+ await access(executable, constants.X_OK);
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ export async function main(args) {
117
+ let locale = localeFromEnv();
118
+ try {
119
+ const { positionals, values } = parseArgs({ args, allowPositionals: true, strict: true, options: {
120
+ workspace: { type: 'string' }, url: { type: 'string' }, model: { type: 'string' },
121
+ 'key-env': { type: 'string' }, out: { type: 'string' }, target: { type: 'string' },
122
+ 'prompt-only': { type: 'boolean' }, help: { type: 'boolean', short: 'h' },
123
+ demo: { type: 'boolean' }, port: { type: 'string' }, 'with-deps': { type: 'boolean' }, lang: { type: 'string' },
124
+ } });
125
+ if (values.lang !== undefined) {
126
+ if (values.lang !== 'en' && values.lang !== 'ko')
127
+ throw new Error(localized(locale, 'Unsupported language. Use en or ko.', '지원하지 않는 언어입니다. en 또는 ko를 사용하세요.'));
128
+ locale = values.lang;
129
+ }
130
+ const [command, arg, second] = positionals;
131
+ const workspace = resolve(values.workspace ?? '.');
132
+ if (!command || (values.help && command !== 'ui')) {
133
+ console.log(help[locale]);
134
+ return;
135
+ }
136
+ if (values.demo && command !== 'ui' || values.port && command !== 'ui' ||
137
+ values['with-deps'] && (command !== 'browser' || arg !== 'install'))
138
+ throw new Error(localized(locale, 'This option is not supported for the command. See --help.', '이 명령에서 사용할 수 없는 옵션입니다. --help를 참고하세요.'));
139
+ if (command === 'ui') {
140
+ if (arg || second)
141
+ throw new Error(localized(locale, 'The ui command does not accept positional arguments.', 'ui 명령에는 위치 인자를 사용할 수 없습니다.'));
142
+ const forwarded = [
143
+ ...(values.workspace ? ['--workspace', values.workspace] : []),
144
+ ...(values.port ? ['--port', values.port] : []),
145
+ ...(values.demo ? ['--demo'] : []),
146
+ ...(values.help ? ['--help'] : []),
147
+ '--lang', locale,
148
+ ];
149
+ await (await import("./ui/entry.js")).main(forwarded);
150
+ return;
151
+ }
152
+ if (command === 'browser') {
153
+ if (arg !== 'install' || second)
154
+ throw new Error(localized(locale, 'The browser command only supports install. See --help.', 'browser 명령은 install만 지원합니다. --help를 참고하세요.'));
155
+ await installBrowser(Boolean(values['with-deps']), locale);
156
+ return;
157
+ }
158
+ if (command === 'init') {
159
+ const dir = resolve(workspace, arg ?? '.');
160
+ const files = await initWorkspace(dir);
161
+ console.log(localized(locale, `Workspace: ${dir}\nCreated: ${files.join(', ') || 'none (existing files preserved)'}`, `워크스페이스: ${dir}\n생성: ${files.join(', ') || '없음 (기존 파일 보존)'}`));
162
+ return;
163
+ }
164
+ if (command === 'connect') {
165
+ const config = await readConfig(workspace);
166
+ const name = idSchema.parse(required(arg, localized(locale, 'Target name', '대상 이름'), locale));
167
+ config.targets[name] = targetSchema.parse({ url: required(values.url, '--url', locale) });
168
+ await writeJSON(resolve(workspace, CONFIG_FILE), config);
169
+ console.log(localized(locale, `Target ${name} connected: ${config.targets[name].url}`, `대상 ${name} 연결: ${config.targets[name].url}`));
170
+ return;
171
+ }
172
+ if (command === 'model') {
173
+ if (arg !== 'planner' && arg !== 'agent')
174
+ throw new Error(localized(locale, 'Model role must be planner or agent.', '모델 종류는 planner 또는 agent여야 합니다.'));
175
+ const config = await readConfig(workspace);
176
+ const base = { baseURL: required(values.url, '--url', locale), model: required(values.model, '--model', locale), ...(values['key-env'] ? { apiKeyEnv: values['key-env'] } : {}) };
177
+ config[arg] = modelSchema.parse(base);
178
+ await writeJSON(resolve(workspace, CONFIG_FILE), config);
179
+ console.log(localized(locale, `${arg} model connection saved. No model call has been made. API key values are not saved in the configuration file.`, `${arg} 모델 연결 설정 저장. 실제 호출 전입니다. API 키 값은 설정 파일에 저장하지 않습니다.`));
180
+ return;
181
+ }
182
+ if (command === 'plan') {
183
+ const flowPath = resolve(workspace, required(arg, localized(locale, 'Flow file', '플로우 파일'), locale));
184
+ const flow = await readFile(flowPath, 'utf8');
185
+ if (Buffer.byteLength(flow) > 128_000)
186
+ throw new Error(localized(locale, 'The flow file must be 128 KB or smaller.', '플로우 파일은 128 KB 이하여야 합니다.'));
187
+ const config = await readConfig(workspace);
188
+ const stem = basename(flowPath).replace(/\.[^.]+$/, '');
189
+ if (values['prompt-only'] || !config.planner) {
190
+ const output = resolve(workspace, values.out ?? `planning/${stem}.prompt.md`);
191
+ await writeOutput(output, buildPlanningPrompt(flow));
192
+ console.log(localized(locale, `Planning prompt: ${output}\nSend it to your AI tool, save the returned JSON under cases/, then run validate → generate → run.`, `설계 프롬프트: ${output}\n연결된 AI에 전달하고 응답 JSON을 cases/에 저장한 뒤 validate → generate → run을 실행하세요.`));
193
+ return;
194
+ }
195
+ const output = resolve(workspace, values.out ?? `cases/${stem}.json`);
196
+ try {
197
+ await access(output);
198
+ throw new Error(localized(locale, 'The output file already exists. Use --out to choose a new path.', '출력 파일이 이미 있습니다. --out으로 새 경로를 지정하세요.'));
199
+ }
200
+ catch (error) {
201
+ if (error.code !== 'ENOENT')
202
+ throw error;
203
+ }
204
+ const suite = await planFlow(flow, config.planner);
205
+ suite.source = { file: arg, sha256: sha256(flow) };
206
+ await writeJSON(output, suite, true);
207
+ console.log(localized(locale, `Cases created: ${output}\n${suite.cases.filter((c) => c.questions.length).length} of ${suite.cases.length} cases have open questions.`, `케이스 생성: ${output}\n${suite.cases.length}개 중 확인 질문이 있는 케이스 ${suite.cases.filter((c) => c.questions.length).length}개`));
208
+ return;
209
+ }
210
+ if (command === 'validate' || command === 'generate' || command === 'run') {
211
+ const suitePath = resolve(workspace, required(arg, localized(locale, 'Case file', '케이스 파일'), locale));
212
+ const suite = await readSuite(suitePath);
213
+ await verifySuiteSource(suite, workspace);
214
+ if (command === 'validate') {
215
+ console.log(localized(locale, `${suite.id}: ${suite.cases.length} valid cases. Blocked by questions: ${suite.cases.filter((c) => c.questions.length).length}`, `${suite.id}: ${suite.cases.length}개 케이스 유효. 질문으로 차단된 케이스: ${suite.cases.filter((c) => c.questions.length).length}`));
216
+ return;
217
+ }
218
+ if (command === 'generate') {
219
+ const output = resolve(workspace, values.out ?? `generated/${suite.id}.e2e.ts`);
220
+ await writeOutput(output, compileSuite(suite));
221
+ console.log(localized(locale, `Test script: ${output}`, `실행 스크립트: ${output}`));
222
+ return;
223
+ }
224
+ const config = await readConfig(workspace);
225
+ const targetName = values.target ?? (Object.keys(config.targets).length === 1 ? Object.keys(config.targets)[0] : undefined);
226
+ const controller = new AbortController();
227
+ const interrupt = () => controller.abort();
228
+ process.once('SIGINT', interrupt);
229
+ process.once('SIGTERM', interrupt);
230
+ try {
231
+ const { report, runDir } = await runSuite({ workspace, suite, config, targetName: required(targetName, '--target', locale), signal: controller.signal });
232
+ console.log(localized(locale, `Run result: ${report.status}\nQA report: ${resolve(runDir, 'qa-report.md')}\nRaw result: ${resolve(runDir, 'qa-report.json')}`, `실행 결과: ${report.status}\nQA 리포트: ${resolve(runDir, 'qa-report.md')}\n원본 결과: ${resolve(runDir, 'qa-report.json')}`));
233
+ process.exitCode = report.status === 'passed' ? 0 : report.status === 'interrupted' ? 130 : 1;
234
+ }
235
+ finally {
236
+ process.off('SIGINT', interrupt);
237
+ process.off('SIGTERM', interrupt);
238
+ }
239
+ return;
240
+ }
241
+ if (command === 'report') {
242
+ const runDirectory = resolve(workspace, required(arg, localized(locale, 'Run directory', '실행 폴더'), locale));
243
+ let content = await readFile(resolve(runDirectory, 'qa-report.md'), 'utf8');
244
+ if (locale === 'ko') {
245
+ try {
246
+ content = renderMarkdown(JSON.parse(await readFile(resolve(runDirectory, 'qa-report.json'), 'utf8')), locale);
247
+ }
248
+ catch { /* Preserve the saved report if an older or malformed JSON report cannot be rendered. */ }
249
+ }
250
+ console.log(content.replace(/\u001b/g, ''));
251
+ return;
252
+ }
253
+ if (command === 'compare') {
254
+ const load = async (dir) => {
255
+ const data = JSON.parse(await readFile(resolve(workspace, required(dir, localized(locale, 'Run directory', '실행 폴더'), locale), 'qa-report.json'), 'utf8'));
256
+ if (!Array.isArray(data.cases) || typeof data.suiteHash !== 'string' || typeof data.suiteId !== 'string')
257
+ throw new Error(localized(locale, 'This is not a valid QA report.', '유효한 QA 리포트가 아닙니다.'));
258
+ return data;
259
+ };
260
+ const before = await load(arg), after = await load(second);
261
+ const comparable = before.suiteHash === after.suiteHash && before.target.url === after.target.url;
262
+ console.log(comparable ? localized(locale, 'Comparing the same suite and target (check external data state separately)', '동일 명세·대상 비교 (외부 데이터 상태는 별도 확인)') : localized(locale, 'Suite or target differs; no direct regression verdict is made.', '명세 또는 대상이 달라 직접적인 회귀 판정은 하지 않습니다.'));
263
+ const previous = new Map(before.cases.map((c) => [c.id, c.status]));
264
+ const current = new Map(after.cases.map((c) => [c.id, c.status]));
265
+ for (const id of new Set([...previous.keys(), ...current.keys()]))
266
+ console.log(`${safeText(id)}: ${safeText(previous.get(id) ?? localized(locale, 'not included', '미포함'))} → ${safeText(current.get(id) ?? localized(locale, 'not included', '미포함'))}`);
267
+ return;
268
+ }
269
+ if (command === 'doctor') {
270
+ const config = configSchema.parse(await readConfig(workspace));
271
+ for (const pkg of ['e2e', '@e2e-dev/web', 'playwright']) {
272
+ console.log(`${pkg}: ${await installedVersion(pkg, locale)}`);
273
+ }
274
+ const hasChromium = await chromiumInstalled();
275
+ console.log(localized(locale, `Node: ${process.version}\nChromium: ${hasChromium ? 'installed' : 'missing (flow-qa browser install)'}\nTargets: ${Object.keys(config.targets).join(', ') || 'none'}\nPlanner model: ${config.planner ? 'configured' : 'not configured (prompt handoff available)'}\nAgent model: ${config.agent ? 'configured' : 'not configured (deterministic tests available)'}`, `Node: ${process.version}\nChromium: ${hasChromium ? '설치됨' : '미설치 (flow-qa browser install)'}\n대상: ${Object.keys(config.targets).join(', ') || '없음'}\n설계 모델: ${config.planner ? '연결 설정됨' : '미연결 (프롬프트 전달 가능)'}\n실행 모델: ${config.agent ? '연결 설정됨' : '미연결 (고정 조작 테스트 가능)'}`));
276
+ return;
277
+ }
278
+ throw new Error(localized(locale, `Unknown command: ${command}`, `알 수 없는 명령: ${command}`));
279
+ }
280
+ catch (error) {
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ console.error(`Flow QA: ${safeText(localizeProductMessage(message, locale))}`);
283
+ process.exitCode = 2;
284
+ }
285
+ }
@@ -0,0 +1,62 @@
1
+ import { suiteSchema } from "./schema.js";
2
+ const literal = (value) => JSON.stringify(value);
3
+ function locatorCode(locator) {
4
+ if (locator.by === 'role') {
5
+ return locator.name === undefined
6
+ ? `screen.getByRole(${literal(locator.value)})`
7
+ : `screen.getByRole(${literal(locator.value)}, ${literal(locator.name)})`;
8
+ }
9
+ const method = { text: 'getByText', label: 'getByLabel', testId: 'getByTestId' }[locator.by];
10
+ return `screen.${method}(${literal(locator.value)})`;
11
+ }
12
+ function stepCode(step) {
13
+ switch (step.type) {
14
+ case 'open': return `await app.open(${literal(step.path)});`;
15
+ case 'act': return `await agent.act(${literal(step.instruction)});`;
16
+ case 'click': return `await ${locatorCode(step.locator)}.tap();`;
17
+ case 'fill': return `await ${locatorCode(step.locator)}.fill(${literal(step.value)});`;
18
+ case 'assertVisible': return `await expect(${locatorCode(step.locator)})${step.visible ? '' : '.not'}.toBeVisible();`;
19
+ case 'assertText': return `await expect(${locatorCode(step.locator)}).${step.match === 'contains' ? 'toContainText' : 'toHaveText'}(${literal(step.expected)});`;
20
+ case 'assertUrl': return `await expect(browser).toHaveURL(${literal(step.expected)});`;
21
+ case 'assertAgent': return `await agent.assert(${literal(step.instruction)});`;
22
+ }
23
+ }
24
+ export function expectedChecks(testCase) {
25
+ return testCase.steps.flatMap((step) => {
26
+ switch (step.type) {
27
+ case 'assertVisible': return [`${step.visible ? 'Visible' : 'Hidden'}: ${JSON.stringify(step.locator)}`];
28
+ case 'assertText': return [`Text ${step.match}: ${JSON.stringify(step.locator)} → ${step.expected}`];
29
+ case 'assertUrl': return [`URL: ${step.expected}`];
30
+ case 'assertAgent': return [`Agent assertion: ${step.instruction}`];
31
+ default: return [];
32
+ }
33
+ });
34
+ }
35
+ export function needsAgent(suite) {
36
+ return suite.cases.some((testCase) => !testCase.questions.length && testCase.steps.some((step) => step.type === 'act' || step.type === 'assertAgent'));
37
+ }
38
+ export function compileSuite(suite) {
39
+ const parsed = suiteSchema.parse(suite);
40
+ const lines = ["import { test } from '@e2e-dev/web';", "import { expect } from 'e2e';", ''];
41
+ for (const testCase of parsed.cases) {
42
+ const title = literal(`[${testCase.id}] ${testCase.title}`);
43
+ if (testCase.questions.length) {
44
+ lines.push(`test.skip(${title}, async () => {});`);
45
+ continue;
46
+ }
47
+ const fixtures = ['app'];
48
+ if (testCase.steps.some((step) => 'locator' in step))
49
+ fixtures.push('screen');
50
+ if (testCase.steps.some((step) => step.type === 'assertUrl'))
51
+ fixtures.push('browser');
52
+ if (testCase.steps.some((step) => step.type === 'act' || step.type === 'assertAgent'))
53
+ fixtures.push('agent');
54
+ lines.push(`test(${title}, async ({ ${fixtures.join(', ')} }) => {`);
55
+ if (testCase.steps[0]?.type !== 'open')
56
+ lines.push(" await app.open('/');");
57
+ for (const step of testCase.steps)
58
+ lines.push(` ${stepCode(step)}`);
59
+ lines.push('});');
60
+ }
61
+ return `${lines.join('\n')}\n`;
62
+ }