From b80ac32d1e6bca142dca904e48cb0547c5d399a9 Mon Sep 17 00:00:00 2001 From: MacMini Date: Wed, 1 Jul 2026 02:48:25 +0900 Subject: [PATCH 01/23] docs: add service readiness roadmap --- .../2026-07-01-service-readiness-roadmap.md | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-service-readiness-roadmap.md diff --git a/docs/superpowers/plans/2026-07-01-service-readiness-roadmap.md b/docs/superpowers/plans/2026-07-01-service-readiness-roadmap.md new file mode 100644 index 0000000..1877e1a --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-service-readiness-roadmap.md @@ -0,0 +1,485 @@ +# App X-Ray Service Readiness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Raise App X-Ray from a complete local-first MVP to a service-quality product while preserving the confirmed-data export contract. + +**Architecture:** Keep the browser app local-first and deterministic by default. Add production readiness in narrow layers: reliable project lifecycle, source ingestion, BYOK AI adapters, review ergonomics, export quality, offline safety, and release QA. Do not introduce hosted accounts, billing, marketplace flows, or server persistence in this roadmap. + +**Tech Stack:** React 19, Vite 8, TypeScript 5.5, Node test runner, jsdom, Playwright, browser `localStorage`, deterministic pure domain/export functions. + +--- + +## Current Baseline + +The current repository already has: + +- Local project storage and migration in `src/storage/project-repository.ts`. +- Mock AI analysis and provider settings in `src/ai/adapter.ts` and `src/ai/settings.ts`. +- Suggested/accepted/edited/rejected/deferred lifecycle rules in `src/domain/status.ts` and `src/domain/lifecycle.ts`. +- Workspace validation in `src/domain/validation.ts`. +- Markdown, Mermaid, JSON, GitHub issue markdown, build prompt, and bundle exports in `src/export/`. +- Source document import for `.md`, `.markdown`, and `.txt` in `src/domain/source-import.ts`. +- Backup import/export in `src/storage/workspace-backup.ts`. +- 38 domain tests in `test/domain.test.mjs`. + +Verification baseline before starting any task: + +```bash +npm run typecheck +npm test +npm run build +``` + +Expected result: all three commands pass. + +--- + +## Service-Level Definition + +App X-Ray is service-ready when a non-developer can repeatedly: + +1. Create or import a project from real notes. +2. Run AI-assisted extraction using either mock mode or their own API key. +3. Review, edit, merge, reject, and defer suggestions without losing confirmed work. +4. Understand validation blockers and fix them on screen. +5. Export deterministic artifacts that are useful in Codex, Cursor, Lovable, Replit, Bolt, and GitHub issue workflows. +6. Close/reopen the browser and recover the same workspace. +7. Import/export a portable backup safely. +8. Use the app without hidden backend, account, billing, or cloud assumptions. + +--- + +## Implementation Slices + +### Task 1: Product Documentation and Release Surface + +**Purpose:** Make the shipped product understandable without reading internal rule files. + +**Files:** +- Modify: `README.md` +- Create: `docs/product/service-readiness.md` +- Create: `docs/product/local-first-data-contract.md` +- Modify: `app-xray-codex-rules/README_CONCEPT.md` + +- [ ] Write `README.md` with the product purpose, local-first boundary, supported imports, supported exports, and development commands. +- [ ] Write `docs/product/service-readiness.md` with the service-level definition, supported workflows, non-goals, and release checklist. +- [ ] Write `docs/product/local-first-data-contract.md` documenting `localStorage` keys, backup format, confirmed-only export rule, and AI key handling. +- [ ] Link those docs from `app-xray-codex-rules/README_CONCEPT.md` without changing the product boundary. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `docs: document App X-Ray service readiness`. + +Acceptance criteria: + +- A new developer can run the app and understand what is and is not in scope from `README.md`. +- The docs explicitly state that default exports include only `accepted` and `edited` objects. +- The docs explicitly state that API keys stay in browser-local storage and are not included in exports or prompts. + +--- + +### Task 2: Real Project Lifecycle and Empty-State Quality + +**Purpose:** Replace demo-first feel with durable project workflows. + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Modify: `src/storage/project-repository.ts` +- Modify: `src/domain/routes.ts` +- Test: `test/domain.test.mjs` +- Create: `test/ui-project-lifecycle.test.mjs` + +- [ ] Add domain tests for creating a project with user-provided name and source text. +- [ ] Add repository tests for duplicate project names, empty collection, corrupt collection, and project deletion. +- [ ] Add UI tests with jsdom for the empty project list, new project form validation, successful creation, project switching, and deletion confirmation state. +- [ ] Refine `src/App.tsx` so `/projects`, `/projects/new`, and `/projects/:projectId/review` each have explicit loading, empty, invalid input, success, and error states. +- [ ] Add non-blocking save status copy when local persistence succeeds or fails. +- [ ] Ensure deleting a project never deletes another project and always leaves a valid route. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: harden local project lifecycle`. + +Acceptance criteria: + +- A user can create, switch, rename, and delete local projects without route drift. +- Empty states explain the next action in Korean without technical metadata. +- Corrupt local data opens a visible recovery state instead of failing silently. + +--- + +### Task 3: Source Ingestion Upgrade + +**Purpose:** Support more realistic input while preserving source version history. + +**Files:** +- Modify: `src/domain/source-import.ts` +- Modify: `src/domain/source-documents.ts` +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Test: `test/domain.test.mjs` +- Create: `test/ui-source-import.test.mjs` + +- [ ] Add tests for `.csv`, `.json`, and pasted text classification. +- [ ] Add tests proving identical content does not create duplicate source versions. +- [ ] Add tests proving unsupported binary files return a user-facing Korean error. +- [ ] Implement `.csv` import as plain structured source text with detected headers. +- [ ] Implement `.json` import as pretty-printed source text with malformed JSON errors. +- [ ] Add paste/import UI affordances that show source type, version count, last imported time, and unsupported-file errors. +- [ ] Keep PDF as explicit unsupported future scope unless a PDF parser dependency is approved in a separate task. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: expand local source import formats`. + +Acceptance criteria: + +- Markdown, text, CSV, JSON, and pasted text produce source documents. +- Malformed JSON and unsupported files are distinguishable in the UI. +- Source version history remains append-only except for exact duplicate content. + +--- + +### Task 4: BYOK AI Provider Adapters + +**Purpose:** Add real AI analysis behind adapters without making the app dependent on a hosted backend. + +**Files:** +- Modify: `src/ai/adapter.ts` +- Modify: `src/ai/settings.ts` +- Create: `src/ai/provider-registry.ts` +- Create: `src/ai/structured-prompt.ts` +- Create: `src/ai/http-provider.ts` +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Test: `test/domain.test.mjs` +- Create: `test/ai-provider.test.mjs` + +- [ ] Refactor `AiProviderAdapter.analyze` to return `Promise`. +- [ ] Add tests proving mock adapter remains deterministic after async conversion. +- [ ] Add `structured-prompt.ts` that builds a provider-neutral extraction prompt from the latest source document and the `AI_ANALYSIS_SCHEMA.md` contract. +- [ ] Add tests proving the prompt includes excluded scopes and asks for structured JSON only. +- [ ] Add `provider-registry.ts` with `mock`, `openai`, `anthropic`, `gemini`, and `openrouter` provider metadata. +- [ ] Add `http-provider.ts` with provider-specific request/response normalization for BYOK browser calls. +- [ ] Add tests with mocked `fetch` for success, invalid JSON, provider error, missing API key, and timeout. +- [ ] Update `App.tsx` so analysis has visible idle, running, success, validation-failed, and provider-error states. +- [ ] Keep API keys in local browser storage only. Never include the raw key in logs, exports, prompts, backups, or public config. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: add BYOK AI provider adapters`. + +Acceptance criteria: + +- Mock mode still works offline. +- Real providers are optional and selected by the user. +- Provider output must pass `validateAiAnalysisResult` before it can touch workspace state. +- Provider failures do not overwrite confirmed or edited objects. + +Risk gate: + +- Browser-side BYOK calls can expose API keys to the browser runtime by design. The UI and docs must state this plainly. +- If CORS blocks a provider, the UI must show that local browser calls are blocked and suggest mock mode or a future local desktop bridge. + +--- + +### Task 5: Review Workbench Upgrade + +**Purpose:** Make suggestion review efficient enough for real projects. + +**Files:** +- Modify: `src/components/ReviewPanel.tsx` +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Modify: `src/domain/lifecycle.ts` +- Modify: `src/domain/diff.ts` +- Test: `test/domain.test.mjs` +- Create: `test/ui-review-workbench.test.mjs` + +- [ ] Add domain tests for bulk accept by bucket, bulk reject by bucket, and preserving edited objects during rerun merge. +- [ ] Add domain tests for undoing the most recent status decision within the current session. +- [ ] Add review UI tests for filter by bucket, filter by status, search by name/description, bulk action, inline edit, and undo. +- [ ] Split review rendering into focused subcomponents only if `ReviewPanel.tsx` becomes hard to reason about during implementation. +- [ ] Add visible counts for all statuses per bucket. +- [ ] Add keyboard-safe controls with labels for accept, edit, reject, defer, and undo. +- [ ] Add a merge-impact panel after rerun showing added, refreshed, preserved, and status-changed suggestions. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: improve suggestion review workbench`. + +Acceptance criteria: + +- A user can process a large suggestion set without one-item-at-a-time friction. +- Confirmed and edited objects survive AI reruns. +- Rejected and deferred items remain visible for audit but stay out of default exports. + +--- + +### Task 6: Validation Repair Guidance + +**Purpose:** Turn validation from a passive export blocker into actionable repair guidance. + +**Files:** +- Modify: `src/domain/validation.ts` +- Create: `src/domain/validation-actions.ts` +- Modify: `src/components/ExportPanel.tsx` +- Modify: `src/components/ReviewPanel.tsx` +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Test: `test/domain.test.mjs` +- Create: `test/ui-validation-actions.test.mjs` + +- [ ] Extend validation issues with `targetBucket`, `targetId`, and `suggestedAction`. +- [ ] Add tests for broken relation, duplicate name, orphan field, empty confirmed object, and non-confirmed export contamination. +- [ ] Implement `validation-actions.ts` with pure helpers that map a validation issue to a review route and repair action label. +- [ ] Add an export-panel validation list where each blocking item can jump to the relevant review object. +- [ ] Add review-side validation badges on affected objects. +- [ ] Add safe repair actions only where deterministic: remove broken relation, mark duplicate as deferred, or exclude issue from prompt. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: add actionable validation repair guidance`. + +Acceptance criteria: + +- Export blockers explain what is wrong, where it is, and the safest next action. +- Deterministic repair actions never delete confirmed user edits without an explicit click. +- Warnings remain non-blocking. + +--- + +### Task 7: Export Quality and Delivery + +**Purpose:** Make exports production-useful, inspectable, and repeatable. + +**Files:** +- Modify: `src/export/export-content.ts` +- Modify: `src/export/markdown.ts` +- Modify: `src/export/json.ts` +- Modify: `src/export/mermaid.ts` +- Modify: `src/export/github-issues.ts` +- Create: `src/export/csv.ts` +- Modify: `src/components/ExportPanel.tsx` +- Modify: `src/App.css` +- Test: `test/domain.test.mjs` +- Create: `test/ui-export-panel.test.mjs` + +- [ ] Add tests proving each export has stable ordering, stable filenames, Korean text preservation, and empty-state content. +- [ ] Add CSV export for issues and data objects using UTF-8 text and deterministic headers. +- [ ] Add copy-to-clipboard result states for preview content. +- [ ] Add per-format export descriptions that explain what each artifact is for. +- [ ] Add bundle manifest metadata: app version, export mode, generated timestamp, validation summary, file list. +- [ ] Keep canonical export mode as confirmed-only. +- [ ] Keep audit-trail mode explicit and visibly labeled. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: improve deterministic export delivery`. + +Acceptance criteria: + +- Export previews are understandable before download. +- CSV opens cleanly in common spreadsheet tools with Korean text preserved. +- Same workspace state produces the same deterministic content except explicit generated timestamp metadata in bundle manifest. + +--- + +### Task 8: Backup, Recovery, and Local Data Safety + +**Purpose:** Reduce data-loss risk for serious use. + +**Files:** +- Modify: `src/storage/workspace-backup.ts` +- Modify: `src/storage/project-repository.ts` +- Create: `src/storage/autosave-snapshots.ts` +- Modify: `src/App.tsx` +- Modify: `src/App.css` +- Test: `test/domain.test.mjs` +- Create: `test/storage-recovery.test.mjs` + +- [ ] Add snapshot tests for creating, listing, restoring, and pruning local autosave snapshots. +- [ ] Add backup tests for schema version mismatch, missing required fields, malformed JSON, and import overwrite confirmation. +- [ ] Implement local autosave snapshots per project with bounded retention. +- [ ] Add restore UI that shows snapshot time, project name, and validation status before applying. +- [ ] Add import UI that distinguishes merge, replace, and cancel. +- [ ] Never overwrite the current workspace from backup without a visible confirmation step. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `feat: add local recovery snapshots`. + +Acceptance criteria: + +- A bad import cannot silently overwrite current work. +- Users can recover a recent local snapshot after accidental edits. +- Snapshot retention is bounded so localStorage usage does not grow without limit. + +--- + +### Task 9: Browser QA and Regression Harness + +**Purpose:** Add realistic end-to-end verification before broader product work. + +**Files:** +- Modify: `package.json` +- Create: `playwright.config.ts` +- Create: `test/e2e/app-xray.spec.ts` +- Create: `test/e2e/fixtures.ts` +- Create: `docs/product/manual-qa-checklist.md` + +- [ ] Add `test:e2e` script that runs Playwright against the Vite dev server. +- [ ] Add an E2E test for create project → import source → run mock analysis → accept/edit/reject → export confirmed-only markdown. +- [ ] Add an E2E test for reload persistence and project switching. +- [ ] Add an E2E test for validation blocking export download. +- [ ] Add an E2E test for backup download and backup import. +- [ ] Add `docs/product/manual-qa-checklist.md` with exact browser checks for desktop and mobile widths. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Run `npm run test:e2e`. +- [ ] Commit with `test: add App X-Ray browser regression coverage`. + +Acceptance criteria: + +- Core user flow is verified in a real browser. +- Reload persistence is covered. +- Export validation blocking behavior is covered. +- Manual QA checklist matches the app's actual routes and controls. + +--- + +### Task 10: Desktop Packaging Feasibility Check + +**Purpose:** Decide whether local-first service quality should ship as browser-only or desktop app next. + +**Files:** +- Create: `docs/product/desktop-packaging-decision.md` +- Modify: `README.md` +- Optional Create: `scripts/check-release.mjs` + +- [ ] Document browser-only release constraints: localStorage capacity, browser BYOK CORS, file access limits, no native encrypted keychain. +- [ ] Document desktop release benefits: native file storage, local encrypted settings, local model bridge option, safer backup location. +- [ ] Compare Electron, Tauri, and browser-only PWA against maintenance cost. +- [ ] If a release script is added, make it run `npm run typecheck`, `npm test`, and `npm run build` in sequence and report failures clearly. +- [ ] Do not add Electron or Tauri in this task. +- [ ] Run `npm run typecheck`. +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Commit with `docs: evaluate App X-Ray desktop packaging`. + +Acceptance criteria: + +- The next platform decision is explicit and reviewable. +- No packaging dependency is introduced before the decision is accepted. +- Browser-only service limits are documented honestly. + +--- + +## Recommended Execution Order + +1. Task 1: Documentation and release surface. +2. Task 9: Browser QA harness, so later work has regression coverage. +3. Task 2: Project lifecycle hardening. +4. Task 3: Source ingestion upgrade. +5. Task 5: Review workbench upgrade. +6. Task 6: Validation repair guidance. +7. Task 7: Export quality and delivery. +8. Task 8: Backup and recovery. +9. Task 4: BYOK AI provider adapters. +10. Task 10: Desktop packaging decision. + +Reasoning: + +- Documentation and E2E coverage make the product boundary explicit before behavior grows. +- Project/source/review/validation/export/recovery improve deterministic product quality without external-service risk. +- Real AI adapters come after the local deterministic loop is strong enough to absorb provider failures safely. +- Desktop packaging should be decided after browser limits are visible through actual usage and tests. + +--- + +## Verification Matrix + +Every implementation task must run: + +```bash +npm run typecheck +npm test +npm run build +``` + +Tasks with browser-visible behavior must also run: + +```bash +npm run test:e2e +``` + +Manual QA after Task 9 and later UI tasks: + +- Desktop viewport: project creation, source import, mock analysis, review, export. +- Mobile-width viewport: navigation, review controls, export preview, validation list. +- Reload persistence: refresh after edits and confirm state survives. +- Empty state: no project and no confirmed objects. +- Error state: malformed import and invalid AI provider result. + +--- + +## Risk Areas + +- DB schema changed: No. This roadmap stays browser-local unless a future task explicitly approves native or hosted persistence. +- API contract changed: Yes for Task 4 only, because `AiProviderAdapter.analyze` becomes async and provider HTTP contracts are introduced. +- Auth/permission changed: No. No user accounts or hosted auth are part of this roadmap. +- UI layout changed: Yes for Tasks 2, 3, 5, 6, 7, and 8. +- State management changed: Yes for Tasks 2, 5, and 8. +- Data import/export changed: Yes for Tasks 3, 7, and 8. +- Build/deployment config changed: Yes for Task 9 if Playwright config and scripts are added. +- Test coverage changed: Yes across all implementation tasks. + +--- + +## Explicit Non-Goals + +- Hosted SaaS backend. +- Login, organizations, billing, marketplace, template sales, or team collaboration. +- GitHub write integration. +- Notion, Linear, Jira, Supabase, or Figma write/sync integration. +- PDF parsing without a separate dependency and privacy review. +- Replacing deterministic exports with AI-generated export text. + +--- + +## Stop Conditions + +Stop and ask for approval before: + +- Adding a backend service. +- Adding Electron, Tauri, or any desktop packaging dependency. +- Adding a PDF parsing dependency. +- Persisting API keys anywhere outside browser-local settings. +- Introducing hosted telemetry or analytics. +- Changing the confirmed-only default export rule. + +--- + +## Plan Self-Review + +Spec coverage: + +- Local-first service readiness is covered by Tasks 1, 2, 8, and 10. +- Real user ingestion is covered by Task 3. +- BYOK AI provider support is covered by Task 4. +- Review and confirmed-data integrity are covered by Task 5. +- Validation and export quality are covered by Tasks 6 and 7. +- Verification depth is covered by Task 9. + +Placeholder scan: + +- No implementation step depends on an unspecified future module except where the task creates that module first. +- PDF support is explicitly excluded from this roadmap until a separate dependency review is approved. + +Type consistency: + +- Existing names are preserved: `ProjectWorkspace`, `AiAnalysisResult`, `AiProviderConfig`, `ExportType`, `ExportMode`, `validateWorkspace`, and `validateAiAnalysisResult`. +- New proposed modules have single responsibilities and do not replace existing deterministic domain/export modules. From 6e3736cc52b379ab7100e2e13208af670e4e4402 Mon Sep 17 00:00:00 2001 From: MacMini Date: Wed, 1 Jul 2026 02:48:30 +0900 Subject: [PATCH 02/23] docs: document App X-Ray service readiness --- README.md | 101 +++++++++++++++++- app-xray-codex-rules/README_CONCEPT.md | 5 + docs/product/local-first-data-contract.md | 123 ++++++++++++++++++++++ docs/product/service-readiness.md | 89 ++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 docs/product/local-first-data-contract.md create mode 100644 docs/product/service-readiness.md diff --git a/README.md b/README.md index 88f1530..94e9cc0 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ -# appXray \ No newline at end of file +# App X-Ray + +> See your app before AI builds it. + +App X-Ray는 비개발자 vibe coder가 앱 아이디어, PRD, 업무 메모를 AI 코딩 도구에 넘기기 전에 구조화된 앱 지도로 정리하도록 돕는 local-first 오픈소스 도구입니다. + +App X-Ray는 코드를 직접 생성하거나 대상 저장소를 수정하지 않습니다. 사용자가 확인한 구조를 바탕으로 App Map, Data Map, 빠진 것, flow 기반 빌드 프롬프트, Markdown/Mermaid/JSON export를 만듭니다. + +## Why This Exists + +도메인 지식이 충분해도 화면, 데이터, 권한, 사용 흐름을 소프트웨어 구조로 바꾸는 일은 어렵습니다. App X-Ray는 AI가 만들기 전에 사용자가 무엇을 만들려는지 먼저 볼 수 있게 해, 불명확한 프롬프트가 불명확한 앱으로 이어지는 문제를 줄입니다. + +## Product Boundary + +App X-Ray는 local-first 제품입니다. + +- 프로젝트 데이터는 브라우저 `localStorage`와 사용자가 내려받는 workspace backup에 저장됩니다. +- 숨겨진 SaaS backend, hosted workspace, login, billing, marketplace, token resale은 없습니다. +- AI output은 초안입니다. 사용자가 `accepted` 또는 `edited`로 확인한 구조만 기본 export에 포함됩니다. +- AI API key는 브라우저 로컬 설정에만 저장되며 export, prompt, workspace backup에 포함되면 안 됩니다. +- Codex, Cursor, Lovable, Replit, Bolt를 대체하지 않고 그 전에 구조를 정리합니다. + +자세한 서비스 기준은 [docs/product/service-readiness.md](docs/product/service-readiness.md)를 보세요. 저장소와 export 계약은 [docs/product/local-first-data-contract.md](docs/product/local-first-data-contract.md)를 보세요. + +## Supported Imports + +현재 지원하는 입력은 다음과 같습니다. + +| 입력 | 지원 상태 | 설명 | +|---|---:|---| +| 직접 붙여넣은 텍스트 | 지원 | PRD, 아이디어, 업무 메모를 source text로 저장합니다. | +| `.md` | 지원 | Markdown 원문으로 가져옵니다. | +| `.markdown` | 지원 | Markdown 원문으로 가져옵니다. | +| `.txt` | 지원 | 일반 텍스트 원문으로 가져옵니다. | +| `.pdf` | 미지원 | PDF parsing은 별도 task에서 dependency 승인 후 다룹니다. | +| `.csv`, `.json` | 미지원 | service-readiness roadmap의 이후 source ingestion task 범위입니다. | + +## Supported Exports + +기본 export는 `accepted`와 `edited` 상태의 confirmed data만 사용합니다. `suggested`, `rejected`, `deferred`는 audit trail 모드에서만 포함할 수 있습니다. + +| Export | 파일 예시 | 용도 | +|---|---|---| +| Markdown | `app-xray-project.md` | 사람이 읽는 앱 구조 문서 | +| App Map Mermaid | `app-xray-project-app-map.mmd` | 화면 관계 다이어그램 | +| Data Map Mermaid | `app-xray-project-data-map.mmd` | 데이터 관계 다이어그램 | +| JSON | `app-xray-project.json` | confirmed structured data | +| Codex Prompt | `app-xray-project-codex.md` | Codex에 전달할 빌드 프롬프트 | +| Cursor Prompt | `app-xray-project-cursor.md` | Cursor에 전달할 빌드 프롬프트 | +| GitHub Issues Markdown | `app-xray-project-github-issues.md` | 구현 issue 초안 | +| Bundle JSON | `app-xray-project-bundle.json` | 위 export들을 하나로 묶은 bundle | +| Workspace Backup | `app-xray-workspace-project.json` | local workspace 이동/복구용 backup | + +## Development + +**Prerequisites**: Node.js 20+ 권장, npm + +```bash +npm install +npm run dev +``` + +기본 dev server는 `127.0.0.1`에서 실행됩니다. + +품질 확인: + +```bash +npm run typecheck +npm test +npm run build +``` + +개발 중 생성되는 주요 출력: + +- `dist/`: TypeScript compile output +- `app-dist/`: Vite production build output + +## Core Workflow + +```text +Idea / PRD / Notes +→ App X-Ray analysis +→ 사용자가 suggested 구조를 검토 +→ accepted / edited 구조 확정 +→ App Map / Data Map / Missing Parts 확인 +→ Markdown / Mermaid / JSON / Prompt export +→ Codex / Cursor / Lovable / Replit / Bolt +``` + +## Product Rules + +App X-Ray의 핵심 원칙은 다음과 같습니다. + +```text +AI suggests. +The user confirms. +The system preserves. +``` + +제품 규칙과 Codex 작업 지침은 [app-xray-codex-rules](app-xray-codex-rules/)에 있습니다. diff --git a/app-xray-codex-rules/README_CONCEPT.md b/app-xray-codex-rules/README_CONCEPT.md index c5ef4b1..0046f47 100644 --- a/app-xray-codex-rules/README_CONCEPT.md +++ b/app-xray-codex-rules/README_CONCEPT.md @@ -67,6 +67,11 @@ Preferred model: - no AI token resale - export to Markdown, Mermaid, JSON, and build prompts +Product-facing docs: + +- [Service readiness](../docs/product/service-readiness.md) +- [Local-first data contract](../docs/product/local-first-data-contract.md) + ## Template Marketplace Direction The future App X-Ray ecosystem may include a template marketplace. diff --git a/docs/product/local-first-data-contract.md b/docs/product/local-first-data-contract.md new file mode 100644 index 0000000..510de0c --- /dev/null +++ b/docs/product/local-first-data-contract.md @@ -0,0 +1,123 @@ +# Local-First Data Contract + +이 문서는 App X-Ray의 browser-local 저장소, backup JSON, export 상태 규칙, AI key 처리 계약을 정의합니다. 이 계약은 제품 경계를 보호하기 위한 문서이며, 숨겨진 SaaS/backend 저장소를 전제로 하지 않습니다. + +## Storage Scope + +App X-Ray는 현재 브라우저 `localStorage`를 사용합니다. 데이터는 같은 browser profile과 origin 안에 남으며, 사용자가 브라우저 데이터를 지우면 삭제될 수 있습니다. + +서버 저장, 계정 동기화, 팀 workspace, billing, token resale은 기본 제품 범위가 아닙니다. + +## localStorage Keys + +| Key | Status | Stored value | Notes | +|---|---|---|---| +| `app-xray.projects.v1` | current | `ProjectCollection` JSON | 여러 workspace와 active project id를 저장합니다. | +| `app-xray.workspace.v1` | legacy | 단일 `ProjectWorkspace` JSON | 이전 단일 workspace 저장소입니다. current collection이 없을 때 migration source로 읽습니다. | +| `app-xray.ai-settings.v1` | current | `AiProviderConfig` JSON | provider, modelName, `apiKey`, `apiKeyPresent`, lastValidatedAt을 browser-local로 저장합니다. | + +`app-xray.projects.v1`의 개념적 구조: + +```json +{ + "activeProjectId": "project_123", + "workspaces": [], + "updatedAt": "2026-07-01T00:00:00.000Z" +} +``` + +각 `workspaces[]` 항목은 `ProjectWorkspace`입니다. + +## ProjectWorkspace Shape + +Workspace는 다음 최상위 필드를 갖습니다. + +| Field | Description | +|---|---| +| `project` | 프로젝트 이름, 설명, app type, 생성/수정 시간 | +| `sourceDocuments` | 붙여넣기 또는 파일 import로 생성된 versioned source documents | +| `objects` | requirements, screens, features, data objects, fields, relations, roles, permissions, flows, flow steps, issues | +| `buildPlanSuggestions` | AI가 제안한 build step 목록 | +| `lastAnalysis` | 마지막 분석 요약 | +| `analysisHistory` | 최근 분석 기록 | +| `lastStructureDiff` | 분석 rerun에서 생긴 구조 변경 요약 | +| `appliedTemplates` | 적용된 template metadata | +| `updatedAt` | workspace 수정 시간 | + +## Backup Format + +Workspace backup은 사용자가 내려받는 JSON 파일입니다. 파일명은 일반적으로 `app-xray-workspace-[project].json` 형태입니다. + +Backup schema: + +```json +{ + "schemaVersion": "1.0.0", + "exportedAt": "2026-07-01T00:00:00.000Z", + "workspace": {} +} +``` + +Current import rules: + +- JSON parse에 실패하면 import를 거부합니다. +- `schemaVersion`이 `1.0.0`이 아니거나 `workspace.project`, `workspace.objects`가 없으면 App X-Ray backup으로 보지 않습니다. +- 현재 workspace가 있을 때 backup을 import하면 source document는 id 기준으로 추가하고, confirmed data는 보존하면서 suggestion set을 merge합니다. +- backup은 AI settings를 포함하지 않습니다. +- backup은 API key 원문을 포함하지 않습니다. + +현재 import identity check는 최소 필드 중심입니다. 더 엄격한 `ProjectWorkspace` 전체 shape 검증과 복구 UI는 service-readiness roadmap의 backup/recovery task에서 강화합니다. + +## Confirmed-Only Export Rule + +모든 기본 export는 confirmed structured data에서 생성됩니다. + +기본 포함 상태: + +- `accepted` +- `edited` + +기본 제외 상태: + +- `suggested` +- `rejected` +- `deferred` + +이 규칙은 Markdown, Mermaid, JSON, Codex Prompt, Cursor Prompt, GitHub Issues Markdown, Bundle JSON에 적용됩니다. audit trail mode는 검토 이력을 확인하기 위한 선택 모드이며, 기본 export 경로가 아닙니다. + +AI output 원문이나 자유 형식 응답은 export의 최종 source of truth가 아닙니다. export는 typed workspace objects와 deterministic export 함수에서 생성되어야 합니다. + +## Supported Export Payloads + +| Export | Default data source | +|---|---| +| Markdown | confirmed workspace objects | +| App Map Mermaid | confirmed screens/features | +| Data Map Mermaid | confirmed data objects/relations | +| JSON | confirmed workspace objects plus project/source documents | +| Codex Prompt | confirmed screens/data/flows/issues and validation warnings | +| Cursor Prompt | confirmed screens/data/flows/issues and validation warnings | +| GitHub Issues Markdown | confirmed issues | +| Bundle JSON | generated export files | +| Workspace Backup | full local workspace, excluding AI settings | + +## AI Key Handling + +AI key handling is browser-local by design. + +- API key 원문은 `app-xray.ai-settings.v1`에만 저장됩니다. +- UI는 key가 browser-local임을 알려야 합니다. +- `toPublicAiProviderConfig` 같은 public config 변환은 `apiKey` 원문을 제거해야 합니다. +- export, build prompt, workspace backup, validation report, public config에는 API key 원문을 넣지 않습니다. +- AI key를 logs, generated prompt, support artifact, sample fixture에 복사하지 않습니다. +- 실제 provider adapter를 추가할 때도 hidden backend/proxy를 전제로 하지 않습니다. + +Browser-side BYOK 호출은 사용자의 브라우저 런타임에 key가 존재한다는 뜻입니다. 이 위험은 UI와 문서에서 명확히 설명하고, key를 원격 서비스나 export artifact에 몰래 복제하지 않는 것으로 통제합니다. + +## Compatibility Rules + +- 새 storage key를 추가할 때는 이 문서를 업데이트합니다. +- backup schema version을 바꾸면 migration 또는 compatibility note를 추가합니다. +- confirmed-only export 규칙을 바꾸는 변경은 breaking change로 취급합니다. +- AI settings와 workspace backup을 합치지 않습니다. +- login이나 server persistence를 추가하는 proposal은 local-first product boundary 변경으로 별도 검토해야 합니다. diff --git a/docs/product/service-readiness.md b/docs/product/service-readiness.md new file mode 100644 index 0000000..07a9ecd --- /dev/null +++ b/docs/product/service-readiness.md @@ -0,0 +1,89 @@ +# App X-Ray Service Readiness + +이 문서는 App X-Ray가 “서비스 수준으로 사용할 수 있다”고 판단하는 기준을 정의합니다. 목표는 local-first, open-source 제품 경계를 유지하면서 비개발자 사용자가 반복적으로 안전하게 프로젝트를 분석, 검토, export할 수 있게 만드는 것입니다. + +## Service-Level Definition + +App X-Ray는 사용자가 다음 작업을 반복할 수 있을 때 service-ready로 봅니다. + +1. 실제 앱 아이디어, PRD, 업무 메모로 로컬 프로젝트를 만들거나 불러옵니다. +2. Mock 분석 또는 사용자가 직접 연결한 BYOK AI 설정으로 구조 초안을 생성합니다. +3. AI가 만든 `suggested` 구조를 검토하고 `accepted`, `edited`, `rejected`, `deferred` 상태로 결정합니다. +4. 사용자가 확정한 구조가 AI 재분석, backup import, template 적용 과정에서 조용히 덮어써지지 않습니다. +5. validation error와 warning을 화면에서 이해하고 export 전에 수정할 수 있습니다. +6. Codex, Cursor, Lovable, Replit, Bolt, GitHub issue workflow에 유용한 deterministic export를 생성합니다. +7. 브라우저를 닫았다 열어도 같은 workspace를 복구합니다. +8. workspace backup을 내려받고 다시 가져올 수 있습니다. +9. 숨겨진 backend, account, billing, cloud workspace, token resale 없이 사용할 수 있습니다. + +## Supported Workflows + +### Local Project Workflow + +- 새 프로젝트를 만들고 source text를 저장합니다. +- 여러 프로젝트를 `localStorage` collection으로 보관합니다. +- active project를 전환합니다. +- workspace backup JSON을 내려받고 다시 가져옵니다. + +### Source Input Workflow + +- 사용자가 PRD, 아이디어, 업무 메모를 직접 붙여넣습니다. +- `.md`, `.markdown`, `.txt` 파일을 source document로 가져옵니다. +- source document는 versioned local workspace data로 남습니다. +- PDF, CSV, JSON import는 현재 release surface가 아니라 이후 source ingestion task 범위입니다. + +### Review Workflow + +- AI 또는 template이 만든 구조는 기본적으로 `suggested`입니다. +- 사용자는 제안을 `accepted`, `edited`, `rejected`, `deferred`로 결정합니다. +- `accepted`와 `edited`만 기본 export에 포함됩니다. +- `rejected`와 `deferred`는 기본 export에서 제외되며 audit trail 용도로만 다룹니다. + +### Export Workflow + +- Markdown, App Map Mermaid, Data Map Mermaid, JSON, Codex Prompt, Cursor Prompt, GitHub Issues Markdown, Bundle JSON을 생성합니다. +- 기본 export mode는 `confirmedOnly`입니다. +- audit trail mode는 검토 이력을 확인하기 위한 선택 모드이며 기본 동작이 아닙니다. +- 같은 workspace 상태는 같은 export content를 만들어야 합니다. + +### AI Settings Workflow + +- 현재 mock adapter는 deterministic 분석에 사용됩니다. +- provider 설정과 API key 입력은 browser-local settings에 저장됩니다. +- API key 원문은 public config, export, prompt, workspace backup에 포함하지 않습니다. +- 실제 provider adapter는 local-first BYOK 경계를 유지하는 별도 task에서 확장합니다. + +## Non-Goals + +다음은 service-readiness의 기본 범위가 아닙니다. + +- hosted SaaS backend +- login, billing, subscription +- multi-tenant team workspace +- real-time collaboration +- marketplace payment, seller dashboard, ranking, review +- AI token resale 또는 hosted AI proxy +- GitHub write integration +- Notion, Google Docs, Linear, Jira sync +- 대상 codebase를 자동으로 수정하는 code generation +- PDF parsing dependency 추가 +- 서버에 workspace 또는 API key 저장 + +## Release Checklist + +출시 전에는 아래 항목을 확인합니다. + +- [ ] README에서 제품 목적, local-first 경계, 지원 import/export, 개발 명령을 확인할 수 있습니다. +- [ ] 기본 export가 `accepted`와 `edited`만 포함한다는 점이 문서와 UI에 드러납니다. +- [ ] API key가 browser-local 설정에만 저장되고 export, prompt, backup에 포함되지 않습니다. +- [ ] workspace backup import가 malformed JSON과 최소 backup identity 오류를 구분해 실패합니다. +- [ ] 기존 confirmed/edited 구조가 AI rerun, backup import, template 적용으로 덮어써지지 않습니다. +- [ ] empty, loading, error, success 상태가 주요 workflow에 존재합니다. +- [ ] `npm run typecheck`가 통과합니다. +- [ ] `npm test`가 통과합니다. +- [ ] `npm run build`가 통과합니다. +- [ ] browser manual QA에서 create/import/review/export/backup/reload workflow를 확인합니다. + +## Current Readiness Notes + +이 문서는 service-ready 목표와 현재 release surface를 함께 설명합니다. 현재 구현의 기본 분석 경로는 mock adapter이며, 실제 BYOK provider 호출은 roadmap의 별도 task에서 adapter 뒤에 추가해야 합니다. 이 확장은 browser-local API key 원칙과 no hidden backend 원칙을 유지해야 합니다. From c1b74bf612199fbf12caa315fae92054ab72ab7b Mon Sep 17 00:00:00 2001 From: MacMini Date: Wed, 1 Jul 2026 03:04:25 +0900 Subject: [PATCH 03/23] test: add App X-Ray browser regression coverage --- .gitignore | 2 + docs/product/manual-qa-checklist.md | 67 +++++++++++++++++ package.json | 3 +- playwright.config.ts | 27 +++++++ src/components/ReviewPanel.tsx | 22 +++--- test/e2e/app-xray.spec.ts | 108 ++++++++++++++++++++++++++++ test/e2e/fixtures.ts | 61 ++++++++++++++++ 7 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 docs/product/manual-qa-checklist.md create mode 100644 playwright.config.ts create mode 100644 test/e2e/app-xray.spec.ts create mode 100644 test/e2e/fixtures.ts diff --git a/.gitignore b/.gitignore index 52d4d82..c243355 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ app-dist/ +test-results/ +playwright-report/ diff --git a/docs/product/manual-qa-checklist.md b/docs/product/manual-qa-checklist.md new file mode 100644 index 0000000..6a30b14 --- /dev/null +++ b/docs/product/manual-qa-checklist.md @@ -0,0 +1,67 @@ +# App X-Ray Manual QA Checklist + +이 체크리스트는 Task 9 이후 브라우저에서 직접 확인할 핵심 회귀 흐름입니다. 기본 주소는 `http://127.0.0.1:5173`입니다. + +## 준비 + +- [ ] `npm run dev -- --port 5173`으로 앱을 엽니다. +- [ ] 테스트 전 브라우저 개발자 도구에서 `localStorage`의 `app-xray.projects.v1`과 `app-xray.workspace.v1`을 지우거나 새 브라우저 프로필을 사용합니다. +- [ ] 확인용 원문으로 아래 문장을 준비합니다. + +```text +현장 전력설비를 관리하는 앱입니다. +담당구역, 공장, 변전실, 부하를 관리해야 합니다. +부하별 점검 이력과 알람을 볼 수 있어야 합니다. +대시보드에서는 단선도와 주요 알람, 일정이 보여야 합니다. +관리자는 설비 정보를 수정할 수 있고 일반 사용자는 조회만 가능해야 합니다. +``` + +## Desktop 1440px + +- [ ] 브라우저 폭을 약 1440px로 둡니다. +- [ ] `새 프로젝트`에서 `프로젝트 이름`과 `아이디어 / PRD`를 입력하고 `프로젝트 저장`을 누릅니다. +- [ ] 좌측 `로컬 프로젝트` 목록에 방금 만든 프로젝트가 보이고, 상단 제목도 같은 이름인지 확인합니다. +- [ ] `저장하고 Mock 분석` 또는 `Mock 재분석`을 누릅니다. +- [ ] `분석 검토`에서 `화면`, `앱이 저장할 정보`, `정보 연결`, `빠진 것` 그룹에 제안이 보이는지 확인합니다. +- [ ] `대시보드` 화면을 `확정`하고, `부하` 정보를 `수정` 후 저장하고, `알람 발생 조건이 빠져 있음` 항목을 `제외`합니다. +- [ ] `내보내기`에서 기본 모드가 `확정만`이고 제목이 `확정 데이터 기반 export`인지 확인합니다. +- [ ] Markdown preview에 확정/수정 확정한 항목은 포함되고 제외한 항목은 빠지는지 확인합니다. +- [ ] `다운로드`를 눌러 `.md` 파일이 내려받아지는지 확인합니다. + +## Mobile Width 390px + +- [ ] 브라우저 폭을 약 390px로 줄입니다. +- [ ] 좌측 내비게이션, 프로젝트 목록, 상단 버튼, 원문 입력 form이 가로 스크롤 없이 조작 가능한지 확인합니다. +- [ ] `분석 검토`의 상태 필터와 각 행의 `확정`, `수정`, `나중`, `제외` 버튼이 터치 가능한 크기로 보이는지 확인합니다. +- [ ] `내보내기`의 export type 버튼, validation panel, preview가 겹치지 않는지 확인합니다. +- [ ] `백업`의 `workspace JSON 저장`과 `workspace JSON 불러오기` 컨트롤이 화면 밖으로 밀리지 않는지 확인합니다. + +## Reload And Project Switching + +- [ ] 프로젝트 두 개를 만듭니다. +- [ ] 좌측 `로컬 프로젝트` 목록에서 첫 번째 프로젝트로 전환합니다. +- [ ] 브라우저를 새로고침합니다. +- [ ] 새로고침 후에도 선택한 프로젝트 이름, 원문, 검토 상태가 유지되는지 확인합니다. +- [ ] 두 번째 프로젝트로 전환했을 때 화면 제목과 원문이 해당 프로젝트로 바뀌는지 확인합니다. + +## Validation Blocking + +- [ ] 새 프로젝트에서 Mock 분석을 실행합니다. +- [ ] `정보 연결` 그룹의 관계 하나만 `확정`하고, `앱이 저장할 정보` 그룹은 확정하지 않습니다. +- [ ] `내보내기`로 이동합니다. +- [ ] `연결이 끊긴 정보 구조 관계가 있습니다.` 문구가 보이는지 확인합니다. +- [ ] `다운로드` 버튼이 비활성화되어 파일이 내려받아지지 않는지 확인합니다. + +## Backup Import/Export + +- [ ] 분석과 일부 검토 상태 변경을 완료한 프로젝트에서 `백업`으로 이동합니다. +- [ ] `workspace JSON 저장`을 눌러 `app-xray-workspace-...json` 파일을 내려받습니다. +- [ ] 다른 로컬 프로젝트를 만든 뒤 같은 `백업` 화면에서 `workspace JSON 불러오기`로 방금 받은 JSON을 선택합니다. +- [ ] `workspace 백업을 불러왔습니다.` 또는 validation 경고가 포함된 import 결과 메시지가 보이는지 확인합니다. +- [ ] 기존 확정/수정 확정 상태가 덮어써지지 않고 보존되는지 `분석 검토`에서 확인합니다. + +## Error And Empty States + +- [ ] 저장된 프로젝트가 없는 브라우저에서 앱을 열면 빈 상태 안내가 보이는지 확인합니다. +- [ ] `Markdown/TXT 파일 가져오기`에 PDF 파일을 선택하면 지원 예정/미지원 안내가 보이는지 확인합니다. +- [ ] `workspace JSON 불러오기`에 App X-Ray backup이 아닌 JSON을 선택하면 `App X-Ray workspace 백업 파일이 아닙니다.` 문구가 보이는지 확인합니다. diff --git a/package.json b/package.json index 909e05a..bcda20e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "compile": "tsc", "build": "npm run compile && vite build --outDir app-dist", "dev": "vite --host 127.0.0.1", - "test": "npm run compile && node --test test/*.test.mjs" + "test": "npm run compile && node --test test/*.test.mjs", + "test:e2e": "playwright test" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..51b0dfa --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "playwright/test"; + +export default defineConfig({ + testDir: "./test/e2e", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: false, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://127.0.0.1:5173", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --port 5173", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/src/components/ReviewPanel.tsx b/src/components/ReviewPanel.tsx index 873f9c3..9fbf817 100644 --- a/src/components/ReviewPanel.tsx +++ b/src/components/ReviewPanel.tsx @@ -212,7 +212,7 @@ function ReviewGroup({ const filteredObjects = filterObjects(objects, filter); return ( -
+

{title} {filteredObjects.length} / {objects.length}

{objects.length === 0 ?

아직 제안이 없습니다.

: null} @@ -260,7 +260,7 @@ function ReviewRow({ if (isEditing) { return ( -
+
{EDIT_FIELDS[bucket].map((field) => (
- - + +
); } return ( -
+
{getObjectLabel(object)}

{getObjectDescription(object)}

@@ -293,15 +293,19 @@ function ReviewRow({
- - - - + + + +
); } +function titleForBucket(bucket: ObjectBucket): string { + return BUCKET_LABELS[bucket]; +} + function EditFieldControl({ draft, field, diff --git a/test/e2e/app-xray.spec.ts b/test/e2e/app-xray.spec.ts new file mode 100644 index 0000000..8692413 --- /dev/null +++ b/test/e2e/app-xray.spec.ts @@ -0,0 +1,108 @@ +import { readFile } from "node:fs/promises"; +import { acceptReviewItem, createAnalyzedProject, e2eSourceText, expect, reviewRow, test } from "./fixtures"; + +test("creates a project, imports source, reviews suggestions, and exports confirmed-only markdown", async ({ cleanPage: page }, testInfo) => { + await createAnalyzedProject(page, testInfo, "E2E 전력설비 앱"); + + await acceptReviewItem(page, "화면", "대시보드"); + + const loadRow = reviewRow(page, "앱이 저장할 정보", "부하"); + await loadRow.getByRole("button", { name: /수정$/ }).click(); + await loadRow.getByLabel("쉬운 이름").fill("부하 검증 완료"); + await loadRow.getByRole("button", { name: /저장$/ }).click(); + await expect(loadRow.getByText("수정 확정")).toBeVisible(); + + const rejectedIssue = reviewRow(page, "빠진 것", "알람 발생 조건"); + await rejectedIssue.getByRole("button", { name: /제외$/ }).click(); + await expect(rejectedIssue.locator(".badge.rejected")).toHaveText("제외"); + + await page.getByRole("link", { name: "내보내기" }).click(); + const exportPanel = page.locator("#export"); + await expect(exportPanel.getByRole("heading", { name: "확정 데이터 기반 export" })).toBeVisible(); + await expect(exportPanel.locator("pre.preview")).toContainText("Export mode: confirmedOnly"); + await expect(exportPanel.locator("pre.preview")).toContainText("대시보드"); + await expect(exportPanel.locator("pre.preview")).toContainText("부하 검증 완료 / Load"); + await expect(exportPanel.locator("pre.preview")).not.toContainText("부하 목록"); + await expect(exportPanel.locator("pre.preview")).not.toContainText("관리자"); + await expect(exportPanel.locator("pre.preview")).not.toContainText("알람 발생 조건이 빠져 있음"); + + const downloadPromise = page.waitForEvent("download"); + await exportPanel.getByRole("button", { name: "다운로드" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("app-xray-e2e-전력설비-앱.md"); + const markdownPath = testInfo.outputPath("confirmed-only-export.md"); + await download.saveAs(markdownPath); + const markdown = await readFile(markdownPath, "utf8"); + expect(markdown).toContain("# E2E 전력설비 앱"); + expect(markdown).toContain("부하 검증 완료 / Load"); + expect(markdown).not.toContain("부하 목록"); + expect(markdown).not.toContain("관리자"); + expect(markdown).not.toContain("알람 발생 조건이 빠져 있음"); +}); + +test("persists projects across reload and supports project switching", async ({ cleanPage: page }) => { + await page.getByLabel("프로젝트 이름").fill("첫 번째 로컬 프로젝트"); + await page.getByLabel("아이디어 / PRD").fill(e2eSourceText); + await page.getByRole("button", { name: "프로젝트 저장" }).click(); + await expect(page.getByRole("heading", { name: "첫 번째 로컬 프로젝트" })).toBeVisible(); + + await page.getByRole("link", { name: "새 프로젝트" }).click(); + await page.getByLabel("프로젝트 이름").fill("두 번째 로컬 프로젝트"); + await page.getByLabel("아이디어 / PRD").fill(`${e2eSourceText}\n두 번째 프로젝트입니다.`); + await page.getByRole("button", { name: "프로젝트 저장" }).click(); + await expect(page.getByRole("heading", { name: "두 번째 로컬 프로젝트" })).toBeVisible(); + + await page.getByLabel("로컬 프로젝트 목록").getByRole("button", { name: "첫 번째 로컬 프로젝트", exact: true }).click(); + await expect(page.getByRole("heading", { name: "첫 번째 로컬 프로젝트" })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole("heading", { name: "첫 번째 로컬 프로젝트" })).toBeVisible(); + await expect(page.getByLabel("로컬 프로젝트 목록").getByRole("button", { name: "두 번째 로컬 프로젝트", exact: true })).toBeVisible(); + + await page.getByLabel("로컬 프로젝트 목록").getByRole("button", { name: "두 번째 로컬 프로젝트", exact: true }).click(); + await expect(page.getByRole("heading", { name: "두 번째 로컬 프로젝트" })).toBeVisible(); +}); + +test("blocks export download when confirmed relations reference unconfirmed data objects", async ({ cleanPage: page }, testInfo) => { + await createAnalyzedProject(page, testInfo, "E2E 검증 차단 앱"); + await acceptReviewItem(page, "정보 연결", /rel_rel_area_loads/); + + await page.getByRole("link", { name: "내보내기" }).click(); + const exportPanel = page.locator("#export"); + await expect(exportPanel.getByText("연결이 끊긴 정보 구조 관계가 있습니다.").first()).toBeVisible(); + await expect(exportPanel.getByRole("button", { name: "다운로드" })).toBeDisabled(); +}); + +test("downloads a workspace backup and imports it into the current workspace", async ({ cleanPage: page }, testInfo) => { + await createAnalyzedProject(page, testInfo, "E2E 백업 원본"); + await acceptReviewItem(page, "화면", "대시보드"); + + await page.getByRole("link", { name: "백업" }).click(); + const downloadPromise = page.waitForEvent("download"); + await page.locator("#backup").getByRole("button", { name: "workspace JSON 저장" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("app-xray-workspace-E2E-백업-원본.json"); + const backupPath = testInfo.outputPath("workspace-backup.json"); + await download.saveAs(backupPath); + + await page.getByRole("link", { name: "새 프로젝트" }).click(); + await page.getByLabel("프로젝트 이름").fill("E2E 백업 가져오기 대상"); + await page.getByLabel("아이디어 / PRD").fill("가져오기 대상 프로젝트입니다."); + await page.getByRole("button", { name: "프로젝트 저장" }).click(); + await expect(page.getByRole("heading", { name: "E2E 백업 가져오기 대상" })).toBeVisible(); + await page.getByRole("button", { name: "Mock 재분석" }).click(); + await expect(page.getByRole("heading", { name: "AI 제안 초안" })).toBeVisible(); + await acceptReviewItem(page, "앱이 저장할 정보", "부하"); + const targetLoadRow = reviewRow(page, "앱이 저장할 정보", "부하"); + await targetLoadRow.getByRole("button", { name: /수정$/ }).click(); + await targetLoadRow.getByLabel("쉬운 이름").fill("대상 프로젝트 보존 부하"); + await targetLoadRow.getByRole("button", { name: /저장$/ }).click(); + await expect(reviewRow(page, "앱이 저장할 정보", "대상 프로젝트 보존 부하")).toBeVisible(); + + await page.getByRole("link", { name: "백업" }).click(); + await page.locator("#backup input[type='file']").setInputFiles(backupPath); + await expect(page.getByText("workspace 백업을 불러왔습니다.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "E2E 백업 가져오기 대상" })).toBeVisible(); + await expect(reviewRow(page, "앱이 저장할 정보", "대상 프로젝트 보존 부하").locator(".badge.edited")).toHaveText("수정 확정"); + await expect(reviewRow(page, "화면", "대시보드").locator(".badge.accepted")).toHaveText("확정"); +}); diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts new file mode 100644 index 0000000..c068bdd --- /dev/null +++ b/test/e2e/fixtures.ts @@ -0,0 +1,61 @@ +import { writeFile } from "node:fs/promises"; +import type { Locator, Page, TestInfo } from "playwright/test"; +import { expect, test as base } from "playwright/test"; + +export const e2eSourceText = [ + "현장 전력설비를 관리하는 앱입니다.", + "담당구역, 공장, 변전실, 부하를 관리해야 합니다.", + "부하별 점검 이력과 알람을 볼 수 있어야 합니다.", + "대시보드에서는 단선도와 주요 알람, 일정이 보여야 합니다.", + "관리자는 설비 정보를 수정할 수 있고 일반 사용자는 조회만 가능해야 합니다.", +].join("\n"); + +type AppFixtures = { + cleanPage: Page; +}; + +export const test = base.extend({ + cleanPage: async ({ page }, use) => { + await page.goto("/"); + await page.evaluate(() => window.localStorage.clear()); + await page.goto("/"); + await use(page); + }, +}); + +export { expect }; + +export async function importSourceFixture(page: Page, testInfo: TestInfo): Promise { + const sourcePath = testInfo.outputPath("field-power-source.txt"); + await writeFile(sourcePath, e2eSourceText, "utf8"); + await page.getByLabel("Markdown/TXT 파일 가져오기").setInputFiles(sourcePath); + await expect(page.getByText("field-power-source.txt 원문을 불러왔습니다.")).toBeVisible(); +} + +export async function createAnalyzedProject(page: Page, testInfo: TestInfo, projectName: string): Promise { + await page.getByLabel("프로젝트 이름").fill(projectName); + await importSourceFixture(page, testInfo); + await page.getByRole("button", { name: "저장하고 Mock 분석" }).click(); + await expect(page.getByRole("heading", { name: projectName })).toBeVisible(); + await expect(page.getByRole("heading", { name: "AI 제안 초안" })).toBeVisible(); + await expect(reviewRow(page, "화면", "대시보드")).toBeVisible(); +} + +export function reviewGroup(page: Page, groupName: string): Locator { + return page.locator(".review-group").filter({ hasText: groupName }).first(); +} + +export function reviewRow(page: Page, groupName: string, itemName: string | RegExp): Locator { + const namePattern = typeof itemName === "string" ? new RegExp(escapeRegExp(itemName)) : itemName; + return page.getByLabel(new RegExp(`리뷰 항목: ${escapeRegExp(groupName)} - .*${namePattern.source}`)).first(); +} + +export async function acceptReviewItem(page: Page, groupName: string, itemName: string | RegExp): Promise { + const row = reviewRow(page, groupName, itemName); + await row.getByRole("button", { name: /확정$/ }).click(); + await expect(row.locator(".badge.accepted")).toHaveText("확정"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} From 571b14b6f7603fff8bfe4453cd270cd35ad50750 Mon Sep 17 00:00:00 2001 From: MacMini Date: Wed, 1 Jul 2026 03:19:38 +0900 Subject: [PATCH 04/23] feat: harden local project lifecycle --- src/App.css | 4 + src/App.tsx | 201 ++++++++++++++-------- src/domain/routes.ts | 4 + src/storage/project-repository.ts | 78 ++++++++- test/domain.test.mjs | 100 +++++++++++ test/ui-project-lifecycle.test.mjs | 259 +++++++++++++++++++++++++++++ 6 files changed, 569 insertions(+), 77 deletions(-) create mode 100644 test/ui-project-lifecycle.test.mjs diff --git a/src/App.css b/src/App.css index 71afa62..ab6e3f3 100644 --- a/src/App.css +++ b/src/App.css @@ -182,6 +182,10 @@ nav a:hover { padding-right: 0.6rem; } +.icon-button.pending-delete { + min-width: 5.4rem; +} + .status-card { background: #f2f7f5; border: 1px solid #dce8e4; diff --git a/src/App.tsx b/src/App.tsx index 838844d..820a67f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,19 +12,18 @@ import { summarizeSuggestionMergeImpact, updateXrayObjectStatus, } from "./domain/lifecycle.js"; -import { parseAppRoute, projectRoute } from "./domain/routes.js"; +import { parseAppRoute, projectOrListRoute, projectRoute, type ProjectRouteSection } from "./domain/routes.js"; import { classifySourceFile } from "./domain/source-import.js"; import { appendSourceDocumentVersion, getLatestSourceDocument } from "./domain/source-documents.js"; import { isConfirmedXrayObject } from "./domain/status.js"; import { applyTemplateToWorkspace } from "./domain/template.js"; import type { BaseXrayObject, SourceDocument, SuggestionStatus, XrayObject, XraySuggestionSet } from "./domain/types.js"; import type { ProjectWorkspace } from "./domain/workspace.js"; -import { createEmptySuggestionSet } from "./domain/workspace.js"; import type { ExportType } from "./export/export-content.js"; import { fieldPowerAppSourceDocument } from "./fixtures/field-power-app.js"; import { fieldPowerTemplate } from "./fixtures/field-power-template.js"; import { createBuildPrompt, type ExtendedBuildPromptTarget } from "./prompt/build-prompt.js"; -import { createLocalStorageProjectRepository, summarizeProjects } from "./storage/project-repository.js"; +import { createLocalStorageProjectRepository, createProjectWorkspace, summarizeProjects } from "./storage/project-repository.js"; import { importWorkspaceBackup, serializeWorkspaceBackup } from "./storage/workspace-backup.js"; import { downloadTextFile } from "./export/export-content.js"; @@ -43,6 +42,9 @@ export default function App() { const [promptTarget, setPromptTarget] = useState("codex"); const [selectedBuildStep, setSelectedBuildStep] = useState(""); const [saveError, setSaveError] = useState(initialLoad.error ?? null); + const [saveStatus, setSaveStatus] = useState(initialLoad.activeWorkspace ? "로컬 프로젝트를 불러왔습니다." : null); + const [formError, setFormError] = useState(null); + const [pendingDeleteProjectId, setPendingDeleteProjectId] = useState(null); const [templateMessage, setTemplateMessage] = useState(null); const [aiConfig, setAiConfig] = useState(() => loadAiProviderConfig()); const [aiSettingsMessage, setAiSettingsMessage] = useState(null); @@ -54,9 +56,11 @@ export default function App() { try { const result = repository.saveWorkspace(workspace); setProjectSummaries(summarizeProjects(result.collection)); - setSaveError(null); + setSaveError(result.error ?? null); + setSaveStatus(result.error ? null : "로컬 저장됨"); } catch (error) { setSaveError(error instanceof Error ? error.message : "저장할 수 없습니다."); + setSaveStatus(null); } }, [repository, workspace]); @@ -82,7 +86,7 @@ export default function App() { return; } if (route.name !== "projectSection") return; - if (workspace?.project.id !== route.projectId) openProject(route.projectId); + if (workspace?.project.id !== route.projectId) openProject(route.projectId, route.section); const sectionId = sectionIdForRoute(route.section); window.requestAnimationFrame(() => document.getElementById(sectionId)?.scrollIntoView({ block: "start" })); }, [route, workspace?.project.id]); @@ -138,40 +142,44 @@ export default function App() { ); function createProject() { + const validationError = validateProjectForm(); + if (validationError) { + setFormError(validationError); + setSaveStatus(null); + return; + } const now = new Date().toISOString(); - const projectId = `project_${crypto.randomUUID()}`; - const nextWorkspace: ProjectWorkspace = { - project: { - id: projectId, - name: projectName.trim() || "새 앱 아이디어", - description: "AI가 만들기 전에 구조를 먼저 확인하는 로컬 프로젝트", - appTypes: [], - createdAt: now, - updatedAt: now, - }, - sourceDocuments: [ - { - id: `src_${crypto.randomUUID()}`, - projectId, - title: "아이디어 / PRD", - content: sourceText.trim(), - sourceType, - version: 1, - createdAt: now, - }, - ], - objects: createEmptySuggestionSet(), - buildPlanSuggestions: [], - updatedAt: now, - }; + const nextWorkspace = createProjectWorkspace({ + name: projectName, + sourceText, + sourceType, + now, + }); + const result = repository.saveWorkspace(nextWorkspace); + if (result.error) { + setFormError(result.error); + setSaveError(result.error); + setSaveStatus(null); + return; + } + setFormError(null); setWorkspace(nextWorkspace); - window.location.hash = projectRoute(projectId, "source"); + setProjectSummaries(summarizeProjects(result.collection)); + setSaveError(null); + setSaveStatus("로컬 저장됨"); + navigateTo(projectRoute(nextWorkspace.project.id, "review")); } function runMockAnalysis() { + const validationError = validateProjectForm(); + if (validationError) { + setFormError(validationError); + setSaveStatus(null); + return; + } const now = new Date().toISOString(); const baseWorkspace = workspace ?? createWorkspaceFromForm(now); - const versionedWorkspace = syncSourceDocument(baseWorkspace, now); + const versionedWorkspace = syncSourceDocument(updateWorkspaceFromForm(baseWorkspace, now), now); const sourceDocument = getLatestSourceDocument(versionedWorkspace); if (!sourceDocument) return; const analysis = mockAiProviderAdapter.analyze({ sourceDocument }); @@ -198,7 +206,7 @@ export default function App() { ...mergeImpact, }; - setWorkspace({ + const nextWorkspace = { ...versionedWorkspace, project: { ...versionedWorkspace.project, @@ -211,35 +219,14 @@ export default function App() { analysisHistory: [lastAnalysis, ...(versionedWorkspace.analysisHistory ?? [])].slice(0, 10), lastStructureDiff: structureDiff, updatedAt: now, - }); + }; + if (commitWorkspace(nextWorkspace, "Mock 분석 완료")) { + navigateTo(projectRoute(nextWorkspace.project.id, "review")); + } } function createWorkspaceFromForm(now: string): ProjectWorkspace { - const projectId = `project_${crypto.randomUUID()}`; - return { - project: { - id: projectId, - name: projectName.trim() || "새 앱 아이디어", - description: "AI가 만들기 전에 구조를 먼저 확인하는 로컬 프로젝트", - appTypes: [], - createdAt: now, - updatedAt: now, - }, - sourceDocuments: [ - { - id: `src_${crypto.randomUUID()}`, - projectId, - title: "아이디어 / PRD", - content: sourceText.trim(), - sourceType, - version: 1, - createdAt: now, - }, - ], - objects: createEmptySuggestionSet(), - buildPlanSuggestions: [], - updatedAt: now, - }; + return createProjectWorkspace({ name: projectName, sourceText, sourceType, now }); } function updateObjectStatus(bucket: ObjectBucket, object: XrayObject, status: SuggestionStatus) { @@ -286,31 +273,84 @@ export default function App() { setProjectName("현장 전력설비 관리 앱"); setSourceText(DEFAULT_PRD); } + navigateTo(projectOrListRoute(result.activeWorkspace?.project.id, "review")); } - function openProject(projectId: string) { + function openProject(projectId: string, section: ProjectRouteSection = "review") { const result = repository.setActiveProject(projectId); setWorkspace(result.workspace); setSaveError(result.error ?? null); + setSaveStatus(result.error ? null : "로컬 프로젝트를 열었습니다."); + setPendingDeleteProjectId(null); + if (!result.workspace || result.workspace.project.id !== projectId) { + navigateTo(projectOrListRoute(result.workspace?.project.id, "review")); + return; + } + navigateTo(projectRoute(projectId, section)); } function deleteProject(projectId: string) { - if (!window.confirm("이 로컬 프로젝트를 삭제할까요? 이 작업은 되돌릴 수 없습니다.")) return; + if (pendingDeleteProjectId !== projectId) { + setPendingDeleteProjectId(projectId); + return; + } const result = repository.deleteWorkspace(projectId); setWorkspace(result.activeWorkspace); setProjectSummaries(summarizeProjects(result.collection)); + setPendingDeleteProjectId(null); + setSaveError(result.error ?? null); + setSaveStatus("로컬 프로젝트를 삭제했습니다."); if (!result.activeWorkspace) { setProjectName("현장 전력설비 관리 앱"); setSourceText(DEFAULT_PRD); } + navigateTo(projectOrListRoute(result.activeWorkspace?.project.id, "review")); + } + + function navigateTo(hash: string) { + window.location.hash = hash; + setRoute(parseAppRoute(hash)); } function saveSourceVersion() { + const validationError = validateProjectForm(); + if (validationError) { + setFormError(validationError); + setSaveStatus(null); + return; + } const now = new Date().toISOString(); - setWorkspace((current) => { - const baseWorkspace = current ?? createWorkspaceFromForm(now); - return syncSourceDocument(baseWorkspace, now); - }); + const baseWorkspace = workspace ?? createWorkspaceFromForm(now); + const nextWorkspace = syncSourceDocument(updateWorkspaceFromForm(baseWorkspace, now), now); + commitWorkspace(nextWorkspace, "로컬 저장됨"); + } + + function updateWorkspaceFromForm(baseWorkspace: ProjectWorkspace, now: string): ProjectWorkspace { + return { + ...baseWorkspace, + project: { + ...baseWorkspace.project, + name: projectName.trim(), + updatedAt: now, + }, + updatedAt: now, + }; + } + + function commitWorkspace(nextWorkspace: ProjectWorkspace, successMessage: string): boolean { + const result = repository.saveWorkspace(nextWorkspace); + if (result.error) { + setFormError(result.error); + setSaveError(result.error); + setSaveStatus(null); + return false; + } + setFormError(null); + setWorkspace(nextWorkspace); + setProjectSummaries(summarizeProjects(result.collection)); + setSaveError(null); + setSaveStatus(successMessage); + return true; } function syncSourceDocument(baseWorkspace: ProjectWorkspace, now: string): ProjectWorkspace { @@ -384,6 +424,20 @@ export default function App() { ); } + function validateProjectForm(): string | null { + const errors: string[] = []; + if (!projectName.trim()) errors.push("프로젝트 이름을 입력하세요."); + if (!sourceText.trim()) errors.push("아이디어나 PRD 원문을 입력하세요."); + const duplicateProject = projectSummaries.find( + (project) => + project.id !== workspace?.project.id && + project.name.trim().replace(/\s+/g, " ").toLocaleLowerCase("ko-KR") === + projectName.trim().replace(/\s+/g, " ").toLocaleLowerCase("ko-KR"), + ); + if (duplicateProject) errors.push("같은 이름의 로컬 프로젝트가 이미 있습니다."); + return errors.length ? errors.join(" ") : null; + } + return (