diff --git a/.github/instructions/pr-review.md b/.github/instructions/pr-review.md index d8ff7af..4abca1d 100644 --- a/.github/instructions/pr-review.md +++ b/.github/instructions/pr-review.md @@ -24,6 +24,7 @@ You are reviewing changes for @knighted/develop. Be concise, technical, and spec ## What to verify - No generated artifacts are edited (dist/, coverage/, test-results/). +- Duplicated logic paths are avoided when a shared helper/module already exists; prefer reusing the established implementation. - CDN import/fallback behavior is not bypassed with ad hoc URLs in feature modules. - Sensitive values (PAT/token) are not logged or exposed in UI/status output. - New UI behavior is covered in Playwright where appropriate. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c34ee..078ecbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - next jobs: ci: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3e4da99..b3a1a72 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - next types: - opened - synchronize @@ -38,20 +39,25 @@ jobs: shardIndex: 1 shardTotal: 1 - browser: webkit - jobName: E2E (Playwright, webkit, shard 1/3) + jobName: E2E (Playwright, webkit, shard 1/4) workers: 1 shardIndex: 1 - shardTotal: 3 + shardTotal: 4 - browser: webkit - jobName: E2E (Playwright, webkit, shard 2/3) + jobName: E2E (Playwright, webkit, shard 2/4) workers: 1 shardIndex: 2 - shardTotal: 3 + shardTotal: 4 - browser: webkit - jobName: E2E (Playwright, webkit, shard 3/3) + jobName: E2E (Playwright, webkit, shard 3/4) workers: 1 shardIndex: 3 - shardTotal: 3 + shardTotal: 4 + - browser: webkit + jobName: E2E (Playwright, webkit, shard 4/4) + workers: 1 + shardIndex: 4 + shardTotal: 4 steps: - name: Checkout diff --git a/AGENTS.md b/AGENTS.md index 51531b0..c033f05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,15 +40,18 @@ Repository structure: - Preserve current project formatting: single quotes, no semicolons, print width 90, arrowParens avoid. - Do not use index files or barrel-file architecture; prefer explicit file names and explicit import paths. +- Prefer modular, colocated architecture; split focused features into nearby files and avoid monolithic modules. - Keep UI changes intentional and lightweight; avoid broad visual rewrites unless requested. - Keep runtime logic defensive for flaky/slow CDN conditions. - Preserve progressive loading behavior (lazy-load optional compilers/runtime pieces where possible). - Do not introduce bundler-only assumptions into src/ runtime code. - Prefer async/await over promise chains. +- Prefer const-assigned function expressions over function declarations, unless hoisting is explicitly required. - Do not use IIFE, find another pattern instead. - In Playwright tests, prefer accessible selectors first: `getByRole`, `getByLabel`, `getByText`, and explicit accessible names. - Avoid `locator()` for interactive controls when a semantic selector is available. - Use `locator()` only as a fallback for cases without reliable semantics (for example: document root `html`, structural class assertions, or implementation-only hooks). +- For known WebKit HTML `` top-layer issues, prefer a stable dialog id locator and `evaluate`-based click for dialog confirmation controls. - When testability needs improvement, prefer adding accessibility semantics (`role`, `aria-label`, `aria-labelledby`) over introducing new id-only selectors. ## CDN and runtime expectations diff --git a/README.md b/README.md index dde61aa..052a7ec 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ CDN-first UI component workbench for rapid prototyping with [`@knighted/jsx`](ht ## What it is -`@knighted/develop` is a browser-native UI component workbench that demonstrates -modern component authoring without a local bundler-first inner loop. +`@knighted/develop` is a browser-native component editor/workbench for fast +UI iteration without a local bundler-first inner loop. The app is designed to showcase two libraries: @@ -18,19 +18,30 @@ The app is designed to showcase two libraries: - [`@knighted/css`](https://github.com/knightedcodemonkey/css) for in-browser CSS compilation workflows Dependencies are delivered over CDN ESM with on-demand loading by mode, so the -browser acts as the runtime host for render, lint, and typecheck flows. +browser acts as the runtime host for editing, render, lint, and typecheck flows. ## Core capabilities `@knighted/develop` lets you: -- write component code in the browser +- write component code in dynamic editor tabs in the browser +- add, rename, and remove tabs with entry-role protection for required tabs +- keep per-tab dirty/synced state while iterating across files - switch render mode between DOM and React - switch style mode between native CSS, CSS Modules, Less, and Sass - run in-browser lint and type diagnostics - open diagnostics in a shared drawer and jump to source locations -- toggle ShadowRoot preview isolation while iterating -- switch layout and theme while preserving fast feedback loops +- use iframe-isolated preview style encapsulation while iterating +- connect a GitHub repository and run Open PR / Push Commit workflows +- use AI chat with tab-aware proposals and apply/undo controls +- switch theme and collapse the preview panel while preserving fast feedback loops + +## Why this shape + +The app started as a focused compile-and-preview loop and has grown into a +more complete browser-native editor surface. The goal is still fast +experimentation, now with practical multi-file editing and repository workflows +in the same UI. ## Try it diff --git a/docs/ai-chat-context-and-payload-strategy.md b/docs/ai-chat-context-and-payload-strategy.md new file mode 100644 index 0000000..72ca275 --- /dev/null +++ b/docs/ai-chat-context-and-payload-strategy.md @@ -0,0 +1,184 @@ +# AI Chat Context and Payload Strategy + +This document describes the current AI chat request construction approach in @knighted/develop, including context shaping, tool usage, payload-size controls, and known improvement opportunities. + +## Current Approach + +### 1. System prompt and mode-aware policy + +Each request includes a system prompt with policy guidance, then augments that prompt with mode-aware constraints: + +- Render mode guidance (DOM vs React) +- Style mode guidance (css, module, less, sass) +- DOM-mode JSX guidance for @knighted/jsx runtime +- Explicit React-avoidance in DOM mode unless migration is requested +- Dialect preservation guidance (avoid cross-dialect rewrites unless requested) + +Primary implementation: + +- src/modules/github/chat/payload.js + +### 2. Repository context + +Each request includes repository targeting context as a dedicated system message: + +- Selected repository full name +- Repository URL +- Default branch +- Policy to treat selected repository as default unless overridden + +Primary implementation: + +- src/modules/github/chat/drawer.js + +### 3. Editor context (Send tab content) + +When enabled, the drawer includes active tab context as a system message: + +- Render mode and style mode +- Active tab label/path +- Available tab targets list (id/path/name/language), currently capped to 20 +- Active tab source code block + +This context is designed to support dynamic proposal targeting by tab id/path and reduce ambiguity. + +Primary implementation: + +- src/modules/github/chat/active-tab-context.js +- src/modules/github/chat/drawer.js + +### 4. Tooling model + +AI proposal actions currently use a function tool: + +- propose_editor_update + +Contract: + +- target: tab id or path +- content: full replacement tab content +- language: optional disambiguation hint +- rationale: optional explanation + +Primary implementation: + +- src/modules/github/chat/proposals.js +- src/modules/github/chat/tab-target-resolver.js +- src/modules/github/chat/drawer.js + +### 5. Apply and undo behavior + +- Apply is proposal-driven and tab-target-aware (id/path resolution) +- Undo is scoped per tab (latest snapshot per tab) +- Undo UI is visible for active tab snapshot only + +Primary implementation: + +- src/modules/github/chat/drawer.js +- src/modules/github/chat/tab-scoped-undo-state.js + +### 6. Payload size controls and summary strategy + +The payload builder includes bounded-conversation controls: + +- Hard byte budget: 120_000 bytes +- Direct conversation retention cap: latest 14 chat messages +- Summary cap: 3_600 characters +- Older dropped conversation turns are compacted into a rolling system summary + +Primary implementation: + +- src/modules/github/chat/payload.js + +### 7. Fallback and transport behavior + +- Streaming request path is attempted first +- Non-stream fallback is attempted on streaming failure +- Model access errors are surfaced with tailored status text + +Primary implementation: + +- src/modules/github/chat/drawer.js +- src/modules/github/api/chat.js + +## Why this approach + +- Keeps active-tab workflows lightweight and responsive +- Supports explicit user review before applying generated edits +- Preserves model guidance quality with mode/dialect policy constraints +- Reduces request-size growth with bounded message history and rolling summaries + +## Possible Areas for Improvement + +### 1. Hard-fit protection when system context alone is large + +Current shrinking behavior primarily trims conversation turns. Add a final hard-fit step that can selectively trim editor context sections when total payload still exceeds budget. + +Potential ideas: + +- Trim available tab target list length adaptively +- Clip active tab source with clear truncation markers +- Retry once on 413 with reduced context envelope + +### 2. Create-tab capability + +Add a dedicated tool for creating workspace tabs so requests like "create a new styles tab" can be completed in one interaction. + +Potential tool: + +- create_workspace_tab(path, language, initialContent?, activate?) + +### 3. Cross-tab source access + +Support workflows where the user references a non-active tab. + +Potential options: + +- Add Send all tabs mode with explicit byte budgeting +- Add read_workspace_tab tool for targeted lookup + +### 4. Better summary fidelity + +Current summary is compact and bounded, but can lose nuanced intent over long sessions. + +Potential ideas: + +- Structured summary sections (goals, constraints, pending asks) +- Weighted retention for user constraints and accepted decisions + +### 5. Context observability in UI + +Provide optional diagnostics showing what context is being sent in the next request. + +Potential ideas: + +- "Preview outgoing context" drawer section +- Approximate byte-count indicator before send + +### 6. Tool-call UX clarity + +Continue improving copy and actions so users understand what is proposed versus what is already applied. + +Potential ideas: + +- Show target tab path in each action +- Add optional diff preview before apply + +### 7. Optional stricter policy profiles + +Allow policy strictness presets depending on user goals. + +Potential ideas: + +- Conservative mode: fewer tool proposals, stronger minimal-change bias +- Refactor mode: broader architectural proposal tolerance + +## Validation status + +Current strategy has focused Playwright coverage for the chat drawer behavior and context policy assertions in: + +- playwright/github-byot-ai.spec.ts + +## Scope note + +This document is intentionally implementation-oriented. It describes current behavior and practical next improvements without locking future UX or API contracts. diff --git a/docs/article.md b/docs/article.md index 6fed297..e049e50 100644 --- a/docs/article.md +++ b/docs/article.md @@ -1,78 +1,90 @@ -# Forget The Build Step: A Browser-Native Workbench For JSX + CSS +# Forget The Build Step: A Browser-Native Editor For JSX + CSS -Frontend tooling has become incredibly capable. +Frontend tooling is incredibly capable. -It has also become very heavy. +It is also often front-loaded. -For many UI experiments, the first thing you do is not write code. You install dependencies, run a dev server, wait for transforms, and only then start iterating. +For many UI ideas, the first thing you do is not write code. You install, +configure, and wait. The creative loop starts late. -I wanted to try a different baseline: +[@knighted/develop](https://github.com/knightedcodemonkey/develop) is built +for a different default: fast prototyping from anywhere you can open a browser. -What if the browser is the dev environment? - -That idea became [@knighted/develop](https://github.com/knightedcodemonkey/develop). - -It is a lightweight in-browser UI component workbench built to showcase [@knighted/jsx](https://github.com/knightedcodemonkey/jsx) and [@knighted/css](https://github.com/knightedcodemonkey/css), with dependencies delivered over CDN ESM instead of requiring a local build step in the inner loop. +It is a browser-native editor/workbench for +[@knighted/jsx](https://github.com/knightedcodemonkey/jsx) and +[@knighted/css](https://github.com/knightedcodemonkey/css), delivered through +CDN ESM with mode-aware loading. ## The Loop, In Practice -Open a page, write JSX and styles, switch rendering/style modes, run lint/typecheck, and see results immediately. +Open the app, edit multiple files in dynamic tabs, switch render/style modes, +run lint/type diagnostics, and preview instantly. -No local bundler needed for that loop. +No local bundler is required for that inner loop. -## What Makes It Fun To Use - -The app is intentionally practical, not just a demo shell: +## What The App Gives You +- Dynamic tabbed editing (add, rename, remove, and protect required entry tabs) - Render mode switch: DOM or React - Style mode switch: CSS, CSS Modules, Less, Sass -- Live preview with ShadowRoot toggle -- In-browser lint and type diagnostics -- Diagnostics drawer with jump-to-line navigation (mouse or keyboard) +- Live preview with iframe-isolated style encapsulation +- In-browser lint and type diagnostics with jump-to-source navigation +- GitHub-connected workflows for Open PR and Push Commit +- AI chat with tab-aware edit proposals and explicit apply/undo controls -So it is not only "can this compile?" It is closer to "can I actually iterate on a component quickly?" +This is not only "can this compile?" It is about shipping the whole iteration +loop in one place: edit, validate, preview, sync, and refine. ## Why `@knighted/jsx` + `@knighted/css` Matter Here -`@knighted/develop` is primarily a showcase app. - -It demonstrates how these libraries behave in a real authoring environment: +The app demonstrates both libraries in realistic authoring conditions: -- `@knighted/jsx` gives you a direct path from JSX to rendered output, including DOM-first workflows. -- `@knighted/css` handles modern style pipelines in-browser, including Modules/Less/Sass. +- `@knighted/jsx` provides a direct path from JSX to rendered output, + including DOM-first workflows. +- `@knighted/css` handles modern browser-side style compilation, + including Modules/Less/Sass modes. -Using both together in one interface makes the bigger point obvious: modern browsers can do much more of the compile/authoring cycle than we usually ask them to. +Together they show how much of the authoring cycle modern browsers can run +directly. -## "Compiler-as-a-Service" Without A Backend Build Farm +## "Compiler-as-a-Service" Without A Build Farm In this project, Compiler-as-a-Service means: -- CDN handles module and WASM delivery. -- The browser tab does the actual compile, lint, typecheck, and render work. +- CDN delivers modules and WASM artifacts. +- The browser session performs compile, lint, typecheck, render, and editor + interactions locally. -It is service-oriented distribution, local execution. +It is service-oriented distribution with local execution. -And because loading is mode-aware, you only pay for what you use. If you never touch Sass, you never load Sass. +Mode-aware loading keeps costs aligned to usage: if you do not use Sass, +Sass does not load. ## Why This Matters -This is not trying to replace production pipelines. +This does not replace production pipelines. -It is about lowering the cost of exploration. +It lowers the cost of exploration while preserving enough workflow surface to +be useful for real component work. -When the setup tax drops, you try more ideas. When feedback is instant, you discover faster. And when the browser is the platform, sharing a repro can be as easy as sharing a URL. +When setup friction drops, teams try more ideas. When feedback is immediate, +they converge faster. When browser-native workspaces can sync to GitHub and +carry chat-assisted edit proposals, collaboration is lighter too. -For prototyping and component iteration, that is a meaningful shift. +For prototyping and component development, that is a meaningful shift. ## Try It - Live workbench: https://knightedcodemonkey.github.io/develop/ - Source: https://github.com/knightedcodemonkey/develop -If you are curious, start by toggling: +If you want a fast product tour, try this sequence: -1. DOM -> React render mode -2. CSS -> Modules -> Less -> Sass style mode -3. ShadowRoot on/off +1. Add a new tab, rename it, and make an edit. +2. Toggle DOM -> React render mode. +3. Toggle CSS -> Modules -> Less -> Sass style mode. +4. Open diagnostics and jump to a reported line. +5. Connect BYOT (Bring Your Own Token) by adding a GitHub personal access token, then run Open PR / Push Commit. +6. Ask chat for a targeted tab update, then apply it. -That sequence tells the story better than any architecture diagram. +That flow tells the product story better than any architecture diagram. diff --git a/docs/build-and-deploy.md b/docs/build-and-deploy.md index 8910a0f..1399a9f 100644 --- a/docs/build-and-deploy.md +++ b/docs/build-and-deploy.md @@ -114,6 +114,7 @@ This command forces `KNIGHTED_PRIMARY_CDN=esm` and runs `npm run build` first, t Related docs: - `docs/code-mirror.md` for CodeMirror CDN integration rules, fallback behavior, and validation checklist. +- `docs/dual-build-gh-pages-strategy.md` for the clean two-URL stable and next deployment model during UI migration. - `src/modules/cdn.js` is the source of truth for CDN-managed runtime libraries (including fallback candidates). Add/update CDN specs there instead of hardcoding module URLs inside feature modules. diff --git a/docs/code-mirror.md b/docs/code-mirror.md deleted file mode 100644 index 89f65d6..0000000 --- a/docs/code-mirror.md +++ /dev/null @@ -1,110 +0,0 @@ -# CodeMirror Integration - -This document defines how CodeMirror is integrated in @knighted/develop and what constraints must be preserved when changing editor behavior. - -## Scope - -CodeMirror is used for both authoring panels: - -- Component panel (JSX source) -- Styles panel (CSS, CSS Modules, Less, Sass source) - -The integration is CDN-first and must keep textarea fallback behavior. - -## Integration Files - -- `src/cdn.js`: CDN import keys and provider candidates -- `src/editor-codemirror.js`: shared CodeMirror runtime + editor factory -- `src/app.js`: editor initialization, fallback handling, and value wiring -- `src/styles.css`: editor host styling - -## Runtime Model - -The app initializes CodeMirror asynchronously. - -- On success: both textareas are hidden and CodeMirror views are mounted. -- On failure: textareas remain active and the app keeps rendering normally. - -This fallback is required. Editor failures must never block rendering. - -## CDN Rules - -CodeMirror packages are loaded with `importFromCdnWithFallback` and entries in `cdnImportSpecs`. - -### Important: esm.sh specifier strategy - -Use unversioned `esm` specifiers for the CodeMirror package group: - -- `@codemirror/state` -- `@codemirror/view` -- `@codemirror/commands` -- `@codemirror/autocomplete` -- `@codemirror/language` -- `@codemirror/lang-javascript` -- `@codemirror/lang-css` - -Reason: this lets esm.sh resolve one compatible dependency graph. Mixing pinned versions can load multiple `@codemirror/state` instances and trigger: - -- `Unrecognized extension value in extension set ([object Object])` - -Keep `jspmGa` candidates as fallback entries. - -## Editor Behavior Baseline - -`src/editor-codemirror.js` should continue to include these extensions: - -- line numbers -- active line and gutter highlight -- bracket matching -- close brackets -- autocompletion -- syntax highlighting -- history keymap -- default keymap -- completion keymap -- close-bracket keymap -- `indentOnInput` -- tab size and indent unit - -Language mapping should remain: - -- component editor: `javascript-jsx` -- styles editor: - - `css` and `module` -> css language - - `less` -> less language - - `sass` -> sass language - -## App Wiring Requirements - -In `src/app.js`: - -- Keep `getJsxSource()` and `getCssSource()` abstraction so both CodeMirror and textarea fallback paths work. -- Keep `initializeCodeEditors()` non-blocking (`void initializeCodeEditors()`). -- Keep style language reconfiguration on style mode change. -- Keep textarea input listeners in place for fallback mode. - -## Validation Checklist - -When modifying editor integration: - -1. Run `npm run lint`. -2. Run `npm run dev` and verify: - - CodeMirror mounts in both panels. - - Textareas are hidden on success. - - Auto-close and indentation work while typing. - - Style mode change reconfigures language and still renders. - - Fallback path works if a CodeMirror import fails. -3. Run `npm run build` when CDN import keys are changed. - -## Troubleshooting - -If the UI still looks like plain textarea behavior: - -1. Check for `.cm-editor` nodes in devtools. -2. Check whether `textarea.source-textarea--hidden` is present. -3. Check status text for editor fallback message. -4. Hard reload to clear cached CDN module responses. -5. Inspect console for duplicate-state error: - - `Unrecognized extension value in extension set ([object Object])` - -If duplicate-state error returns, first verify `esm` CodeMirror specifiers in `src/cdn.js` are still unversioned for the full package group. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 875d269..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,120 +0,0 @@ -# Contributing - -Thanks for contributing to `@knighted/develop`. - -This project is a CDN-first UI component workbench for showcasing `@knighted/jsx` -and `@knighted/css`, so local workflows should preserve browser execution -behavior and avoid bundler-only assumptions in `src/` runtime code. - -## Project docs - -- Type checking notes: `docs/type-checking.md` -- Build and deploy notes: `docs/build-and-deploy.md` -- CodeMirror integration notes: `docs/code-mirror.md` -- Roadmap: `docs/next-steps.md` -- Article draft: `docs/article.md` - -## Prerequisites - -- Node.js `>= 22.22.1` -- npm - -## Install - -```bash -npm install -``` - -## Local development - -Start the local app: - -```bash -npm run dev -``` - -The local server opens `src/index.html`. - -## Build commands - -Build prep + CSS + import map generation: - -```bash -npm run build -``` - -Build with explicit primary CDN modes: - -```bash -npm run build:esm -npm run build:jspm -npm run build:importmap-mode -``` - -Preview generated dist output: - -```bash -npm run preview -``` - -## Validation commands - -Lint source and Playwright files: - -```bash -npm run lint -``` - -Type check TS tooling files: - -```bash -npm run check-types -``` - -## Playwright end-to-end tests - -Install browser binaries once: - -```bash -npx playwright install -``` - -If your environment also needs system deps (for example CI-like Linux -containers): - -```bash -npx playwright install --with-deps -``` - -Run preview-mode E2E suite: - -```bash -npm run test:e2e -``` - -Run dev-mode E2E suite: - -```bash -npm run test:e2e:dev -``` - -Run preview-mode suite headed: - -```bash -npm run test:e2e:headed -``` - -## Contributor checklist - -Before opening a PR: - -1. Run `npm run lint`. -2. Run `npm run build:esm` for runtime/build changes. -3. Run relevant Playwright tests for UI/runtime changes. -4. Update docs when user-facing behavior or workflows change. - -## Scope guidance - -- Keep changes focused to `@knighted/develop`. -- Preserve CDN-first loading and fallback behavior. -- Avoid editing generated output unless explicitly required. diff --git a/docs/idb-workspace-state.md b/docs/idb-workspace-state.md new file mode 100644 index 0000000..af9438b --- /dev/null +++ b/docs/idb-workspace-state.md @@ -0,0 +1,77 @@ +# IndexedDB Workspace State Ownership + +This document is the source of truth for what `@knighted/develop` stores in IndexedDB. + +## Storage Location + +- Database: `knighted-develop-workspaces` +- Object store: `prWorkspaces` + +## Canonical State In IDB + +IndexedDB is the canonical source for workspace and pull request context. + +Each workspace record may include: + +- Workspace identity and timing: + - `id` + - `createdAt` + - `lastModified` + - `workspaceScope` (`local` | `repository`) +- Repository and PR context: + - `repo` + - `base` + - `head` + - `prTitle` + - `prNumber` + - `prContextState` (`inactive` | `active` | `closed`) +- Runtime/editor state: + - `renderMode` + - `activeTabId` + - `tabs[]` including content, dirty state, sync metadata, and file paths + +## Why IDB Is Canonical + +Workspace restore and PR workflow continuity require structured, durable records. + +IDB supports that by storing: + +- Full workspace snapshots +- Repo-scoped context records +- Historical transitions such as closed PR context + +## Design Rule + +If a value is required to accurately restore PR/workspace behavior after reload, it must live in IDB records. + +`localStorage` should only mirror user preferences and lightweight bootstrap values. + +PR metadata boundary: + +- PR drawer form edits are draft-only input. +- Persisted PR metadata in workspace records is updated on successful workflow outcomes + (Open PR, Push Commit, Close/verified closed updates), not on each form edit. + +## Post-Push Baseline Invariant + +After a successful Push Commit action for an active PR workspace: + +- The active workspace record must persist immediately in IDB. +- Any committed tab path returned by push updates must persist with: + - `isDirty = false` + - `syncedContent = content` + - `syncedAt` updated to the push/reconcile time + - `lastSyncedRemoteSha` set when a commit SHA is available +- The same clean baseline must survive a full page reload. + +Dirty-state note: + +- When `syncedContent` is present for a tab, canonical dirty state is derived from + `content !== syncedContent`. +- This prevents stale UI-only dirty flags from overriding persisted sync baseline truth. + +## Behavioral Spec + +For action-level drawer semantics and state machine behavior, see: + +- `docs/workspaces-behavior-algorithm.md` diff --git a/docs/localstorage-state.md b/docs/localstorage-state.md new file mode 100644 index 0000000..ae273a2 --- /dev/null +++ b/docs/localstorage-state.md @@ -0,0 +1,38 @@ +# localStorage State Ownership + +This document is the source of truth for what `@knighted/develop` stores in `localStorage`. + +## Allowed Keys + +`localStorage` is intentionally limited to lightweight user preference/session keys: + +1. `knighted:develop:github-pat` + - GitHub personal access token used for API calls. +2. `knighted-develop:render-mode` + - Last selected render mode (`dom` or `react`). +3. Theme/UI preference keys managed by layout theme modules. + +## Not Allowed In localStorage + +Do not store pull request context in `localStorage`. + +Examples that must stay out of `localStorage`: + +- Selected repository preference (`owner/repo`) +- PR context state (`active`, `closed`, `inactive`) +- PR number and URL +- PR base/head/title/body +- PR drawer repository-scoped workflow state +- Workspace tab snapshots and synced file metadata + +## Design Rule + +`localStorage` is for lightweight bootstrap preferences only. + +If data is needed to restore workspace or pull request workflow state, it belongs in IndexedDB workspace records. + +Repository selection is derived from in-memory BYOT controls and IndexedDB-backed workspace records, not from a dedicated localStorage key. + +For the Workspaces drawer action/state algorithm, see: + +- `docs/workspaces-behavior-algorithm.md` diff --git a/docs/media/develop-ide-flow.gif b/docs/media/develop-ide-flow.gif index 7acc79a..236c106 100644 Binary files a/docs/media/develop-ide-flow.gif and b/docs/media/develop-ide-flow.gif differ diff --git a/docs/next-steps.md b/docs/next-steps.md index feee14b..36e6edc 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -19,33 +19,17 @@ Focused follow-up work for `@knighted/develop`. - Suggested implementation prompt: - "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run." -4. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** - - Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App. - - Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable. - - Preserve existing runtime behavior and editor content semantics while adding tab switching, active tab indication, and predictable persistence/reset behavior consistent with current app patterns. - - Ensure assistant/editor integration remains compatible with this model (edits should target one of the fixed tabs) without expanding to dynamic tab metadata yet. - - Suggested implementation prompt: - - "Implement Phase 2 UX/UI tab support in @knighted/develop with a fixed first-pass tab model: Component, Styles, and App only (no arbitrary tab names yet). Add a clear tab UI for switching editor panes, preserve existing editor behavior/content wiring, and keep render/lint/typecheck/diagnostics flows working with the selected tab context where relevant. Keep AI chat feature-flag behavior unchanged while keeping PR/BYOT controls available by default, maintain CDN-first runtime constraints, and do not add dependencies. Add targeted Playwright coverage for tab switching, default/active tab behavior, and interactions with existing render/style-mode flows. Validate with npm run lint and targeted Playwright tests." - -5. **Document implicit App strict-flow behavior (auto render)** - - Add a short behavior matrix in docs that explains when implicit App wrapping is allowed versus when users must define `App` explicitly. - - Include concrete Component editor examples for each case so reviewer/user expectations are clear. - - Suggested example cases to document: - - Allowed implicit wrap (standalone top-level JSX, no imports/declarations), for example: - - `() as any` - - Requires explicit `App` (top-level JSX with declarations/imports), for example: - - `const label = 'Hello'` - - `const Button = () => ` - - `(` - - `const App = () => '].join( - '\n', - ), - ) - - await page.getByRole('button', { name: 'Typecheck' }).click() - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - - await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toHaveClass( - /status--error/, - ) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - const dialog = page.getByRole('dialog') - await ensureDiagnosticsDrawerClosed(page) - await page.getByLabel('Clear styles source').click() - await expect(dialog).toHaveAttribute('open', '') - await dialog.getByRole('button', { name: 'Clear' }).click() - - await expect(page.getByText('Styles cleared', { exact: true })).toHaveClass( - /status--neutral/, - ) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) -}) - test('clear component diagnostics removes type errors and restores rendered status', async ({ page, }) => { @@ -110,13 +53,17 @@ test('clear component diagnostics removes type errors and restores rendered stat ), ) - await page.getByRole('button', { name: 'Typecheck' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText("const count: number = 'oops'") + + await runTypecheck(page) const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toBeVisible() await ensureDiagnosticsDrawerOpen(page) - await page.getByRole('button', { name: 'Reset component' }).click() + await page.getByRole('button', { name: 'Reset types' }).click() await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) await expect(diagnosticsToggle).toHaveText('Diagnostics') @@ -124,46 +71,6 @@ test('clear component diagnostics removes type errors and restores rendered stat await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) }) -test('clear all diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('Style mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('Style compilation failed.')).toBeVisible() - - await page.getByRole('button', { name: 'Reset all' }).click() - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) - await expect(diagnosticsToggle).toHaveText('Diagnostics') - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) -}) - -test('clear styles diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('Style mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('Style compilation failed.')).toBeVisible() - - await page.getByRole('button', { name: 'Reset styles' }).click() - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) - await expect(diagnosticsToggle).toHaveText('Diagnostics') - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) -}) - test('typecheck success reports ok diagnostics state in button and drawer', async ({ page, }) => { @@ -247,6 +154,99 @@ test('dom mode typecheck resolves @knighted/jsx type-only imports', async ({ pag expect(diagnosticsText).not.toContain("Cannot find module '@knighted/jsx'") }) +test('typecheck resolves .js import to workspace tsx module tab', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + kind: 'component', + source: [ + 'type ThingProps = { label: string }', + 'export const Thing = ({ label }: ThingProps) =>

{label}

', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import { Thing } from './module.js'", + 'const App = () => ', + '', + ].join('\n'), + ) + + await runTypecheck(page) + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-component')).toContainText( + 'No TypeScript errors found.', + ) + + const diagnosticsText = await page.locator('#diagnostics-component').innerText() + expect(diagnosticsText).not.toContain("Cannot find module './module.js'") +}) + +test('typecheck resolves parent-relative .js import to workspace tsx module tab', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + kind: 'component', + source: [ + 'type ThingProps = { label: string }', + 'export const Thing = ({ label }: ThingProps) =>

{label}

', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import { Thing } from '../components/module.js'", + 'const App = () => ', + '', + ].join('\n'), + ) + + await runTypecheck(page) + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-component')).toContainText( + 'No TypeScript errors found.', + ) + + const diagnosticsText = await page.locator('#diagnostics-component').innerText() + expect(diagnosticsText).not.toContain("Cannot find module '../components/module.js'") +}) + +test('typecheck does not report TS2307 for stylesheet side-effect imports', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await setComponentEditorSource( + page, + ["import '../styles/app.css'", '', 'const App = () =>

style import

', ''].join( + '\n', + ), + ) + + await runTypecheck(page) + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-component')).toContainText( + 'No TypeScript errors found.', + ) + + const diagnosticsText = await page.locator('#diagnostics-component').innerText() + expect(diagnosticsText).not.toContain("Cannot find module '../styles/app.css'") +}) + test('component diagnostics rows navigate editor to reported line', async ({ page }) => { await waitForInitialRender(page) @@ -331,6 +331,21 @@ test('component lint error reports diagnostics count and details', async ({ page await expect(page.getByText('Biome reported issues.')).toBeVisible() }) +test('component lint reports missing button type prop', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource(page, 'const App = () => ') + + await runComponentLint(page) + + await waitForLintDiagnosticsIssues(page, { + rerunLint: async () => { + await runComponentLint(page) + }, + }) + await expect(page.getByText(/a11y\/useButtonType/)).toBeVisible() +}) + test('styles diagnostics rows navigate editor to reported line', async ({ page }) => { await waitForInitialRender(page) @@ -342,10 +357,11 @@ test('styles diagnostics rows navigate editor to reported line', async ({ page } await runStylesLint(page) - await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( - /diagnostics-toggle--error/, - ) - await ensureDiagnosticsDrawerOpen(page) + await waitForLintDiagnosticsIssues(page, { + rerunLint: async () => { + await runStylesLint(page) + }, + }) const targetDiagnostic = page.getByRole('button', { name: /^L3(:\d+)?\s/ }).first() await expect(targetDiagnostic).toBeVisible() @@ -355,6 +371,41 @@ test('styles diagnostics rows navigate editor to reported line', async ({ page } await expect.poll(() => getActiveStylesEditorLineNumber(page)).toBe('3') }) +test('styles lint reports CSS syntax errors', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + await setStylesEditorSource(page, ['p {', ' color green;', '}'].join('\n')) + + await runStylesLint(page) + + await waitForLintDiagnosticsIssues(page, { + rerunLint: async () => { + await runStylesLint(page) + }, + }) + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Biome reported issues.', + ) +}) + +test('sass compiler warnings surface in styles diagnostics', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') + await setStylesEditorSource( + page, + ['.card {', ' color: darken(#ff0000, 10%);', '}'].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Style compilation warnings.', + ) +}) + test('clear component diagnostics resets rendered lint-issue status pill', async ({ page, }) => { @@ -377,9 +428,9 @@ test('clear component diagnostics resets rendered lint-issue status pill', async ) await ensureDiagnosticsDrawerOpen(page) - await page.getByRole('button', { name: 'Reset component' }).click() + await page.getByRole('button', { name: 'Reset lint' }).click() - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) + await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') await expect(diagnosticsToggle).toHaveText('Diagnostics') await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) @@ -416,7 +467,7 @@ test('component lint ignores only unused App binding', async ({ page }) => { expect(diagnosticsText).toContain('This function render is unused') }) -test('component lint with unresolved issues enters pending diagnostics state while typing', async ({ +test('component lint with unresolved issues becomes stale and waits for manual rerun', async ({ page, }) => { await waitForInitialRender(page) @@ -439,43 +490,147 @@ test('component lint with unresolved issues enters pending diagnostics state whi ), ) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--pending/) - await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'true') - - await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toBeVisible() - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'false') + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Source changed. Click Lint to run diagnostics.', + ) + + await expect(page.getByText('Rendered', { exact: true })).toBeVisible() +}) + +test('styles active tab shows lint-only diagnostics drawer actions', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + await ensureDiagnosticsDrawerOpen(page) + + await expect(page.locator('[data-diagnostics-scope="component"]')).toBeHidden() + await expect(page.locator('#diagnostics-clear-styles')).toHaveText('Reset lint') + await expect(page.locator('#diagnostics-clear-styles')).toBeVisible() + await expect(page.locator('#diagnostics-clear-component')).toBeHidden() + await expect(page.locator('#diagnostics-clear-all')).toBeHidden() }) -test('changing css dialect resets diagnostics after lint and typecheck runs', async ({ +test('component lint completion is ignored after switching to another component tab', async ({ page, }) => { await waitForInitialRender(page) - await ensurePanelToolsVisible(page, 'styles') + + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page) + + const heavyLintSource = [ + ...Array.from({ length: 120 }, (_, index) => `const unused${index} = ${index}`), + 'const App = () => ', + ].join('\n') + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + kind: 'component', + source: heavyLintSource, + }) + + const lintTrigger = page.getByRole('button', { name: 'Lint' }).first() + await lintTrigger.click() await setComponentEditorSource( page, - [ - "const broken: number = 'oops'", - 'const unusedValue = 1', - 'const App = () => ', - ].join('\n'), + 'const App = () => ', + ) + + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Source changed. Click Lint to run diagnostics.', + ) + await expect(page.locator('#diagnostics-styles')).not.toContainText( + 'Biome reported issues.', + ) + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( + /diagnostics-toggle--neutral/, + ) +}) + +test('switching tabs clears diagnostics while drawer is open', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource(page, 'const App = () => ') + await runComponentLint(page) + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Biome reported issues.', + ) + + await page.getByRole('button', { name: 'Open tab app.css' }).click() + + await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( + /diagnostics-toggle--neutral/, + ) +}) + +test('same-tab edits with drawer open replace lint issues with stale state', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ['const count: string = 1', 'const App = () => '].join('\n'), ) await runTypecheck(page) await runComponentLint(page) + await ensureDiagnosticsDrawerOpen(page) - const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + await expect(page.locator('#diagnostics-component')).toContainText('TypeScript found') + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Biome reported issues.', + ) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) - await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) + await setComponentEditorSource( + page, + [ + 'const count: string = "ok"', + 'const App = () => ', + ].join('\n'), + ) - await page.getByLabel('Style mode').selectOption('less') + await expect(page.locator('#diagnostics-component')).not.toContainText('TS2322') + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Source changed. Click Lint to run diagnostics.', + ) + await expect(page.locator('#diagnostics-styles')).not.toContainText( + 'Biome reported issues.', + ) +}) - await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) - await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) - await expect(diagnosticsToggle).toHaveText('Diagnostics') +test('reset lint on styles tab clears in-flight component lint state', async ({ + page, +}) => { + await waitForInitialRender(page) + + const heavyLintSource = [ + ...Array.from({ length: 120 }, (_, index) => `const unused${index} = ${index}`), + 'const App = () => ', + ].join('\n') + + await setComponentEditorSource(page, heavyLintSource) await ensureDiagnosticsDrawerOpen(page) - await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) + await page.getByRole('button', { name: 'Lint' }).first().click() + + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await page.locator('#diagnostics-clear-styles').click() + + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveAttribute( + 'aria-busy', + 'false', + ) + await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') + await expect(page.locator('#diagnostics-styles')).not.toContainText( + 'Biome reported issues.', + ) }) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index ec00e48..8d3c559 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github-api.js' +import { defaultGitHubChatModel } from '../src/modules/github/api/chat.js' import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' import { appEntryPath, @@ -7,6 +7,7 @@ import { ensureAiChatDrawerOpen, ensureOpenPrDrawerOpen, mockRepositoryBranches, + openWorkspaceTab, setComponentEditorSource, setStylesEditorSource, waitForAppReady, @@ -23,6 +24,11 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a exact: true, includeHidden: true, }) + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + includeHidden: true, + }) await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() @@ -30,6 +36,8 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(page.getByRole('heading', { name: 'AI Chat' })).toBeHidden() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() + await expect(workspacesToggle).toHaveCount(1) + await expect(workspacesToggle).toBeHidden() }) test('chat becomes available after token connect', async ({ page }) => { @@ -37,9 +45,20 @@ test('chat becomes available after token connect', async ({ page }) => { await connectByotWithSingleRepo(page) await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Workspaces' })).toBeVisible() await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() }) +test('workspace context status is visible only after PAT connect', async ({ page }) => { + await waitForAppReady(page) + + const workspaceContextStatus = page.locator('#workspace-context-status') + await expect(workspaceContextStatus).toBeHidden() + + await connectByotWithSingleRepo(page) + await expect(workspaceContextStatus).toBeVisible() +}) + test('BYOT controls render with default app entry', async ({ page }) => { await waitForAppReady(page, appEntryPath) @@ -49,12 +68,19 @@ test('BYOT controls render with default app entry', async ({ page }) => { exact: true, includeHidden: true, }) + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + includeHidden: true, + }) await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() + await expect(workspacesToggle).toHaveCount(1) + await expect(workspacesToggle).toBeHidden() }) test('GitHub token info panel reflects missing and present token states', async ({ @@ -216,6 +242,15 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { expect(streamRequestBody?.messages?.[0]?.content).toContain( 'expert software development assistant focused on CSS dialects and JSX syntax', ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'JSX is compiled for @knighted/jsx DOM runtime', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Do not suggest React imports, hooks, or React-only runtime APIs', + ) + expect(streamRequestBody?.messages?.[0]?.content).toContain( + 'Preserve the selected style dialect and avoid cross-dialect rewrites', + ) const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) @@ -233,6 +268,23 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { message.content?.includes('Editor context:'), ), ).toBe(true) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('App.tsx'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) }) test('AI chat can disable editor context payload via checkbox', async ({ page }) => { @@ -257,7 +309,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await connectByotWithSingleRepo(page) await ensureAiChatDrawerOpen(page) - const includeEditorsToggle = page.getByLabel('Send JSX + CSS editor context') + const includeEditorsToggle = page.getByLabel('Send tab content') await expect(includeEditorsToggle).toBeChecked() await includeEditorsToggle.uncheck() @@ -291,7 +343,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) ).toBe(false) }) -test('AI chat proposals can be confirmed, applied, and undone for component and styles editors', async ({ +test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ page, }) => { await page.route('https://models.github.ai/inference/chat/completions', async route => { @@ -322,7 +374,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'component', + target: 'src/components/App.tsx', content: 'const App = () => ', rationale: 'Use explicit App component output.', }), @@ -334,7 +386,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', rationale: 'Provide deterministic button styling.', }), @@ -352,6 +404,7 @@ test('AI chat proposals can be confirmed, applied, and undone for component and await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') await ensureAiChatDrawerOpen(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') @@ -361,53 +414,65 @@ test('AI chat proposals can be confirmed, applied, and undone for component and page.getByText('Prepared updates for both editors.', { exact: true }), ).toBeVisible() - const assistantResponseMessage = page - .locator('.ai-chat-message--assistant') - .filter({ hasText: 'Prepared updates for both editors.' }) - .first() + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() + + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toBeHidden() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to both editors' }).click() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Undo last apply for app.css' }), ).toBeHidden() await expect( - page.getByRole('button', { name: 'Apply update to Component editor' }), - ).toBeHidden() + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Updated') + + await openWorkspaceTab(page, 'app.css') await expect( - page.getByRole('button', { name: 'Apply update to Styles editor' }), + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), ).toBeHidden() await expect( - assistantResponseMessage.getByRole('button', { name: 'Undo last Component apply' }), - ).toHaveCount(0) + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to app.css' }).click() + await expect( - assistantResponseMessage.getByRole('button', { name: 'Undo last Styles apply' }), - ).toHaveCount(0) + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('rgb(10 20 30)') await expect( - page.getByRole('button', { name: 'Undo last Component apply' }), + page.getByRole('button', { name: 'Undo last apply for app.css' }), ).toBeVisible() - await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).toBeVisible() - await expect(page.locator('.component-panel .cm-content').first()).toContainText( - 'Updated', - ) - await expect(page.locator('.styles-panel .cm-content').first()).toContainText( - 'rgb(10 20 30)', - ) + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeHidden() - await page.getByRole('button', { name: 'Undo last Component apply' }).click() - await expect(page.locator('.component-panel .cm-content').first()).toContainText( - 'Before', - ) + await page.getByRole('button', { name: 'Undo last apply for app.css' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="styles"] .cm-content').first(), + ).toContainText('red') - await page.getByRole('button', { name: 'Undo last Styles apply' }).click() - await expect(page.locator('.styles-panel .cm-content').first()).toContainText('red') + await openWorkspaceTab(page, 'App.tsx') + await expect( + page.getByRole('button', { name: 'Undo last apply for App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeHidden() + + await page.getByRole('button', { name: 'Undo last apply for App.tsx' }).click() + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Before') }) -test('AI chat shows a single apply action when both editor proposals are available', async ({ - page, -}) => { +test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { await page.route('https://models.github.ai/inference/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null @@ -436,7 +501,7 @@ test('AI chat shows a single apply action when both editor proposals are availab function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'component', + target: 'src/components/App.tsx', content: 'const App = () => ', }), }, @@ -447,7 +512,7 @@ test('AI chat shows a single apply action when both editor proposals are availab function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', }), }, @@ -464,6 +529,7 @@ test('AI chat shows a single apply action when both editor proposals are availab await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') + await openWorkspaceTab(page, 'App.tsx') await ensureAiChatDrawerOpen(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') @@ -474,14 +540,216 @@ test('AI chat shows a single apply action when both editor proposals are availab ).toBeVisible() await expect( - page.getByRole('button', { name: 'Apply update to both editors' }), + page.getByRole('button', { name: 'Apply update to App.tsx' }), ).toBeVisible() await expect( - page.getByRole('button', { name: 'Apply update to Component editor' }), - ).toBeHidden() + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() + + await openWorkspaceTab(page, 'app.css') + await expect( - page.getByRole('button', { name: 'Apply update to Styles editor' }), - ).toBeHidden() + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Apply update to app.css' }), + ).toBeVisible() +}) + +test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ + page, +}) => { + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for App tab.', + tool_calls: [ + { + id: 'call_unresolved', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/missing.tsx', + content: 'const Missing = () => null', + }), + }, + }, + { + id: 'call_component', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () =>

Resolved update

', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Update App tab only.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByRole('button', { name: 'Apply update to App.tsx' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Apply update to App.tsx' }).click() + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Resolved update') +}) + +test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({ + page, +}) => { + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: 'Prepared updates for App tab.', + tool_calls: [ + { + id: 'call_component_id', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'component', + content: 'const App = () =>

By id

', + }), + }, + }, + { + id: 'call_component_path', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/components/App.tsx', + content: 'const App = () =>

By path

', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>

Before

') + await openWorkspaceTab(page, 'App.tsx') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Update App tab once.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByRole('button', { name: 'Apply update to App.tsx' })).toHaveCount( + 1, + ) +}) + +test('AI chat sends the currently active tab when context is enabled', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setStylesEditorSource(page, '.button { color: red; }') + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Use active tab context only.') + await page.getByRole('button', { name: 'Send' }).click() + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toHaveText('Response streamed from GitHub.') + + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some( + (message: ChatRequestMessage) => + message.content?.includes('- Active tab:') && + message.content?.includes('app.css'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Active tab source:'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Available tab targets (id and path):'), + ), + ).toBe(true) }) test('AI chat streaming text still updates while latest undo actions are visible', async ({ @@ -519,7 +787,7 @@ test('AI chat streaming text still updates while latest undo actions are visible function: { name: 'propose_editor_update', arguments: JSON.stringify({ - target: 'styles', + target: 'src/styles/app.css', content: '.button { color: rgb(10 20 30); }', }), }, @@ -569,8 +837,10 @@ test('AI chat streaming text still updates while latest undo actions are visible await expect( page.getByText('Prepared updates for styles editor.', { exact: true }), ).toBeVisible() - await page.getByRole('button', { name: 'Apply update to Styles editor' }).click() - await expect(page.getByRole('button', { name: 'Undo last Styles apply' })).toBeVisible() + await page.getByRole('button', { name: 'Apply update to app.css' }).click() + await expect( + page.getByRole('button', { name: 'Undo last apply for app.css' }), + ).toBeVisible() await page .getByLabel('Ask AI assistant') @@ -743,21 +1013,32 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => { .fill('github_pat_fake_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() - await ensureOpenPrDrawerOpen(page) - const repoSelect = page.getByLabel('Pull request repository') - await expect(repoSelect).toBeEnabled() + await expect(repoSelect).toBeDisabled() await expect(page.getByRole('status', { name: 'App status' })).toHaveText( 'Loaded 2 writable repositories', ) - await repoSelect.selectOption('knightedcodemonkey/develop') + await page.getByRole('button', { name: 'Workspaces' }).click() + const workspaceRepositoryFilter = page.getByLabel('Workspace repository filter') + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) + await expect(workspaceRepositoryFilter).toBeVisible() + await workspaceRepositoryFilter.selectOption('knightedcodemonkey/develop') + await expect(workspaceRepositoryFilter).toHaveValue('knightedcodemonkey/develop') + + await expect(initializeButton).toBeVisible() + await initializeButton.click() + + await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') await page.reload() await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() await expect(page.getByRole('status', { name: 'App status' })).toHaveText( - 'Loaded 2 writable repositories', + /Loaded 2 writable repositories|Rendered/, { timeout: 60_000, }, @@ -766,4 +1047,5 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => { await expect(page.getByRole('button', { name: 'Delete GitHub token' })).toBeVisible() await ensureOpenPrDrawerOpen(page) await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() }) diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts deleted file mode 100644 index bf31180..0000000 --- a/playwright/github-pr-drawer.spec.ts +++ /dev/null @@ -1,2092 +0,0 @@ -import { expect, test } from '@playwright/test' -import type { Page } from '@playwright/test' -import type { - CreateRefRequestBody, - PullRequestCreateBody, -} from './helpers/app-test-helpers.js' -import { - appEntryPath, - connectByotWithSingleRepo, - ensureOpenPrDrawerOpen, - mockRepositoryBranches, - setComponentEditorSource, - setStylesEditorSource, - waitForAppReady, -} from './helpers/app-test-helpers.js' - -const defaultCommitMessage = 'chore: sync editor updates from @knighted/develop' - -const decodeGitHubFileBodyContent = (body: Record) => { - const encoded = typeof body.content === 'string' ? body.content : '' - return Buffer.from(encoded, 'base64').toString('utf8') -} - -const getOpenPrDrawer = (page: Page) => - page.getByRole('complementary', { name: /Open Pull Request|Push Commit/ }) - -const clickOpenPrDrawerSubmit = async (page: Page) => { - const drawer = getOpenPrDrawer(page) - await expect(drawer).toBeVisible() - const submitButton = drawer.getByRole('button', { name: 'Open PR' }) - await expect(submitButton).toBeEnabled() - /* - * NOTE: WebKit's HTML Top Layer behavior can cause Playwright - * actionability checks to fail or time out, even when the control is - * visibly ready and works in Safari. - * - * Keep this evaluate-based click because standard locator.click() and - * locator.click({ force: true }) have been flaky here and can fail to - * resolve the hit target for this drawer flow. - */ - await submitButton.evaluate(element => { - if (element instanceof HTMLButtonElement) { - element.click() - } - }) -} - -const triggerOpenPrConfirmation = async (page: Page) => { - await clickOpenPrDrawerSubmit(page) - const dialog = page.locator('#clear-confirm-dialog') - await expect(dialog).toBeVisible() - return dialog -} - -const submitOpenPrAndConfirm = async ( - page: Page, - { - expectedSummaryLines, - }: { - expectedSummaryLines?: string[] - } = {}, -) => { - const dialog = await triggerOpenPrConfirmation(page) - - for (const line of expectedSummaryLines ?? []) { - await expect(dialog.getByText(line, { exact: true })).toBeVisible() - } - - /* Same WebKit Top Layer issue applies to the confirm button. */ - await dialog.locator('button[value="confirm"]').evaluate(element => { - if (element instanceof HTMLButtonElement) { - element.click() - } - }) -} - -const expectOpenPrConfirmationPrompt = async (page: Page) => { - const dialog = await triggerOpenPrConfirmation(page) - await expect(dialog).toBeVisible() -} - -const removeSavedGitHubToken = async (page: Page) => { - await page.getByRole('button', { name: 'Delete GitHub token' }).click() - - const dialog = page.getByRole('dialog', { - name: 'Remove saved GitHub token?', - includeHidden: true, - }) - - await expect(dialog).toHaveAttribute('open', '') - await dialog.getByRole('button', { name: 'Remove' }).click() - await expect(dialog).not.toHaveAttribute('open', '') -} - -test('Open PR drawer confirms and submits component/styles filepaths', async ({ - page, -}) => { - const customCommitMessage = 'chore: sync develop editor outputs' - let createdRefBody: CreateRefRequestBody | null = null - const upsertRequests: Array<{ path: string; body: Record }> = [] - let pullRequestBody: PullRequestCreateBody | null = null - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/main', - object: { type: 'commit', sha: 'abc123mainsha' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - createdRefBody = route.request().postDataJSON() as CreateRefRequestBody - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const url = request.url() - const path = new URL(url).pathname.split('/contents/')[1] ?? '' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - pullRequestBody = route.request().postDataJSON() as PullRequestCreateBody - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 42, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/42', - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.getByLabel('Head').fill('Develop/Open-Pr-Test') - await page.getByLabel('Component filename').fill('examples/component/App.tsx') - await page.getByLabel('Styles filename').fill('examples/styles/app.css') - await page.getByLabel('PR title').fill('Apply editor updates from develop') - await page - .getByLabel('PR description') - .fill('Generated from editor content in @knighted/develop.') - await page.getByLabel('Commit message').fill(customCommitMessage) - - await submitOpenPrAndConfirm(page, { - expectedSummaryLines: [ - 'Open pull request with editor content?', - 'Component file path: examples/component/App.tsx', - 'Styles file path: examples/styles/app.css', - ], - }) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText( - 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/42', - ) - - const createdRefPayload = createdRefBody as CreateRefRequestBody | null - const pullRequestPayload = pullRequestBody as PullRequestCreateBody | null - - expect(createdRefPayload?.ref).toBe('refs/heads/Develop/Open-Pr-Test') - expect(createdRefPayload?.sha).toBe('abc123mainsha') - - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(customCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(customCommitMessage) - expect(pullRequestPayload?.head).toBe('Develop/Open-Pr-Test') - expect(pullRequestPayload?.base).toBe('main') - - await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Component filename')).toHaveValue( - 'examples/component/App.tsx', - ) - await expect(page.getByLabel('Styles filename')).toHaveValue('examples/styles/app.css') - await expect(page.getByLabel('Pull request base branch')).toHaveValue('main') - await expect(page.getByLabel('Head')).toHaveValue('Develop/Open-Pr-Test') - await expect(page.getByLabel('PR title')).toHaveValue( - 'Apply editor updates from develop', - ) - await expect(page.getByLabel('PR description')).toBeHidden() - await expect(page.getByLabel('Commit message')).toBeVisible() - await expect(page.getByLabel('Commit message')).toHaveValue(customCommitMessage) - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Close active pull request context' }), - ).toBeVisible() -}) - -test('Open PR drawer starts with empty title/description and short default head', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - const headValue = await page.getByLabel('Head').inputValue() - expect(headValue).toMatch(/^feat\/component-[a-z0-9]{4}$/) - await expect(page.getByLabel('PR title')).toHaveValue('') - await expect(page.getByLabel('PR description')).toHaveValue('') -}) - -test('Open PR drawer base dropdown updates from mocked repo branches', async ({ - page, -}) => { - const branchRequestUrls: string[] = [] - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 2, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - { - id: 1, - owner: { login: 'knightedcodemonkey' }, - name: 'css', - full_name: 'knightedcodemonkey/css', - default_branch: 'stable', - permissions: { push: true }, - }, - ]), - }) - }) - - await page.route('https://api.github.com/repos/**/branches**', async route => { - const url = route.request().url() - branchRequestUrls.push(url) - - const branchNames = url.includes('/repos/knightedcodemonkey/css/branches') - ? ['stable', 'release/1.x'] - : ['main', 'develop-next'] - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(branchNames.map(name => ({ name }))), - }) - }) - - await waitForAppReady(page, `${appEntryPath}`) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - await expect(page.getByRole('status', { name: 'App status' })).toHaveText( - 'Loaded 2 writable repositories', - ) - - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.getByLabel('Pull request repository') - const baseSelect = page.getByLabel('Pull request base branch') - - await repoSelect.selectOption('knightedcodemonkey/develop') - await expect(baseSelect).toHaveValue('main') - await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next']) - - await repoSelect.selectOption('knightedcodemonkey/css') - await expect(baseSelect).toHaveValue('stable') - await expect(baseSelect.getByRole('option')).toHaveText(['stable', 'release/1.x']) - - expect( - branchRequestUrls.some(url => - url.includes('https://api.github.com/repos/knightedcodemonkey/develop/branches'), - ), - ).toBe(true) - expect( - branchRequestUrls.some(url => - url.includes('https://api.github.com/repos/knightedcodemonkey/css/branches'), - ), - ).toBe(true) -}) - -test('Open PR drawer keeps a single active PR context in localStorage', async ({ - page, -}) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 2, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - { - id: 1, - owner: { login: 'knightedcodemonkey' }, - name: 'css', - full_name: 'knightedcodemonkey/css', - default_branch: 'stable', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'develop-next'], - 'knightedcodemonkey/css': ['stable', 'release/1.x'], - }) - - await waitForAppReady(page, `${appEntryPath}`) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.getByLabel('Pull request repository') - const componentPath = page.getByLabel('Component filename') - - await repoSelect.selectOption('knightedcodemonkey/develop') - await componentPath.fill('examples/develop/App.tsx') - await componentPath.blur() - - await repoSelect.selectOption('knightedcodemonkey/css') - await componentPath.fill('examples/css/App.tsx') - await componentPath.blur() - - const activeContext = await page.evaluate(() => { - const storagePrefix = 'knighted:develop:github-pr-config:' - const keys = Object.keys(localStorage).filter(key => key.startsWith(storagePrefix)) - const key = keys[0] ?? null - const raw = key ? localStorage.getItem(key) : null - - let parsed = null - try { - parsed = raw ? JSON.parse(raw) : null - } catch { - parsed = null - } - - return { keys, key, parsed } - }) - - expect(activeContext.keys).toHaveLength(1) - expect(activeContext.key).toBe( - 'knighted:develop:github-pr-config:knightedcodemonkey/css', - ) - expect(activeContext.parsed?.componentFilePath).toBe('examples/css/App.tsx') -}) - -test('Open PR drawer does not prune saved PR context on repo switch before save', async ({ - page, -}) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 2, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - { - id: 1, - owner: { login: 'knightedcodemonkey' }, - name: 'css', - full_name: 'knightedcodemonkey/css', - default_branch: 'stable', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'develop-next'], - 'knightedcodemonkey/css': ['stable', 'release/1.x'], - }) - - await waitForAppReady(page, `${appEntryPath}`) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.getByLabel('Pull request repository') - const componentPath = page.getByLabel('Component filename') - - await repoSelect.selectOption('knightedcodemonkey/develop') - await componentPath.fill('examples/develop/App.tsx') - await componentPath.blur() - - await repoSelect.selectOption('knightedcodemonkey/css') - - const contexts = await page.evaluate(() => { - const storagePrefix = 'knighted:develop:github-pr-config:' - const keys = Object.keys(localStorage) - .filter(key => key.startsWith(storagePrefix)) - .sort((left, right) => left.localeCompare(right)) - - return keys.map(key => { - const raw = localStorage.getItem(key) - let parsed = null - - try { - parsed = raw ? JSON.parse(raw) : null - } catch { - parsed = null - } - - return { key, parsed } - }) - }) - - expect(contexts).toHaveLength(1) - expect(contexts[0]?.key).toBe( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - ) - expect(contexts[0]?.parsed?.componentFilePath).toBe('examples/develop/App.tsx') -}) - -test('Active PR context disconnect uses local-only confirmation flow', async ({ - page, -}) => { - let closePullRequestRequestCount = 0 - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - if (route.request().method() === 'PATCH') { - closePullRequestRequestCount += 1 - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'closed', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/develop/open-pr-test', - object: { type: 'commit', sha: 'existing-head-sha' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 2, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeVisible() - - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(dialog).toContainText('Disconnect PR context?') - await expect(dialog).toContainText( - 'This will disconnect the active pull request context in this app only.', - ) - await expect(dialog).toContainText('Your pull request will stay open on GitHub.') - await expect(dialog).toContainText( - 'Your GitHub token and selected repository will stay connected.', - ) - - await dialog.getByRole('button', { name: 'Cancel' }).click() - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - const savedActiveStateAfterCancel = await page.evaluate(() => { - const raw = localStorage.getItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - ) - - if (!raw) { - return null - } - - try { - const parsed = JSON.parse(raw) - return parsed?.isActivePr === true - } catch { - return null - } - }) - - expect(savedActiveStateAfterCancel).toBe(true) - - await page - .getByRole('button', { name: 'Disconnect active pull request context' }) - .click() - await dialog.getByRole('button', { name: 'Disconnect' }).click() - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Disconnect active pull request context' }), - ).toBeHidden() - - const savedContextAfterDisconnect = await page.evaluate(() => { - const raw = localStorage.getItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - ) - - if (!raw) { - return null - } - - try { - return JSON.parse(raw) - } catch { - return null - } - }) - - expect(savedContextAfterDisconnect).not.toBeNull() - expect(savedContextAfterDisconnect?.isActivePr).toBe(false) - expect(savedContextAfterDisconnect?.pullRequestNumber).toBe(2) - expect(closePullRequestRequestCount).toBe(0) -}) - -test('Active PR context updates controls and can be closed from AI controls', async ({ - page, -}) => { - let closePullRequestRequestCount = 0 - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - if (route.request().method() === 'PATCH') { - closePullRequestRequestCount += 1 - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'closed', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - return - } - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/develop/open-pr-test', - object: { type: 'commit', sha: 'existing-head-sha' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 2, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Close active pull request context' }), - ).toBeVisible() - - await page.getByRole('button', { name: 'Close active pull request context' }).click() - - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(page.getByText('PR: develop/pr/2')).toBeVisible() - await dialog.getByRole('button', { name: 'Close PR on GitHub' }).click() - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Close active pull request context' }), - ).toBeHidden() - - const storedValue = await page.evaluate(() => - localStorage.getItem('knighted:develop:github-pr-config:knightedcodemonkey/develop'), - ) - expect(storedValue).toBeNull() - expect(closePullRequestRequestCount).toBe(1) -}) - -test('Active PR context is disabled on load when pull request is closed', async ({ - page, -}) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'closed', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 2, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect( - page.getByRole('button', { name: 'Close active pull request context' }), - ).toBeHidden() - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Saved pull request context is not open on GitHub.') - - const isActivePr = await page.evaluate(() => { - const raw = localStorage.getItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - ) - if (!raw) { - return null - } - - try { - const parsed = JSON.parse(raw) - return parsed?.isActivePr === true - } catch { - return null - } - }) - - expect(isActivePr).toBe(false) -}) - -test('Active PR context rehydrates after token remove and re-add', async ({ page }) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - { - id: 12, - owner: { login: 'knightedcodemonkey' }, - name: 'css', - full_name: 'knightedcodemonkey/css', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - 'knightedcodemonkey/css': ['main', 'release', 'css/rehydrate-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/css/pulls/7', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 7, - state: 'open', - title: 'Saved css PR context', - html_url: 'https://github.com/knightedcodemonkey/css/pull/7', - head: { ref: 'css/rehydrate-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem('knighted:develop:github-repository', 'knightedcodemonkey/css') - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/css', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'css/rehydrate-test', - prTitle: 'Saved css PR context', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 7, - pullRequestUrl: 'https://github.com/knightedcodemonkey/css/pull/7', - }), - ) - }) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - - await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toHaveValue( - 'knightedcodemonkey/css', - ) - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - await removeSavedGitHubToken(page) - await expect(page.getByRole('status', { name: 'App status' })).toHaveText( - 'GitHub token removed', - ) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - - await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toHaveValue( - 'knightedcodemonkey/css', - ) - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - const selectedRepository = await page.evaluate(() => - localStorage.getItem('knighted:develop:github-repository'), - ) - expect(selectedRepository).toBe('knightedcodemonkey/css') -}) - -test('Active PR context deactivates after token remove and re-add when PR is closed', async ({ - page, -}) => { - let useClosedPullRequest = false - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - { - id: 12, - owner: { login: 'knightedcodemonkey' }, - name: 'css', - full_name: 'knightedcodemonkey/css', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - 'knightedcodemonkey/css': ['main', 'release', 'css/rehydrate-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/css/pulls/7', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 7, - state: useClosedPullRequest ? 'closed' : 'open', - title: 'Saved css PR context', - html_url: 'https://github.com/knightedcodemonkey/css/pull/7', - head: { ref: 'css/rehydrate-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem('knighted:develop:github-repository', 'knightedcodemonkey/css') - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/css', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'css/rehydrate-test', - prTitle: 'Saved css PR context', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 7, - pullRequestUrl: 'https://github.com/knightedcodemonkey/css/pull/7', - }), - ) - }) - - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - await removeSavedGitHubToken(page) - await expect(page.getByRole('status', { name: 'App status' })).toHaveText( - 'GitHub token removed', - ) - - useClosedPullRequest = true - await page - .getByRole('textbox', { name: 'GitHub token' }) - .fill('github_pat_fake_1234567890') - await page.getByRole('button', { name: 'Add GitHub token' }).click() - - await ensureOpenPrDrawerOpen(page) - await expect(page.getByLabel('Pull request repository')).toHaveValue( - 'knightedcodemonkey/css', - ) - await expect( - page.getByRole('button', { name: 'Open pull request', exact: true }), - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'Close active pull request context' }), - ).toBeHidden() - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Saved pull request context is not open on GitHub.') - - const isActivePr = await page.evaluate(() => { - const raw = localStorage.getItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/css', - ) - if (!raw) { - return null - } - - try { - const parsed = JSON.parse(raw) - return parsed?.isActivePr === true - } catch { - return null - } - }) - - expect(isActivePr).toBe(false) -}) - -test('Active PR context recovers when saved head branch is missing but PR metadata exists', async ({ - page, -}) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Recovered PR context title', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: '', - prTitle: 'Recovered PR context title', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 2, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - - await ensureOpenPrDrawerOpen(page) - await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() - await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') -}) - -test('Active PR context uses Push commit flow without creating a new pull request', async ({ - page, -}) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] - let createRefRequestCount = 0 - let pullRequestRequestCount = 0 - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/develop/open-pr-test', - object: { type: 'commit', sha: 'existing-head-sha' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - createRefRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/unexpected' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - pullRequestRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 999, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const url = request.url() - const path = new URL(url).pathname.split('/contents/')[1] ?? '' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestNumber: 2, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await expect(page.getByLabel('Pull request repository')).toBeDisabled() - await expect(page.getByLabel('Pull request base branch')).toBeDisabled() - await expect(page.getByLabel('Head')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('Component filename')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('Styles filename')).toHaveJSProperty('readOnly', true) - await expect(page.getByLabel('PR title')).toHaveJSProperty('readOnly', true) - await expect( - page.getByLabel('Include App wrapper in committed component source'), - ).toBeEnabled() - await expect(page.getByLabel('Commit message')).toBeEditable() - - await expect(page.getByLabel('PR description')).toBeHidden() - await expect(page.getByLabel('Commit message')).toBeVisible() - - const includeWrapperToggle = page.getByLabel( - 'Include App wrapper in committed component source', - ) - await expect(includeWrapperToggle).toBeEnabled() - await includeWrapperToggle.check() - await expect(includeWrapperToggle).toBeChecked() - await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() - await expect(page.getByLabel('PR description')).toBeHidden() - await expect(page.getByLabel('Commit message')).toBeVisible() - - await setComponentEditorSource(page, 'const commitMarker = 1') - await setStylesEditorSource(page, '.commit-marker { color: red; }') - const pushCommitMessage = 'chore: push active context sync' - await page.getByLabel('Commit message').fill(pushCommitMessage) - - await page.getByRole('button', { name: 'Push commit' }).last().click() - - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect( - page.getByText('Push commit to active pull request branch?', { exact: true }), - ).toHaveText('Push commit to active pull request branch?') - await expect( - page.getByText('Head branch: develop/open-pr-test', { exact: true }), - ).toBeVisible() - await expect( - page.getByText('Component file path: examples/component/App.tsx', { exact: true }), - ).toBeVisible() - await expect( - page.getByText('Styles file path: examples/styles/app.css', { exact: true }), - ).toBeVisible() - - await dialog.getByRole('button', { name: 'Push commit' }).click() - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') - - expect(createRefRequestCount).toBe(0) - expect(pullRequestRequestCount).toBe(0) - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(pushCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(pushCommitMessage) -}) - -test('Reloaded active PR context from URL metadata keeps Push mode and status reference', async ({ - page, -}) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] - let createRefRequestCount = 0 - let pullRequestRequestCount = 0 - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/develop/open-pr-test', - object: { type: 'commit', sha: 'existing-head-sha' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - createRefRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/unexpected-branch' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - pullRequestRequestCount += 1 - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 999, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const path = new URL(request.url()).pathname.split('/contents/')[1] ?? '' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - - await expect( - page.getByRole('button', { name: 'Push commit to active pull request branch' }), - ).toBeVisible() - await ensureOpenPrDrawerOpen(page) - await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() - await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') - await expect(page.getByLabel('PR description')).toBeHidden() - await expect(page.getByLabel('Commit message')).toBeVisible() - - await setComponentEditorSource(page, 'const commitMarker = 1') - await setStylesEditorSource(page, '.commit-marker { color: red; }') - - await page.getByRole('button', { name: 'Push commit' }).last().click() - - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await dialog.getByRole('button', { name: 'Push commit' }).click() - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') - - expect(createRefRequestCount).toBe(0) - expect(pullRequestRequestCount).toBe(0) - expect(upsertRequests).toHaveLength(2) - expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx') - expect(upsertRequests[1]?.path).toBe('examples/styles/app.css') - expect(upsertRequests[0]?.body.message).toBe(defaultCommitMessage) - expect(upsertRequests[1]?.body.message).toBe(defaultCommitMessage) -}) - -test('Reloaded active PR context syncs editor content from GitHub branch and restores style mode', async ({ - page, -}) => { - const remoteComponentSource = 'export const App = () =>
Synced from PR
' - const remoteStylesSource = '.synced-from-pr { color: tomato; }' - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const url = new URL(request.url()) - const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') - const ref = url.searchParams.get('ref') - - if (method !== 'GET' || ref !== 'develop/open-pr-test') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - if (path === 'examples/component/App.tsx') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - sha: 'component-sha', - content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), - }), - }) - return - } - - if (path === 'examples/styles/app.css') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - sha: 'styles-sha', - content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), - }), - }) - return - } - - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - styleMode: 'sass', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - await expect(page.getByLabel('Render mode')).toHaveValue('react') - await expect(page.getByLabel('Style mode')).toHaveValue('sass') - - await expect - .poll(async () => - page.evaluate(() => { - const componentEditor = document.getElementById('jsx-editor') - const stylesEditor = document.getElementById('css-editor') - - return { - component: - componentEditor instanceof HTMLTextAreaElement ? componentEditor.value : '', - styles: stylesEditor instanceof HTMLTextAreaElement ? stylesEditor.value : '', - } - }), - ) - .toEqual({ - component: remoteComponentSource, - styles: remoteStylesSource, - }) -}) - -test('Reloaded active PR context falls back to css style mode for unsupported value', async ({ - page, -}) => { - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - number: 2, - state: 'open', - title: 'Existing PR context from storage', - html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', - head: { ref: 'develop/open-pr-test' }, - base: { ref: 'main' }, - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - - await page.evaluate(() => { - localStorage.setItem( - 'knighted:develop:github-pr-config:knightedcodemonkey/develop', - JSON.stringify({ - componentFilePath: 'examples/component/App.tsx', - stylesFilePath: 'examples/styles/app.css', - renderMode: 'react', - styleMode: 'scss', - baseBranch: 'main', - headBranch: 'develop/open-pr-test', - prTitle: 'Existing PR context from storage', - prBody: 'Saved body', - isActivePr: true, - pullRequestUrl: 'https://github.com/knightedcodemonkey/develop/pull/2', - }), - ) - }) - - await connectByotWithSingleRepo(page) - await expect(page.getByLabel('Render mode')).toHaveValue('react') - await expect(page.getByLabel('Style mode')).toHaveValue('css') -}) - -test('Open PR drawer validates unsafe filepaths', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - const componentPath = page.getByLabel('Component filename') - await page.getByLabel('PR title').fill('Validate unsafe paths') - await componentPath.fill('../outside/App.tsx') - await expect(componentPath).toHaveValue('../outside/App.tsx') - await componentPath.blur() - await clickOpenPrDrawerSubmit(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText('Component path: File path cannot include parent directory traversal.') - await expect(page.getByRole('dialog')).toBeHidden() -}) - -test('Open PR drawer allows dotted file segments that are not traversal', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - const componentPath = page.getByLabel('Component filename') - const stylesPath = page.getByLabel('Styles filename') - - await componentPath.fill('docs/v1.0..v1.1/App.tsx') - await stylesPath.fill('styles/foo..bar.css') - await expect(componentPath).toHaveValue('docs/v1.0..v1.1/App.tsx') - await expect(stylesPath).toHaveValue('styles/foo..bar.css') - await page.getByLabel('PR title').fill('Allow dotted file segments') - await stylesPath.blur() - - await expectOpenPrConfirmationPrompt(page) - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).not.toContainText('File path cannot include parent directory traversal.') -}) - -test('Open PR drawer rejects trailing slash file paths', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.getByLabel('Component filename').fill('src/components/') - await page.getByLabel('PR title').fill('Reject trailing slash path') - await clickOpenPrDrawerSubmit(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText( - 'Component path: File path must include a filename (no trailing slash).', - ) - await expect(page.getByRole('dialog')).toBeHidden() -}) - -test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - const includeWrapperToggle = page.getByLabel( - 'Include App wrapper in committed component source', - ) - await expect(includeWrapperToggle).not.toBeChecked() - - await includeWrapperToggle.check() - await expect(includeWrapperToggle).toBeChecked() - - await page.getByRole('button', { name: 'Close open pull request drawer' }).click() - await ensureOpenPrDrawerOpen(page) - - await expect(includeWrapperToggle).not.toBeChecked() -}) - -test('Open PR drawer strips App wrapper from committed component source by default', async ({ - page, -}) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/main', - object: { type: 'commit', sha: 'abc123mainsha' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const path = - new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 101, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - - const componentSource = [ - 'const CounterButton = () => ', - 'const App = () => ', - ].join('\n') - - await setComponentEditorSource(page, componentSource) - await ensureOpenPrDrawerOpen(page) - - await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') - await page.getByLabel('PR title').fill('Strip App wrapper by default') - await submitOpenPrAndConfirm(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText( - 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', - ) - - const componentUpserts = upsertRequests.filter(request => - request.path.endsWith('/App.jsx'), - ) - - expect(componentUpserts).toHaveLength(1) - - const strippedComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) - - expect(strippedComponentSource).toContain('const CounterButton = () =>') - expect(strippedComponentSource).not.toContain('const App = () =>') -}) - -test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ - page, -}) => { - const upsertRequests: Array<{ path: string; body: Record }> = [] - - await page.route('https://api.github.com/user/repos**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify([ - { - id: 11, - owner: { login: 'knightedcodemonkey' }, - name: 'develop', - full_name: 'knightedcodemonkey/develop', - default_branch: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', - async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - ref: 'refs/heads/main', - object: { type: 'commit', sha: 'abc123mainsha' }, - }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', - async route => { - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', - async route => { - const request = route.request() - const method = request.method() - const path = - new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' - - if (method === 'GET') { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify({ message: 'Not Found' }), - }) - return - } - - const body = request.postDataJSON() as Record - upsertRequests.push({ path: decodeURIComponent(path), body }) - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ commit: { sha: 'commit-sha' } }), - }) - }, - ) - - await page.route( - 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', - async route => { - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - number: 101, - html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', - }), - }) - }, - ) - - await waitForAppReady(page, `${appEntryPath}`) - await connectByotWithSingleRepo(page) - - await setComponentEditorSource( - page, - [ - 'const CounterButton = () => ', - 'const App = () => ', - ].join('\n'), - ) - await ensureOpenPrDrawerOpen(page) - - const includeWrapperToggle = page.getByLabel( - 'Include App wrapper in committed component source', - ) - await includeWrapperToggle.check() - - await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') - await page.getByLabel('PR title').fill('Include App wrapper in commit') - await submitOpenPrAndConfirm(page) - - await expect( - page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), - ).toContainText( - 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', - ) - - const componentUpserts = upsertRequests.filter(request => - request.path.endsWith('/App.jsx'), - ) - - expect(componentUpserts).toHaveLength(1) - - const fullComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) - expect(fullComponentSource).toContain('const CounterButton = () =>') - expect(fullComponentSource).toContain('const App = () =>') -}) diff --git a/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts new file mode 100644 index 0000000..34bd55d --- /dev/null +++ b/playwright/github-pr-drawer/active-context-switch-debounce-essential.spec.ts @@ -0,0 +1,750 @@ +import { expect, test } from '@playwright/test' +import type { Route } from '@playwright/test' +import { + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + getWorkspaceTabsRecord, + openStoredWorkspaceContextByHead, + seedLocalWorkspaceContexts, + setComponentEditorSource, + toRecordIntegritySnapshot, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +const repositoryFullName = 'knightedcodemonkey/develop' +const sandboxRepositoryFullName = 'knightedcodemonkey/develop-sandbox' + +const setupSandboxRepositoryRoutes = async ({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest, +}: { + page: Parameters[0] + pHeadBranch: string + ppHeadBranch: string + // eslint-disable-next-line no-unused-vars + onPullRequestRequest?: (_input: { + pullRequestNumber: number + route: Route + }) => Promise +}) => { + const [repositoryOwner, repositoryName] = sandboxRepositoryFullName.split('/') + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: repositoryOwner }, + name: repositoryName, + full_name: sandboxRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/branches**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { name: 'main' }, + { name: pHeadBranch }, + { name: ppHeadBranch }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/pulls**', async route => { + const url = new URL(route.request().url()) + const match = url.pathname.match(/\/pulls\/(\d+)$/) + const pullRequestNumber = match ? Number.parseInt(match[1], 10) : Number.NaN + + if (!Number.isFinite(pullRequestNumber)) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (typeof onPullRequestRequest === 'function') { + await onPullRequestRequest({ pullRequestNumber, route }) + return + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }) +} + +const seedSandboxActivePpContexts = async ({ + page, + pHeadBranch, + ppHeadBranch, +}: { + page: Parameters[0] + pHeadBranch: string + ppHeadBranch: string +}) => { + await seedLocalWorkspaceContexts(page, [ + { + id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + repo: sandboxRepositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: pHeadBranch, + prTitle: 'P', + prNumber: 69, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { P } from '../components/module.js'\nexport const App = () =>

\n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'button { padding: 10px; }\n', + }, + ], + activeTabId: 'component', + }, + { + id: 'ws_d6502674-64fd-46a6-9418-596f31067779', + repo: sandboxRepositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: ppHeadBranch, + prTitle: 'PP', + prNumber: 70, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { PP } from '../components/module.js'\nexport const App = () => \n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: red; }\n', + }, + ], + activeTabId: 'component', + }, + ]) +} + +const seedRepositoryWorkspaces = async ({ + page, + sourceHeadBranch, + targetHeadBranch, +}: { + page: Parameters[0] + sourceHeadBranch: string + targetHeadBranch: string +}) => { + const now = Date.now() + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: sourceHeadBranch, + }), + repo: repositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: sourceHeadBranch, + prTitle: 'Source workspace', + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>

Source baseline
', + }, + ], + activeTabId: 'component', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: targetHeadBranch, + }), + repo: repositoryFullName, + workspaceScope: 'repository', + base: 'main', + head: targetHeadBranch, + prTitle: 'Target workspace', + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Target baseline
', + }, + ], + activeTabId: 'component', + createdAt: now - 60_000, + lastModified: now - 60_000, + }, + ]) +} + +test('Pending debounced source edit does not overwrite switched-to workspace', async ({ + page, +}) => { + const sourceHeadBranch = 'develop/issue-debounce-source' + const targetHeadBranch = 'develop/issue-debounce-target' + + await waitForAppReady(page, `${appEntryPath}`) + await seedRepositoryWorkspaces({ + page, + sourceHeadBranch, + targetHeadBranch, + }) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch], + }, + }) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + const pendingSourceContent = + 'export const App = () =>
Source pending debounce payload
' + await setComponentEditorSource(page, pendingSourceContent) + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + + return toRecordIntegritySnapshot(targetRecord) + }) + .toMatchObject({ + repo: repositoryFullName, + head: targetHeadBranch, + prContextState: 'inactive', + componentContent: 'export const App = () =>
Target baseline
', + }) +}) + +test('Rapid A->B->A switching with pending edits avoids cross-workspace tab contamination', async ({ + page, +}) => { + const sourceHeadBranch = 'develop/issue-roundtrip-source' + const targetHeadBranch = 'develop/issue-roundtrip-target' + + await waitForAppReady(page, `${appEntryPath}`) + await seedRepositoryWorkspaces({ + page, + sourceHeadBranch, + targetHeadBranch, + }) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', sourceHeadBranch, targetHeadBranch], + }, + }) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + const sourcePendingPayload = + 'export const App = () =>
Source pending during roundtrip
' + await setComponentEditorSource(page, sourcePendingPayload) + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + const targetPendingPayload = + 'export const App = () =>
Target pending during roundtrip
' + await setComponentEditorSource(page, targetPendingPayload) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + await expect + .poll(async () => { + const sourceRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranch, + }) + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + const sourceSnapshot = toRecordIntegritySnapshot(sourceRecord) + const targetSnapshot = toRecordIntegritySnapshot(targetRecord) + + return { + sourceHead: sourceSnapshot.head, + targetHead: targetSnapshot.head, + sourceHasTargetPayload: + sourceSnapshot.componentContent.trim() === targetPendingPayload, + targetHasSourcePayload: + targetSnapshot.componentContent.trim() === sourcePendingPayload, + } + }) + .toEqual({ + sourceHead: sourceHeadBranch, + targetHead: targetHeadBranch, + sourceHasTargetPayload: false, + targetHasSourcePayload: false, + }) +}) + +test('Switching between active P and PP contexts preserves record ids, keys, and tab shapes', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + const repository = 'knightedcodemonkey/develop-sandbox' + const [repositoryOwner, repositoryName] = repository.split('/') + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: repositoryOwner }, + name: repositoryName, + full_name: repository, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/branches**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { name: 'main' }, + { name: pHeadBranch }, + { name: ppHeadBranch }, + ]), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + repo: repository, + workspaceScope: 'repository', + base: 'main', + head: pHeadBranch, + prTitle: 'P', + prNumber: 64, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { P } from '../components/module.js'\nexport const App = () =>

\n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: white; }\n', + }, + { + id: 'module-mokdas01-j40ovo', + name: 'module.tsx', + path: 'src/components/module.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: 'export const P = () =>

blah

\n', + }, + ], + activeTabId: 'component', + }, + { + id: 'ws_d6502674-64fd-46a6-9418-596f31067779', + repo: repository, + workspaceScope: 'repository', + base: 'main', + head: ppHeadBranch, + prTitle: 'PP', + prNumber: 65, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import { PP } from '../components/module.js'\nexport const App = () => \n", + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { color: red; }\n', + }, + { + id: 'module-mokdas01-j40ovo', + name: 'module.tsx', + path: 'src/components/module.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: 'export const PP = () =>

PP

\n', + }, + { + id: 'style-mokddymb-ehiken', + name: 'module.css', + path: 'src/styles/module.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { margin: 0; background: green; }\n', + }, + ], + activeTabId: 'component', + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('const App = () => ') + await expect( + page.getByRole('listitem', { name: 'Workspace tab module.tsx' }), + ).toBeVisible() + + await ensureOpenPrDrawerOpen(page) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch) + await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP') + + await expect + .poll(async () => { + const pRecord = await getWorkspaceTabsRecord(page, { headBranch: pHeadBranch }) + const ppRecord = await getWorkspaceTabsRecord(page, { headBranch: ppHeadBranch }) + const pTabs = Array.isArray(pRecord?.tabs) ? pRecord.tabs : [] + const ppTabs = Array.isArray(ppRecord?.tabs) ? ppRecord.tabs : [] + + const pComponent = pTabs.find(tab => tab?.id === 'component') as + | { content?: unknown } + | undefined + const ppComponent = ppTabs.find(tab => tab?.id === 'component') as + | { content?: unknown } + | undefined + const ppStyles = ppTabs.find(tab => tab?.id === 'styles') as + | { content?: unknown; syncedContent?: unknown; isDirty?: unknown } + | undefined + const ppStylesContent = + typeof ppStyles?.content === 'string' ? ppStyles.content : '' + const ppStylesSyncedContent = + typeof ppStyles?.syncedContent === 'string' ? ppStyles.syncedContent : null + const ppStylesDirty = ppStyles?.isDirty === true + + return { + pId: typeof pRecord?.id === 'string' ? pRecord.id : '', + ppId: typeof ppRecord?.id === 'string' ? ppRecord.id : '', + pKey: + typeof pRecord?.workspaceKey === 'string' ? pRecord.workspaceKey.trim() : '', + ppKey: + typeof ppRecord?.workspaceKey === 'string' ? ppRecord.workspaceKey.trim() : '', + pTabCount: pTabs.length, + ppTabCount: ppTabs.length, + pHasPContent: + typeof pComponent?.content === 'string' && pComponent.content.includes('

'), + ppHasPPContent: + typeof ppComponent?.content === 'string' && + ppComponent.content.includes(''), + ppStylesDirtyConsistent: + ppStylesSyncedContent === null + ? true + : ppStylesDirty === (ppStylesContent !== ppStylesSyncedContent), + } + }) + .toEqual({ + pId: 'ws_45d9b895-a424-43ef-8bab-7090726f94f7', + ppId: 'ws_d6502674-64fd-46a6-9418-596f31067779', + pKey: 'knightedcodemonkey-develop-sandbox::feat-p', + ppKey: 'knightedcodemonkey-develop-sandbox::feat-pp', + pTabCount: 3, + ppTabCount: 4, + pHasPContent: true, + ppHasPPContent: true, + ppStylesDirtyConsistent: true, + }) +}) + +test('First switch P->PP keeps PP metadata when PR verification fails', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ route }) => { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Bad credentials' }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect(pushDrawer.getByLabel('Head')).toHaveValue(ppHeadBranch) + await expect(pushDrawer.getByLabel('PR title')).toHaveValue('PP') + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('const App = () => ') + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Could not verify saved pull request state') +}) + +test('Late verify response from P does not override PP after first switch', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ pullRequestNumber, route }) => { + if (pullRequestNumber === 69) { + await new Promise(resolve => { + setTimeout(resolve, 400) + }) + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, pHeadBranch) + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect + .poll(async () => { + const head = await pushDrawer.getByLabel('Head').inputValue() + const title = await pushDrawer.getByLabel('PR title').inputValue() + const component = await page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + .innerText() + + return { + head: typeof head === 'string' ? head.trim() : '', + title: typeof title === 'string' ? title.trim() : '', + hasPpComponent: component.includes(''), + } + }) + .toEqual({ + head: ppHeadBranch, + title: 'PP', + hasPpComponent: true, + }) +}) + +test('Late verify response from PP does not override P after switching back', async ({ + page, +}) => { + const pHeadBranch = 'feat/P' + const ppHeadBranch = 'feat/PP' + + await setupSandboxRepositoryRoutes({ + page, + pHeadBranch, + ppHeadBranch, + onPullRequestRequest: async ({ pullRequestNumber, route }) => { + if (pullRequestNumber === 70) { + await new Promise(resolve => { + setTimeout(resolve, 400) + }) + } + + const headRef = pullRequestNumber === 70 ? ppHeadBranch : pHeadBranch + const title = pullRequestNumber === 70 ? 'PP' : 'P' + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: pullRequestNumber, + state: 'open', + title, + html_url: `https://github.com/${sandboxRepositoryFullName}/pull/${pullRequestNumber}`, + head: { ref: headRef }, + base: { ref: 'main' }, + }), + }) + }, + }) + + await waitForAppReady(page, `${appEntryPath}`) + await seedSandboxActivePpContexts({ page, pHeadBranch, ppHeadBranch }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, ppHeadBranch) + await openStoredWorkspaceContextByHead(page, pHeadBranch) + + await ensureOpenPrDrawerOpen(page) + const pushDrawer = page.getByRole('complementary', { name: 'Push Commit' }) + await expect(pushDrawer).toBeVisible() + + await expect + .poll(async () => { + const head = await pushDrawer.getByLabel('Head').inputValue() + const title = await pushDrawer.getByLabel('PR title').inputValue() + const component = await page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + .innerText() + + return { + head: typeof head === 'string' ? head.trim() : '', + title: typeof title === 'string' ? title.trim() : '', + hasPComponent: component.includes('

'), + } + }) + .toEqual({ + head: pHeadBranch, + title: 'P', + hasPComponent: true, + }) +}) diff --git a/playwright/github-pr-drawer/active-context-switch.spec.ts b/playwright/github-pr-drawer/active-context-switch.spec.ts new file mode 100644 index 0000000..808b814 --- /dev/null +++ b/playwright/github-pr-drawer/active-context-switch.spec.ts @@ -0,0 +1,1754 @@ +import { expect, test } from '@playwright/test' +import { + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + getAllWorkspaceRecords, + getWorkspaceTabsRecord, + mockRepositoryBranches, + openMostRecentStoredWorkspaceContext, + openStoredWorkspaceContextByHead, + removeSavedGitHubToken, + runActiveWorkspaceCrossRepoSwitchIntegrityScenario, + runActiveWorkspaceSwitchIntegrityScenario, + seedActivePrWorkspaceContext, + seedLocalWorkspaceContexts, + setComponentEditorSource, + setStylesEditorSource, + toRecordIntegritySnapshot, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +test('Switching active workspace to inactive preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to closed preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceSwitchIntegrityScenario({ + page, + targetState: 'closed', + }) + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() + + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspace to cross-repo inactive preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ + page, + targetState: 'inactive', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Switching active workspaces with different module sync paths keeps remote sync isolated per path', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const alphaHeadBranch = 'develop/issue-alpha-sync' + const betaHeadBranch = 'develop/issue-beta-sync' + const alphaWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: alphaHeadBranch, + }) + const betaWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: betaHeadBranch, + }) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/21', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 21, + state: 'open', + title: 'Alpha active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/21', + head: { ref: alphaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/22', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 22, + state: 'open', + title: 'Beta active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/22', + head: { ref: betaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'active-sync-switch-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const ref = url.searchParams.get('ref') ?? '' + const keyedPath = `${ref}:${path}` + + const contentByBranchPath: Record = { + [`${alphaHeadBranch}:src/components/alpha-widget.tsx`]: + 'export const AlphaWidget = () =>

Alpha synced
', + [`${alphaHeadBranch}:src/styles/app.css`]: '.alpha { color: coral; }', + [`${betaHeadBranch}:src/components/beta-widget.tsx`]: + 'export const BetaWidget = () =>
Beta synced
', + [`${betaHeadBranch}:src/styles/app.css`]: '.beta { color: steelblue; }', + } + + const content = contentByBranchPath[keyedPath] + if (!content) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + path, + sha: `sha-${ref}-${path}`, + content: Buffer.from(content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: alphaWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: alphaHeadBranch, + prTitle: 'Alpha active workspace', + prNumber: 21, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Alpha local entry
', + }, + { + id: 'alpha-styles-tab', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.alpha { color: #111; }', + }, + { + id: 'alpha-widget-tab', + name: 'alpha-widget.tsx', + path: 'src/components/alpha-widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const AlphaWidget = () =>
Alpha local module
', + }, + ], + activeTabId: 'alpha-widget-tab', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: betaWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: betaHeadBranch, + prTitle: 'Beta active workspace', + prNumber: 22, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Beta local entry
', + }, + { + id: 'beta-styles-tab', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.beta { color: #111; }', + }, + { + id: 'beta-widget-tab', + name: 'beta-widget.tsx', + path: 'src/components/beta-widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const BetaWidget = () =>
Beta local module
', + }, + ], + activeTabId: 'beta-widget-tab', + createdAt: now - 60_000, + lastModified: now - 60_000, + }, + ]) + + await connectByotWithSingleRepo(page) + + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const alphaRecord = records.find(record => { + const recordId = typeof record?.id === 'string' ? record.id.trim() : '' + const recordHead = typeof record?.head === 'string' ? record.head.trim() : '' + return recordId === alphaWorkspaceId || recordHead === alphaHeadBranch + }) + const betaRecord = records.find(record => { + const recordId = typeof record?.id === 'string' ? record.id.trim() : '' + const recordHead = typeof record?.head === 'string' ? record.head.trim() : '' + return recordId === betaWorkspaceId || recordHead === betaHeadBranch + }) + + const alphaTabs = Array.isArray(alphaRecord?.tabs) + ? (alphaRecord.tabs as Array>) + : [] + const betaTabs = Array.isArray(betaRecord?.tabs) + ? (betaRecord.tabs as Array>) + : [] + + const alphaModule = alphaTabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/alpha-widget.tsx', + ) + const betaModule = betaTabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/beta-widget.tsx', + ) + + const alphaModuleContent = + typeof alphaModule?.content === 'string' ? alphaModule.content.trim() : '' + const betaModuleContent = + typeof betaModule?.content === 'string' ? betaModule.content.trim() : '' + + return { + alphaModulePresent: Boolean(alphaModule), + alphaHasBetaContent: + alphaModuleContent === + 'export const BetaWidget = () =>
Beta synced
' || + alphaModuleContent === + 'export const BetaWidget = () =>
Beta local module
', + betaHasAlphaContent: + betaModuleContent === + 'export const AlphaWidget = () =>
Alpha synced
' || + betaModuleContent === + 'export const AlphaWidget = () =>
Alpha local module
', + } + }) + .toEqual({ + alphaModulePresent: true, + alphaHasBetaContent: false, + betaHasAlphaContent: false, + }) +}) + +test('Switching active repository workspaces B->A->B preserves each workspace tab content', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const alphaHeadBranch = 'develop/issue-alpha-roundtrip' + const betaHeadBranch = 'develop/issue-beta-roundtrip' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', alphaHeadBranch, betaHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/31', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 31, + state: 'open', + title: 'Alpha active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/31', + head: { ref: alphaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/32', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 32, + state: 'open', + title: 'Beta active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/32', + head: { ref: betaHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${alphaHeadBranch}`, + object: { type: 'commit', sha: 'roundtrip-active-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: alphaHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: alphaHeadBranch, + prTitle: 'Alpha active workspace', + prNumber: 31, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Alpha unique entry
', + }, + ], + activeTabId: 'component', + createdAt: now - 120_000, + lastModified: now - 120_000, + }, + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: betaHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: betaHeadBranch, + prTitle: 'Beta active workspace', + prNumber: 32, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Beta unique entry
', + }, + ], + activeTabId: 'component', + createdAt: now - 60_000, + lastModified: now - 60_000, + }, + ]) + + await connectByotWithSingleRepo(page) + + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + await openStoredWorkspaceContextByHead(page, alphaHeadBranch) + await openStoredWorkspaceContextByHead(page, betaHeadBranch) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const alphaRecord = records.find( + record => + typeof record?.head === 'string' && record.head.trim() === alphaHeadBranch, + ) + const betaRecord = records.find( + record => + typeof record?.head === 'string' && record.head.trim() === betaHeadBranch, + ) + + const alphaComponent = Array.isArray(alphaRecord?.tabs) + ? (alphaRecord.tabs as Array>).find( + tab => tab?.id === 'component', + ) + : null + const betaComponent = Array.isArray(betaRecord?.tabs) + ? (betaRecord.tabs as Array>).find( + tab => tab?.id === 'component', + ) + : null + + return { + alpha: typeof alphaComponent?.content === 'string' ? alphaComponent.content : '', + beta: typeof betaComponent?.content === 'string' ? betaComponent.content : '', + } + }) + .toEqual({ + alpha: 'export const App = () =>
Alpha unique entry
', + beta: 'export const App = () =>
Beta unique entry
', + }) +}) + +test('Switching from one active context in source repo to target repo does not overwrite sibling active source context', async ({ + page, +}) => { + const sourceRepositoryFullName = 'knightedcodemonkey/css' + const targetRepositoryFullName = 'knightedcodemonkey/develop' + const sourceHeadBranchPrimary = 'css/issue-123-primary' + const sourceHeadBranchSibling = 'css/issue-123-sibling' + const targetHeadBranch = 'develop/issue-123-target' + + const sourcePrimaryWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranchPrimary, + }) + const sourceSiblingWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranchSibling, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: targetRepositoryFullName, + headBranch: targetHeadBranch, + }) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: sourceRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: targetRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [sourceRepositoryFullName]: [ + 'main', + sourceHeadBranchPrimary, + sourceHeadBranchSibling, + ], + [targetRepositoryFullName]: ['main', targetHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/9', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 9, + state: 'open', + title: 'Source primary active workspace', + html_url: 'https://github.com/knightedcodemonkey/css/pull/9', + head: { ref: sourceHeadBranchPrimary }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/10', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 10, + state: 'open', + title: 'Source sibling active workspace', + html_url: 'https://github.com/knightedcodemonkey/css/pull/10', + head: { ref: sourceHeadBranchSibling }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Target active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: targetHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${sourceHeadBranchPrimary}`, + object: { type: 'commit', sha: 'source-primary-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${targetHeadBranch}`, + object: { type: 'commit', sha: 'target-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: sourcePrimaryWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchPrimary, + prTitle: 'Source primary active workspace', + prNumber: 9, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Source primary content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 180_000, + lastModified: Date.now() - 180_000, + }, + { + id: sourceSiblingWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchSibling, + prTitle: 'Source sibling active workspace', + prNumber: 10, + prContextState: 'active', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Source sibling content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 120_000, + lastModified: Date.now() - 120_000, + }, + { + id: targetWorkspaceId, + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: 'Target active workspace', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Target content
', + }, + ], + activeTabId: 'component', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextByHead(page, sourceHeadBranchPrimary) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('Target content') + + await expect + .poll(async () => { + const sourcePrimaryRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranchPrimary, + }) + return toRecordIntegritySnapshot( + sourcePrimaryRecord as Record | null, + ) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchPrimary, + prTitle: 'Source primary active workspace', + prNumber: 9, + prContextState: 'active', + componentContent: 'export const App = () =>
Source primary content
', + }) + + await expect + .poll(async () => { + const sourceSiblingRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranchSibling, + }) + return toRecordIntegritySnapshot( + sourceSiblingRecord as Record | null, + ) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranchSibling, + prTitle: 'Source sibling active workspace', + prNumber: 10, + prContextState: 'active', + componentContent: 'export const App = () =>
Source sibling content
', + }) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + return toRecordIntegritySnapshot(targetRecord as Record | null) + }) + .toEqual({ + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: 'Target active workspace', + prNumber: 2, + prContextState: 'active', + componentContent: 'export const App = () =>
Target content
', + }) +}) + +test('Switching active workspace to cross-repo closed preserves switched-from record integrity', async ({ + page, +}) => { + await runActiveWorkspaceCrossRepoSwitchIntegrityScenario({ + page, + targetState: 'closed', + }) + await expect(page.getByRole('status', { name: 'App status' })).toContainText('Rendered') +}) + +test('Active PR context updates controls and can be closed from AI controls', async ({ + page, +}) => { + let closePullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + if (route.request().method() === 'PATCH') { + closePullRequestRequestCount += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'closed', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Close active pull request context' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(page.getByText('PR: develop/pr/2')).toBeVisible() + await dialog.getByRole('button', { name: 'Close PR on GitHub' }).click() + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() + await expect( + page.getByRole('listitem', { name: 'Workspace tab App.tsx' }), + ).toBeVisible() + await expect( + page.getByRole('list', { name: 'Workspace editor tabs' }).getByRole('listitem'), + ).toHaveCount(2) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const closedRecord = records.find( + record => + record?.repo === 'knightedcodemonkey/develop' && + record?.prContextState === 'closed' && + record?.prNumber === 2, + ) + + return { + prContextState: closedRecord?.prContextState, + prNumber: closedRecord?.prNumber, + prTitle: closedRecord?.prTitle, + } + }) + .toEqual({ + prContextState: 'closed', + prNumber: 2, + prTitle: 'Existing PR context from storage', + }) + + expect(closePullRequestRequestCount).toBe(1) +}) + +test('Active PR context is disabled on load when pull request is closed', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'closed', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() + await expect + .poll(async () => { + const statusText = await page + .getByRole('status', { name: 'Open pull request status', includeHidden: true }) + .textContent() + const normalizedStatus = typeof statusText === 'string' ? statusText.trim() : '' + return ( + normalizedStatus.includes('Saved pull request context is not open on GitHub.') || + normalizedStatus.includes( + 'Repository is selected from Workspaces. Configure branch details and commit metadata.', + ) + ) + }) + .toBe(true) +}) + +test('Active PR context rehydrates after token remove and re-add', async ({ page }) => { + const githubHeadBranch = 'css/rehydrate-test' + const staleLocalHeadBranch = 'css/stale-local-head' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + 'knightedcodemonkey/css': ['main', 'release', githubHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/7', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 7, + state: 'open', + title: 'Saved css PR context', + html_url: 'https://github.com/knightedcodemonkey/css/pull/7', + head: { ref: githubHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${githubHeadBranch}`, + object: { type: 'commit', sha: 'rehydrate-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem('knighted:develop:github-repository', 'knightedcodemonkey/css') + }) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/css', + headBranch: staleLocalHeadBranch, + prTitle: 'Saved css PR context', + prNumber: 7, + renderMode: 'react', + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeVisible() + await expect(page.getByLabel('Head')).toHaveValue(githubHeadBranch) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const restoredRecord = records.find( + record => + record?.repo === 'knightedcodemonkey/css' && + record?.prNumber === 7 && + record?.prTitle === 'Saved css PR context', + ) + + return Boolean(restoredRecord) + }) + .toBe(true) + + await removeSavedGitHubToken(page) + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'GitHub token removed', + ) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeVisible() + + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) +}) + +test('Active PR context deactivates after token remove and re-add when PR is closed', async ({ + page, +}) => { + let useClosedPullRequest = false + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + 'knightedcodemonkey/css': ['main', 'release', 'css/rehydrate-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/7', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 7, + state: useClosedPullRequest ? 'closed' : 'open', + title: 'Saved css PR context', + html_url: 'https://github.com/knightedcodemonkey/css/pull/7', + head: { ref: 'css/rehydrate-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/css/rehydrate-test', + object: { type: 'commit', sha: 'rehydrate-closed-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await page.evaluate(() => { + localStorage.setItem('knighted:develop:github-repository', 'knightedcodemonkey/css') + }) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/css', + headBranch: 'css/rehydrate-test', + prTitle: 'Saved css PR context', + prNumber: 7, + renderMode: 'react', + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeVisible() + + await removeSavedGitHubToken(page) + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'GitHub token removed', + ) + + useClosedPullRequest = true + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await openMostRecentStoredWorkspaceContext(page) + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue( + 'knightedcodemonkey/css', + ) + await expect( + page.getByRole('button', { name: 'Open pull request', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeHidden() + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Repository is selected from Workspaces.') +}) + +test('Active PR context recovers when saved head branch is missing but PR metadata exists', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Recovered PR context title', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'recovered-pr-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: '', + prTitle: 'Recovered PR context title', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() + await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') +}) + +test('Active PR context uses Push commit flow without creating a new pull request', async ({ + page, +}) => { + const contentsPutRequests: string[] = [] + let createRefRequestCount = 0 + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/unexpected' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 999, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Tree API unavailable' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + await expect(page.getByLabel('Pull request repository')).toBeDisabled() + await expect(page.getByLabel('Pull request base branch')).toBeDisabled() + await expect(page.getByLabel('Head')).toHaveJSProperty('readOnly', true) + await expect(page.getByLabel('PR title')).toHaveJSProperty('readOnly', true) + await expect(page.getByLabel('Include entry tab')).toBeEnabled() + await expect(page.getByLabel('Commit message')).toBeEditable() + + await expect(page.getByLabel('PR description')).toBeHidden() + await expect(page.getByLabel('Commit message')).toBeVisible() + + const includeWrapperToggle = page.getByLabel('Include entry tab') + await expect(includeWrapperToggle).toBeEnabled() + await includeWrapperToggle.check() + await expect(includeWrapperToggle).toBeChecked() + await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() + await expect(page.getByLabel('PR description')).toBeHidden() + await expect(page.getByLabel('Commit message')).toBeVisible() + + await setComponentEditorSource(page, 'const commitMarker = 1') + await setStylesEditorSource(page, '.commit-marker { color: red; }') + const pushCommitMessage = 'chore: push active context sync' + await page.getByLabel('Commit message').fill(pushCommitMessage) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect( + page.getByText('Push commit to active pull request branch?', { exact: true }), + ).toHaveText('Push commit to active pull request branch?') + await expect( + page.getByText('Head branch: develop/open-pr-test', { exact: true }), + ).toBeVisible() + await expect(page.getByText('Files to commit:', { exact: true })).toBeVisible() + await expect( + page.getByText('App.tsx -> src/components/App.tsx', { exact: true }), + ).toBeVisible() + await expect( + page.getByText('app.css -> src/styles/app.css', { exact: true }), + ).toBeVisible() + + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Push commit failed:') + + expect(createRefRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Active PR context push with no local changes shows neutral status', async ({ + page, +}) => { + const updateRefRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + await setComponentEditorSource(page, 'const commitMarker = 2') + await ensureOpenPrDrawerOpen(page) + + const pushCommitButton = page + .locator('#github-pr-drawer') + .getByRole('button', { name: 'Push commit', exact: true }) + await expect(pushCommitButton).toBeEnabled() + + await pushCommitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test') + expect(updateRefRequests).toHaveLength(1) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect + .poll(async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + const hasEntryTab = tabs.some(tab => tab?.role === 'entry') + const hasStyleTab = tabs.some(tab => { + const language = + typeof tab?.language === 'string' ? tab.language.trim().toLowerCase() : '' + return language === 'css' || language === 'less' || language === 'sass' + }) + const hasPrimaryTabs = hasEntryTab && hasStyleTab + return hasPrimaryTabs && tabs.every(tab => tab?.isDirty === false) + }) + .toBe(true) + + await ensureOpenPrDrawerOpen(page) + + await pushCommitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('No local editor changes to push.') + expect(updateRefRequests).toHaveLength(1) + await expect(page.locator('#clear-confirm-dialog')).toBeHidden() +}) diff --git a/playwright/github-pr-drawer/active-context-sync.spec.ts b/playwright/github-pr-drawer/active-context-sync.spec.ts new file mode 100644 index 0000000..87f4b4f --- /dev/null +++ b/playwright/github-pr-drawer/active-context-sync.spec.ts @@ -0,0 +1,2683 @@ +import { expect, test } from '@playwright/test' +import { + addWorkspaceTab, + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + getAllWorkspaceRecords, + getWorkspaceComponentContent, + getWorkspaceTabsRecord, + mockRepositoryBranches, + openMostRecentStoredWorkspaceContext, + renameWorkspaceTab, + seedActivePrWorkspaceContext, + seedLocalWorkspaceContexts, + selectWorkspacesRepositoryFilter, + setComponentEditorSource, + setStylesEditorSource, + submitOpenPrAndConfirm, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +test('New workspace tabs do not show Edited indicator before first sync in active PR context', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await addWorkspaceTab(page) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab module.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) +}) + +test('Unsynced dirty tabs keep plain accessible names during active PR context', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await addWorkspaceTab(page) + + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toBeVisible() + await expect( + page.getByRole('listitem', { name: 'Workspace tab module.tsx' }), + ).toBeVisible() +}) + +test('Renaming a synced module tab keeps plain tab label and includes renamed path in Push commit confirmation', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const responseByPath: Record = { + 'src/components/boop.tsx': { + status: 200, + body: JSON.stringify({ sha: 'boop-existing-sha' }), + }, + } + const response = responseByPath[path] ?? { + status: 404, + body: JSON.stringify({ message: 'Not Found' }), + } + + await route.fulfill({ + status: response.status, + contentType: 'application/json', + body: response.body, + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'rename-commit-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Hello from Knighted
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Hello from Knighted
', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: 'main { color: #111; }', + syncedAt: now, + isDirty: false, + }, + { + id: 'boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: 'export const Boop = () =>

boop

', + targetPrFilePath: 'src/components/boop.tsx', + syncedContent: 'export const Boop = () =>

boop

', + syncedAt: now, + isDirty: false, + }, + ], + activeTabId: 'component', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await renameWorkspaceTab(page, { from: 'boop.tsx', to: 'beep.tsx' }) + + await expect(page.getByRole('button', { name: 'Open tab beep.tsx' })).toBeVisible() + + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + const renamedModuleEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await renamedModuleEditor.fill('export const Boop = () =>

beep

') + + await ensureOpenPrDrawerOpen(page) + const pushCommitButton = page + .locator('#github-pr-drawer') + .getByRole('button', { name: 'Push commit', exact: true }) + await expect(pushCommitButton).toBeEnabled() + await pushCommitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Files to commit:', { exact: true })).toBeVisible() + await expect( + dialog.getByText('beep.tsx -> src/components/beep.tsx', { exact: true }), + ).toBeVisible() + await expect( + dialog.getByText('beep.tsx -> src/components/boop.tsx (delete)', { exact: true }), + ).toBeVisible() + + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test') + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const renamedBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx') + const deletedBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx') + + expect(renamedBlob).toMatchObject({ + path: 'src/components/beep.tsx', + mode: '100644', + type: 'blob', + }) + expect(typeof renamedBlob?.content).toBe('string') + + expect(deletedBlob).toEqual({ + path: 'src/components/boop.tsx', + mode: '100644', + type: 'blob', + sha: null, + }) +}) + +test('Push commit prunes stale delete entries before Git tree creation', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + const payload = route.request().postDataJSON() as Record + treeRequests.push(payload) + + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'rename-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'rename-commit-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Hello from Knighted
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Hello from Knighted
', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'style.css', + path: 'src/style.css', + language: 'css', + role: 'module', + isActive: false, + content: 'button {\n color: red;\n}', + targetPrFilePath: 'src/styles.css', + syncedContent: 'button {\n color: red;\n}', + syncedAt: now, + isDirty: true, + }, + ], + activeTabId: 'component', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await page.getByRole('button', { name: 'Open tab style.css' }).click() + await expect(page.getByRole('region', { name: 'style.css' })).toBeVisible() + const stylesEditor = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() + await stylesEditor.fill('button {\n color: blue;\n}') + + await ensureOpenPrDrawerOpen(page) + const pushCommitButton = page + .locator('#github-pr-drawer') + .getByRole('button', { name: 'Push commit', exact: true }) + await expect(pushCommitButton).toBeEnabled() + await pushCommitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test') + + expect(treeRequests).toHaveLength(1) + + const firstTreeEntries = treeRequests[0]?.tree as Array> + expect(Array.isArray(firstTreeEntries)).toBe(true) + + expect( + firstTreeEntries.some( + entry => entry?.path === 'src/styles.css' && entry?.sha === null, + ), + ).toBe(false) + expect(firstTreeEntries.some(entry => entry?.path === 'src/style.css')).toBe(true) +}) + +test('Active PR context sync applies remote updates by tab path', async ({ page }) => { + const remoteByPath: Record = { + 'src/components/App.tsx': 'export const App = () =>
Local entry
', + 'src/components/widget.tsx': 'export const Widget = () =>
Synced widget
', + 'src/styles/app.css': '.widget { color: green; }', + } + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const url = new URL(route.request().url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '').trim() + const content = remoteByPath[path] + + if (!content) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + path, + sha: `sha-${path.replace(/[^a-z0-9]/gi, '-')}`, + content: Buffer.from(content, 'utf8').toString('base64'), + encoding: 'base64', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Local entry
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Local entry
', + syncedAt: now, + isDirty: false, + }, + { + id: 'workspace-styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: 'main { color: #111; }', + syncedAt: now, + isDirty: false, + }, + { + id: 'widget-tab', + name: 'widget.tsx', + path: 'src/components/widget.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const Widget = () =>
Local widget
', + targetPrFilePath: 'src/components/widget.tsx', + syncedContent: 'export const Widget = () =>
Local widget
', + syncedAt: now, + isDirty: false, + }, + ], + activeTabId: 'widget-tab', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await expect + .poll(async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find( + tab => + typeof tab?.path === 'string' && tab.path.trim() === 'src/components/App.tsx', + ) + const stylesTab = tabs.find( + tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const widgetTab = tabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim() === 'src/components/widget.tsx', + ) + + return { + entryContent: + typeof entryTab?.content === 'string' ? entryTab.content.trim() : '', + widgetContent: + typeof widgetTab?.content === 'string' ? widgetTab.content.trim() : '', + widgetSynced: + typeof widgetTab?.syncedContent === 'string' + ? widgetTab.syncedContent.trim() + : '', + stylesContent: + typeof stylesTab?.content === 'string' ? stylesTab.content.trim() : '', + } + }) + .toEqual({ + entryContent: 'export const App = () =>
Local entry
', + widgetContent: remoteByPath['src/components/widget.tsx'], + widgetSynced: remoteByPath['src/components/widget.tsx'], + stylesContent: remoteByPath['src/styles/app.css'], + }) +}) + +test('Active PR context push commit uses Git Database API atomic path by default', async ({ + page, +}) => { + let createRefRequestCount = 0 + let pullRequestRequestCount = 0 + const treeRequests: Array> = [] + const commitRequests: Array> = [] + const updateRefRequests: Array> = [] + const contentsPutRequests: string[] = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/unexpected' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 999, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/existing-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'existing-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + await setComponentEditorSource(page, 'const commitMarker = 2') + await setStylesEditorSource(page, '.commit-marker { color: blue; }') + const pushCommitMessage = 'chore: push active context sync (atomic)' + await page.getByLabel('Commit message').fill(pushCommitMessage) + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + const hasEntryTab = tabs.some(tab => tab?.role === 'entry') + const hasStyleTab = tabs.some(tab => { + const language = + typeof tab?.language === 'string' ? tab.language.trim().toLowerCase() : '' + return language === 'css' || language === 'less' || language === 'sass' + }) + const hasPrimaryTabs = hasEntryTab && hasStyleTab + return hasPrimaryTabs && tabs.every(tab => tab?.isDirty === false) + }, + { timeout: 10_000 }, + ) + .toBe(true) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect( + page + .getByRole('listitem', { name: 'Workspace tab app.css' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect(page.locator('#styles-dirty-status')).toBeHidden() + + expect(createRefRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(pushCommitMessage) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('push-commit-sha') + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Open PR uses module tab paths when stale target file paths collide', async ({ + page, + browserName, +}) => { + // WebKit-only quarantine: keep this spec active on Chromium while CI flake is investigated. + test.fixme( + browserName === 'webkit', + 'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.', + ) + + const treeRequests: Array> = [] + const commitRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-stale-target-paths' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-stale-target-paths' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 333, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/333', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const localBoopSource = 'export const Boop = () =>

boop boop boop

\n' + const localBeepSource = 'export const Beep = () =>

beep beep beep

\n' + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-stale-target-paths', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-stale-target-paths', + prTitle: 'Open PR with stale module target paths', + prNumber: null, + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import '../styles/app.css'\nimport { Boop } from './boop.js'\nimport { Beep } from './beep.js'\n\nexport const App = () => (\n <>\n \n \n \n)\n", + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { margin: 0; color: blue; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/App.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + const commitMessage = 'chore: open pr with stale module target path metadata' + await page.getByLabel('Head').fill('develop/open-pr-stale-target-paths') + await page.getByLabel('PR title').fill('Open PR keeps module paths and content') + await page.getByLabel('Commit message').fill(commitMessage) + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/333', + ) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + expect(treePayload?.length).toBe(4) + + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + const stylesBlob = treePayload?.find(file => file.path === 'src/styles/app.css') + const boopBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx') + const beepBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx') + + expect(componentBlob?.content).toEqual(expect.any(String)) + expect(stylesBlob?.content).toEqual(expect.any(String)) + expect(boopBlob?.content).toBe(localBoopSource) + expect(beepBlob?.content).toBe(localBeepSource) + + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(commitMessage) +}) + +test('Reloaded active PR context from URL metadata keeps Push mode and status reference', async ({ + page, +}) => { + const contentsPutRequests: string[] = [] + let createRefRequestCount = 0 + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/develop/open-pr-test', + object: { type: 'commit', sha: 'existing-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/unexpected-branch' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 999, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/999', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Tree API unavailable' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await ensureOpenPrDrawerOpen(page) + await expect(page.getByRole('button', { name: 'Push commit' }).last()).toBeVisible() + await expect(page.getByLabel('Head')).toHaveValue('develop/open-pr-test') + await expect(page.getByLabel('PR description')).toBeHidden() + await expect(page.getByLabel('Commit message')).toBeVisible() + + await setComponentEditorSource(page, 'const commitMarker = 1') + await setStylesEditorSource(page, '.commit-marker { color: red; }') + + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Push commit' }).click() + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Push commit failed:') + + expect(createRefRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Reload keeps persisted active PR workspace context active', async ({ page }) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName, + headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + const workspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }) + + await page.evaluate( + ({ repo }) => { + localStorage.setItem( + 'knighted:develop:github-pat', + 'github_pat_fake_chat_1234567890', + ) + localStorage.setItem('knighted:develop:github-repository', repo) + }, + { repo: repositoryFullName }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + const activeRecord = await getWorkspaceTabsRecord(page, { headBranch }) + expect(activeRecord?.id).toBe(workspaceId) + expect(activeRecord?.prContextState).toBe('active') + expect(activeRecord?.prNumber).toBe(2) + + const workspaceRecords = await getAllWorkspaceRecords(page) + const activeRecordsForPr = workspaceRecords.filter( + record => + record?.repo === repositoryFullName && + record?.prContextState === 'active' && + record?.prNumber === 2, + ) + expect(activeRecordsForPr).toHaveLength(1) +}) + +test('Non-local New workspace forks from active PR context into a new repository workspace', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/open-pr-test' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', activeHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: activeHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName, + headBranch: activeHeadBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const countRepositoryRecords = async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName + }).length + } + + const initialRepositoryCount = await countRepositoryRecords() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + + await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1) + + await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Push commit' })).toHaveCount(0) +}) + +test('Reload restores active PR context when title is empty but PR identity exists', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-empty-title' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/37', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 37, + state: 'open', + title: 'Recovered PR title from GitHub', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/37', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: '', + prNumber: 37, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Active identity restore
', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate( + ({ repo }) => { + localStorage.setItem( + 'knighted:develop:github-pat', + 'github_pat_fake_chat_1234567890', + ) + localStorage.setItem('knighted:develop:github-repository', repo) + }, + { repo: repositoryFullName }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch, + }) + + return { + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : null, + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + } + }) + .toEqual({ + prContextState: 'active', + prNumber: 37, + }) +}) + +test('Reload prefers active PR workspace when mixed workspace records exist', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/open-pr-test' + const inactiveHeadBranch = 'feat/stale-local-workspace' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', activeHeadBranch, inactiveHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: activeHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const activeWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: activeHeadBranch, + }) + const inactiveWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: inactiveHeadBranch, + }) + + await seedLocalWorkspaceContexts(page, [ + { + id: inactiveWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: inactiveHeadBranch, + prTitle: '', + prNumber: null, + prContextState: 'inactive', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Inactive workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #444; }', + }, + ], + activeTabId: 'component', + }, + { + id: activeWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Active workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: tomato; }', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate( + ({ repo }) => { + localStorage.setItem( + 'knighted:develop:github-pat', + 'github_pat_fake_chat_1234567890', + ) + localStorage.setItem('knighted:develop:github-repository', repo) + }, + { repo: repositoryFullName }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + const selectedRecord = await getWorkspaceTabsRecord(page, { + headBranch: activeHeadBranch, + }) + expect(selectedRecord?.id).toBe(activeWorkspaceId) + expect(selectedRecord?.prContextState).toBe('active') + expect(selectedRecord?.prNumber).toBe(2) +}) + +test('Reloaded active PR context syncs editor content from GitHub branch and restores style mode', async ({ + page, +}) => { + const remoteComponentSource = 'export const App = () =>
Synced from PR
' + const remoteStylesSource = '.synced-from-pr { color: tomato; }' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== 'develop/open-pr-test') { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + if (path === 'src/styles/app.css') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'styles-sha', + content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), + }), + }) + return + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + styleLanguage: 'sass', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await expect(page.getByLabel('Render mode')).toHaveValue('react') + await expect(page.getByLabel('Style mode')).toHaveValue('sass') + + await expect + .poll(async () => { + const result = await page.evaluate(() => { + const componentEditor = document.getElementById('jsx-editor') + const stylesEditor = document.getElementById('css-editor') + + return { + component: + componentEditor instanceof HTMLTextAreaElement ? componentEditor.value : '', + styles: stylesEditor instanceof HTMLTextAreaElement ? stylesEditor.value : '', + } + }) + + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + const stylesTab = tabs.find( + tab => typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const stylesContent = + typeof stylesTab?.content === 'string' ? stylesTab.content : '' + + const componentMatchesKnownStates = + result.component === remoteComponentSource || + result.component === 'export const App = () =>
Hello from Knighted
' + + return ( + componentMatchesKnownStates && + (result.styles === remoteStylesSource || stylesContent === remoteStylesSource) + ) + }) + .toBe(true) +}) + +test('Reloaded active PR context does not apply partial sync when one primary file is missing', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const localComponentSource = 'export const App = () =>
Local App
\n' + const localStylesSource = '.local-app-styles { color: magenta; }\n' + const remoteComponentSource = 'export const App = () =>
Remote App
\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + /* Intentionally missing styles file forces a partial sync candidate. */ + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: localComponentSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: localStylesSource, + targetPrFilePath: 'src/styles/app.css', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.role === 'entry') + const stylesTab = tabs.find( + tab => + typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + stylesContent: typeof stylesTab?.content === 'string' ? stylesTab.content : '', + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + entryContent: localComponentSource, + stylesContent: localStylesSource, + }) +}) + +test('Reloaded active PR context sync does not overwrite non-primary module tabs', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const remoteComponentSource = 'export const App = () =>
Synced App
' + const remoteStylesSource = '.synced-app-styles { color: cyan; }' + const localBoopSource = 'export const Boop = () =>

Boop local module

\n' + const localBeepSource = 'export const Beep = () =>

Beep local module

\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + if (path === 'src/styles/app.css') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'styles-sha', + content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), + }), + }) + return + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Local App
\n', + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.local-app-styles { color: magenta; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/boop.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/beep.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.role === 'entry') + const boopTab = tabs.find(tab => tab?.id === 'module-boop') + const beepTab = tabs.find(tab => tab?.id === 'module-beep') + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + entryTargetPath: + typeof entryTab?.targetPrFilePath === 'string' + ? entryTab.targetPrFilePath + : '', + boopContent: typeof boopTab?.content === 'string' ? boopTab.content : '', + boopTargetPath: + typeof boopTab?.targetPrFilePath === 'string' ? boopTab.targetPrFilePath : '', + beepContent: typeof beepTab?.content === 'string' ? beepTab.content : '', + beepTargetPath: + typeof beepTab?.targetPrFilePath === 'string' ? beepTab.targetPrFilePath : '', + } + }, + { timeout: 10_000 }, + ) + .toMatchObject({ + entryTargetPath: 'src/components/App.tsx', + boopContent: localBoopSource, + boopTargetPath: 'src/components/boop.tsx', + beepContent: localBeepSource, + beepTargetPath: 'src/components/beep.tsx', + }) + + const workspaceAfterSync = await getWorkspaceTabsRecord(page, { headBranch }) + const entryAfterSyncContent = getWorkspaceComponentContent(workspaceAfterSync) + expect( + new Set([ + remoteComponentSource, + 'export const App = () =>
Local App
\n', + ]).has(entryAfterSyncContent), + ).toBe(true) +}) + +test('Reloaded active PR context sync does not overwrite non-primary tabs with stale target path collisions', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const remoteComponentSource = 'export const App = () =>
Synced App
' + const remoteStylesSource = '.synced-app-styles { color: cyan; }' + const localBoopSource = 'export const Boop = () =>

Boop local module

\n' + const localBeepSource = 'export const Beep = () =>

Beep local module

\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + if (path === 'src/styles/app.css') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'styles-sha', + content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), + }), + }) + return + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Local App
\n', + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.local-app-styles { color: magenta; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/App.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.role === 'entry') + const boopTab = tabs.find(tab => tab?.id === 'module-boop') + const beepTab = tabs.find(tab => tab?.id === 'module-beep') + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + boopContent: typeof boopTab?.content === 'string' ? boopTab.content : '', + beepContent: typeof beepTab?.content === 'string' ? beepTab.content : '', + } + }, + { timeout: 10_000 }, + ) + .toMatchObject({ + boopContent: localBoopSource, + beepContent: localBeepSource, + }) + + const staleCollisionRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const staleCollisionEntryContent = getWorkspaceComponentContent(staleCollisionRecord) + expect( + new Set([ + remoteComponentSource, + 'export const App = () =>
Local App
\n', + ]).has(staleCollisionEntryContent), + ).toBe(true) +}) + +test('Reloaded active PR context falls back to css style mode for unsupported value', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release', 'develop/open-pr-test'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: 'develop/open-pr-test' }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedActivePrWorkspaceContext(page, { + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-test', + prTitle: 'Existing PR context from storage', + prNumber: 2, + renderMode: 'react', + styleLanguage: 'css', + }) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await expect(page.getByLabel('Render mode')).toHaveValue('react') + await expect(page.getByLabel('Style mode')).toHaveValue('css') +}) diff --git a/playwright/github-pr-drawer/github-pr-drawer.helpers.ts b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts new file mode 100644 index 0000000..5c3d1e3 --- /dev/null +++ b/playwright/github-pr-drawer/github-pr-drawer.helpers.ts @@ -0,0 +1,1184 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { + addWorkspaceTab, + appEntryPath, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + ensureWorkspacesDrawerClosed, + mockRepositoryBranches, + resetWorkbenchStorage, + setComponentEditorSource, + setStylesEditorSource, + waitForAppReady, +} from '../helpers/app-test-helpers.js' + +export { + addWorkspaceTab, + appEntryPath, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + ensureWorkspacesDrawerClosed, + mockRepositoryBranches, + resetWorkbenchStorage, + setComponentEditorSource, + setStylesEditorSource, + waitForAppReady, +} +export const getOpenPrDrawer = (page: Page) => + page.getByRole('complementary', { name: /Open Pull Request|Push Commit/ }) + +export const renameWorkspaceTab = async ( + page: Page, + { + from, + to, + }: { + from: string + to: string + }, +) => { + await page.getByRole('button', { name: `Rename tab ${from}` }).click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') +} + +export const clickOpenPrDrawerSubmit = async (page: Page) => { + const drawer = getOpenPrDrawer(page) + await expect(drawer).toBeVisible() + const submitButton = drawer.getByRole('button', { name: 'Open PR' }) + await expect(submitButton).toBeEnabled() + /* + * NOTE: WebKit's HTML Top Layer behavior can cause Playwright + * actionability checks to fail or time out, even when the control is + * visibly ready and works in Safari. + * + * Keep this evaluate-based click because standard locator.click() and + * locator.click({ force: true }) have been flaky here and can fail to + * resolve the hit target for this drawer flow. + */ + await submitButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) +} + +export const triggerOpenPrConfirmation = async (page: Page) => { + await clickOpenPrDrawerSubmit(page) + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + return dialog +} + +export const submitOpenPrAndConfirm = async ( + page: Page, + { + expectedSummaryLines, + }: { + expectedSummaryLines?: string[] + } = {}, +) => { + const dialog = await triggerOpenPrConfirmation(page) + + for (const line of expectedSummaryLines ?? []) { + await expect(dialog.getByText(line, { exact: true })).toBeVisible() + } + + /* Same WebKit Top Layer issue applies to the confirm button. */ + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) +} + +export const expectOpenPrConfirmationPrompt = async (page: Page) => { + const dialog = await triggerOpenPrConfirmation(page) + await expect(dialog).toBeVisible() +} + +export const removeSavedGitHubToken = async (page: Page) => { + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } + + await page.getByRole('button', { name: 'Delete GitHub token' }).evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + const dialog = page.getByRole('dialog', { + name: 'Remove saved GitHub token?', + includeHidden: true, + }) + + await expect(dialog).toHaveAttribute('open', '') + await dialog.getByRole('button', { name: 'Remove' }).click() + await expect(dialog).not.toHaveAttribute('open', '') +} + +export const ensureWorkspacesDrawerOpen = async (page: Page) => { + const drawer = page.getByRole('complementary', { name: 'Workspaces' }) + + if (await drawer.isVisible()) { + return + } + + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.click() + } + + await page.getByRole('button', { name: 'Workspaces' }).click() + await expect(drawer).toBeVisible() +} + +export const getWorkspaceRecordId = ( + record: Record | null | undefined, +) => (typeof record?.id === 'string' ? record.id : '') + +export const getWorkspacesRepositoryFilterForRecord = ({ + repo, + workspaceScope, +}: { + repo?: unknown + workspaceScope?: unknown +}) => { + const normalizedRepo = typeof repo === 'string' ? repo.trim() : '' + const normalizedScope = + typeof workspaceScope === 'string' ? workspaceScope.trim().toLowerCase() : '' + + if (normalizedScope === 'local') { + return '__local__' + } + + if (normalizedScope === 'repository') { + return normalizedRepo || '__local__' + } + + if (!normalizedRepo) { + return '__local__' + } + + return normalizedRepo +} + +export const openStoredWorkspaceContextById = async ( + page: Page, + workspaceId: string, + { + repositoryFilter, + }: { + repositoryFilter?: string + } = {}, +) => { + const select = page.getByLabel('Stored workspace') + const openButton = page.locator('#workspaces-open') + + const resolveRepositoryFilterForWorkspace = async () => { + return page.evaluate(async targetWorkspaceId => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getRequest = store.get(targetWorkspaceId) + + const record = await new Promise | null>( + (resolve, reject) => { + getRequest.onsuccess = () => { + const value = + getRequest.result && typeof getRequest.result === 'object' + ? (getRequest.result as Record) + : null + resolve(value) + } + getRequest.onerror = () => reject(getRequest.error) + }, + ) + + if (!record) { + return '' + } + + const repo = typeof record.repo === 'string' ? record.repo : '' + const prContextState = + typeof record.prContextState === 'string' ? record.prContextState : '' + const prNumber = + typeof record.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null + + return { repo, prContextState, prNumber } + } finally { + db.close() + } + }, workspaceId) + } + + if (typeof repositoryFilter === 'string' && repositoryFilter.trim()) { + await selectWorkspacesRepositoryFilter(page, repositoryFilter) + } else { + const contextRecord = await resolveRepositoryFilterForWorkspace() + if (contextRecord && typeof contextRecord === 'object') { + const inferredFilter = getWorkspacesRepositoryFilterForRecord(contextRecord) + if (inferredFilter) { + await selectWorkspacesRepositoryFilter(page, inferredFilter) + } + } + } + + await ensureWorkspacesDrawerOpen(page) + + await expect + .poll(async () => { + const options = await select.locator('option').all() + for (const option of options) { + if ((await option.getAttribute('value')) === workspaceId) { + return true + } + } + + return false + }) + .toBe(true) + + await select.selectOption(workspaceId) + await expect(select).toHaveValue(workspaceId) + await expect(openButton).toBeEnabled() + await openButton.click() + await ensureWorkspacesDrawerClosed(page) +} + +export const openMostRecentStoredWorkspaceContext = async (page: Page) => { + const mostRecentContext = await page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const byLastModified = ( + left: Record, + right: Record, + ) => { + const leftModified = + typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified) + ? left.lastModified + : 0 + const rightModified = + typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified) + ? right.lastModified + : 0 + return rightModified - leftModified + } + + const sortedAll = records.slice().sort(byLastModified) + const mostRecent = sortedAll[0] + const id = typeof mostRecent?.id === 'string' ? mostRecent.id : '' + const repo = typeof mostRecent?.repo === 'string' ? mostRecent.repo : '' + const prContextState = + typeof mostRecent?.prContextState === 'string' ? mostRecent.prContextState : '' + const prNumber = + typeof mostRecent?.prNumber === 'number' && Number.isFinite(mostRecent.prNumber) + ? mostRecent.prNumber + : null + return { id, repo, prContextState, prNumber } + } finally { + db.close() + } + }) + + expect(mostRecentContext?.id).not.toBe('') + const repositoryFilter = getWorkspacesRepositoryFilterForRecord(mostRecentContext) + await openStoredWorkspaceContextById(page, mostRecentContext.id, { + repositoryFilter, + }) +} + +export const selectWorkspacesRepositoryFilter = async ( + page: Page, + repositoryFilter: string, +) => { + const workspacesToggle = page.getByRole('button', { name: 'Workspaces' }) + const repositorySelect = page.getByLabel('Workspace repository filter') + + if (!(await repositorySelect.isVisible())) { + const closePrButton = page.getByRole('button', { + name: 'Close open pull request drawer', + }) + if (await closePrButton.isVisible()) { + await closePrButton.click() + } + + await expect(workspacesToggle).toBeVisible() + await workspacesToggle.click() + await expect(repositorySelect).toBeVisible() + } + + await expect + .poll(async () => { + await repositorySelect.evaluate((element, value) => { + if (!(element instanceof HTMLSelectElement)) { + return '' + } + + element.value = value + element.dispatchEvent(new Event('change', { bubbles: true })) + return element.value + }, repositoryFilter) + + return repositorySelect.inputValue() + }) + .toBe(repositoryFilter) +} + +export const openStoredWorkspaceContextByHead = async ( + page: Page, + headBranch: string, +) => { + const workspace = await page.evaluate(async inputHeadBranch => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const normalizedHeadBranch = + typeof inputHeadBranch === 'string' ? inputHeadBranch.trim().toLowerCase() : '' + const matched = records.find(record => { + const recordHead = + typeof record?.head === 'string' ? record.head.trim().toLowerCase() : '' + return recordHead === normalizedHeadBranch + }) + + const id = typeof matched?.id === 'string' ? matched.id : '' + const repo = typeof matched?.repo === 'string' ? matched.repo : '' + const prContextState = + typeof matched?.prContextState === 'string' ? matched.prContextState : '' + const prNumber = + typeof matched?.prNumber === 'number' && Number.isFinite(matched.prNumber) + ? matched.prNumber + : null + return { id, repo, prContextState, prNumber } + } finally { + db.close() + } + }, headBranch) + + expect(workspace?.id).not.toBe('') + + const repositoryFilter = getWorkspacesRepositoryFilterForRecord(workspace) + + await openStoredWorkspaceContextById(page, workspace.id, { repositoryFilter }) +} + +export const seedLocalWorkspaceContexts = async ( + page: Page, + contexts: Array<{ + id: string + repo: string + workspaceScope?: 'local' | 'repository' + base?: string + head: string + prTitle: string + prNumber?: number | null + prContextState?: 'inactive' | 'active' | 'closed' + renderMode?: 'dom' | 'react' + tabs?: Array> + activeTabId?: string | null + createdAt?: number + lastModified?: number + }>, +) => { + await page.evaluate(async inputContexts => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readwrite') + const store = tx.objectStore('prWorkspaces') + const now = Date.now() + + for (const context of inputContexts) { + const putRequest = store.put({ + id: context.id, + workspaceScope: + context.workspaceScope === 'repository' || context.workspaceScope === 'local' + ? context.workspaceScope + : context.repo && context.repo.trim() + ? 'repository' + : 'local', + repo: context.repo, + base: context.base ?? 'main', + head: context.head, + prTitle: context.prTitle, + prNumber: + typeof context.prNumber === 'number' && Number.isFinite(context.prNumber) + ? context.prNumber + : null, + prContextState: + typeof context.prContextState === 'string' && context.prContextState.trim() + ? context.prContextState + : 'inactive', + renderMode: context.renderMode === 'react' ? 'react' : 'dom', + tabs: Array.isArray(context.tabs) ? context.tabs : [], + activeTabId: + typeof context.activeTabId === 'string' ? context.activeTabId : 'component', + schemaVersion: 1, + createdAt: + typeof context.createdAt === 'number' && Number.isFinite(context.createdAt) + ? context.createdAt + : now, + lastModified: + typeof context.lastModified === 'number' && + Number.isFinite(context.lastModified) + ? context.lastModified + : now, + }) + + await new Promise((resolve, reject) => { + putRequest.onsuccess = () => resolve() + putRequest.onerror = () => reject(putRequest.error) + }) + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }, contexts) +} + +export const toWorkspaceIdentitySegment = (value: string) => { + const normalized = value.trim().toLowerCase() + return normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') +} + +export const buildWorkspaceRecordId = ({ + repositoryFullName, + headBranch, +}: { + repositoryFullName: string + headBranch: string +}) => { + const repoSegment = toWorkspaceIdentitySegment(repositoryFullName) + const headSegment = toWorkspaceIdentitySegment(headBranch) || 'draft' + return repoSegment ? `repo_${repoSegment}_${headSegment}` : `workspace_${headSegment}` +} + +export const seedActivePrWorkspaceContext = async ( + page: Page, + { + repositoryFullName, + baseBranch = 'main', + headBranch, + prTitle, + prNumber, + renderMode = 'react', + styleLanguage = 'css', + }: { + repositoryFullName: string + baseBranch?: string + headBranch: string + prTitle: string + prNumber: number + renderMode?: 'dom' | 'react' + styleLanguage?: 'css' | 'sass' | 'less' + }, +) => { + const safeStyleLanguage = + styleLanguage === 'sass' || styleLanguage === 'less' ? styleLanguage : 'css' + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: baseBranch, + head: headBranch, + prTitle, + prNumber, + prContextState: 'active', + renderMode, + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Hello from Knighted
', + }, + { + id: 'style', + name: 'app.css', + path: 'src/styles/app.css', + language: safeStyleLanguage, + role: 'module', + isActive: false, + content: 'main { color: #111; }', + }, + ], + activeTabId: 'entry', + createdAt: Date.now() + 60_000, + lastModified: Date.now() + 60_000, + }, + ]) +} + +export const getLocalContextOptionLabels = async (page: Page) => { + return page + .getByLabel('Stored workspace') + .locator('option') + .evaluateAll(nodes => nodes.map(node => node.textContent?.trim() || '')) +} + +export const getWorkspaceTabsRecord = async ( + page: Page, + { headBranch = '' }: { headBranch?: string } = {}, +) => { + return page.evaluate( + async input => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) + ? getAllRequest.result + : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const normalizedHead = + typeof input?.headBranch === 'string' + ? input.headBranch.trim().toLowerCase() + : '' + + if (normalizedHead) { + const matched = records.find(record => { + const headValue = + typeof record?.head === 'string' ? record.head.trim().toLowerCase() : '' + return headValue === normalizedHead + }) + + if (matched) { + return matched + } + } + + const sortedByLastModified = [...records].sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' ? left.lastModified : 0 + const rightModified = + typeof right?.lastModified === 'number' ? right.lastModified : 0 + return rightModified - leftModified + }) + + return sortedByLastModified[0] ?? null + } finally { + db.close() + } + }, + { headBranch }, + ) +} + +export const getAllWorkspaceRecords = async (page: Page) => { + return page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records + } finally { + db.close() + } + }) +} + +export const getWorkspaceComponentContent = (record: Record | null) => { + if (!record || typeof record !== 'object') { + return '' + } + + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const componentTab = tabs.find(tab => { + if (!tab || typeof tab !== 'object') { + return false + } + + return (tab as { role?: unknown }).role === 'entry' + }) as { content?: unknown } | undefined + + return typeof componentTab?.content === 'string' ? componentTab.content : '' +} + +export const toRecordIntegritySnapshot = (record: Record | null) => { + return { + repo: typeof record?.repo === 'string' ? record.repo : '', + base: typeof record?.base === 'string' ? record.base : '', + head: typeof record?.head === 'string' ? record.head : '', + prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '', + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : 'inactive', + componentContent: getWorkspaceComponentContent(record), + } +} + +export const runActiveWorkspaceSwitchIntegrityScenario = async ({ + page, + targetState, +}: { + page: Page + targetState: 'inactive' | 'closed' +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const activeHeadBranch = 'develop/issue-97-active-a' + const targetHeadBranch = `develop/issue-97-target-${targetState}` + const activeWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: activeHeadBranch, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName, + headBranch: targetHeadBranch, + }) + const targetPrTitle = '' + const targetPrNumber = null + const expectedTargetPrContextState = targetState + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', activeHeadBranch, targetHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/**', + async route => { + const url = route.request().url() + const pullRequestNumberMatch = url.match(/\/pulls\/(\d+)/) + const pullRequestNumber = pullRequestNumberMatch + ? Number.parseInt(pullRequestNumberMatch[1] ?? '', 10) + : 0 + const isTargetPullRequest = pullRequestNumber === 9 + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: isTargetPullRequest ? 9 : 2, + state: isTargetPullRequest && targetState === 'closed' ? 'closed' : 'open', + title: isTargetPullRequest ? targetPrTitle : 'Active A workspace', + html_url: `https://github.com/knightedcodemonkey/develop/pull/${isTargetPullRequest ? 9 : 2}`, + head: { ref: isTargetPullRequest ? targetHeadBranch : activeHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + const url = route.request().url() + const isTargetHeadRef = url.includes(targetHeadBranch) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${isTargetHeadRef ? targetHeadBranch : activeHeadBranch}`, + object: { + type: 'commit', + sha: isTargetHeadRef ? 'target-head-sha' : 'active-head-sha', + }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: activeWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Active A workspace', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Active A content
', + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, + { + id: targetWorkspaceId, + repo: repositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: targetState, + renderMode: 'dom', + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
Target ${targetState} content
`, + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 120_000, + lastModified: Date.now() - 120_000, + }, + ]) + + await connectByotWithSingleRepo(page, { + branchesByRepo: { + [repositoryFullName]: ['main', activeHeadBranch, targetHeadBranch], + }, + }) + await openStoredWorkspaceContextById(page, activeWorkspaceId, { + repositoryFilter: repositoryFullName, + }) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await openStoredWorkspaceContextById(page, targetWorkspaceId) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText(`Target ${targetState} content`) + + const originalSnapshot = { + active: { + repo: repositoryFullName, + base: 'main', + head: activeHeadBranch, + prTitle: 'Active A workspace', + prNumber: 2, + prContextState: 'active', + componentContent: 'export const App = () =>
Active A content
', + }, + target: { + repo: repositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: expectedTargetPrContextState, + componentContent: `export const App = () =>
Target ${targetState} content
`, + }, + } + + const readSnapshot = async () => { + const records = await getAllWorkspaceRecords(page) + const activeRecord = records.find(record => record?.id === activeWorkspaceId) ?? null + const targetRecord = records.find(record => record?.id === targetWorkspaceId) ?? null + + return { + active: toRecordIntegritySnapshot(activeRecord as Record | null), + target: toRecordIntegritySnapshot(targetRecord as Record | null), + } + } + + await expect + .poll(async () => { + const snapshot = await readSnapshot() + const activeMatches = + JSON.stringify(snapshot.active) === JSON.stringify(originalSnapshot.active) + + const target = snapshot.target + const targetStateMatches = target?.prContextState === expectedTargetPrContextState + + const targetMatches = + target?.repo === originalSnapshot.target.repo && + target?.base === originalSnapshot.target.base && + target?.head === originalSnapshot.target.head && + target?.prTitle === originalSnapshot.target.prTitle && + target?.prNumber === originalSnapshot.target.prNumber && + target?.componentContent === originalSnapshot.target.componentContent && + targetStateMatches + + return activeMatches && targetMatches + }) + .toBe(true) +} + +export const runActiveWorkspaceCrossRepoSwitchIntegrityScenario = async ({ + page, + targetState, +}: { + page: Page + targetState: 'inactive' | 'closed' +}) => { + const sourceRepositoryFullName = 'knightedcodemonkey/develop' + const targetRepositoryFullName = 'knightedcodemonkey/css' + const sourceHeadBranch = 'develop/issue-97-cross-source-active' + const targetHeadBranch = `css/issue-97-cross-target-${targetState}` + const sourceWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepositoryFullName, + headBranch: sourceHeadBranch, + }) + const targetWorkspaceId = buildWorkspaceRecordId({ + repositoryFullName: targetRepositoryFullName, + headBranch: targetHeadBranch, + }) + const targetPrTitle = + targetState === 'inactive' ? '' : `Cross target ${targetState} workspace` + const targetPrNumber = 9 + const expectedTargetPrContextState = targetState + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: sourceRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: targetRepositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [sourceRepositoryFullName]: ['main', sourceHeadBranch], + [targetRepositoryFullName]: ['main', targetHeadBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Cross source active workspace', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: sourceHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/pulls/9', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 9, + state: targetState === 'closed' ? 'closed' : 'open', + title: targetPrTitle, + html_url: 'https://github.com/knightedcodemonkey/css/pull/9', + head: { ref: targetHeadBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${sourceHeadBranch}`, + object: { type: 'commit', sha: 'cross-source-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/css/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${targetHeadBranch}`, + object: { type: 'commit', sha: 'cross-target-head-sha' }, + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: sourceWorkspaceId, + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranch, + prTitle: 'Cross source active workspace', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Cross source active content
', + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 60_000, + lastModified: Date.now() - 60_000, + }, + { + id: targetWorkspaceId, + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: targetState, + renderMode: 'dom', + tabs: [ + { + id: 'entry', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: `export const App = () =>
Cross target ${targetState} content
`, + }, + ], + activeTabId: 'entry', + createdAt: Date.now() - 120_000, + lastModified: Date.now() - 120_000, + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await selectWorkspacesRepositoryFilter(page, sourceRepositoryFullName) + + await openStoredWorkspaceContextByHead(page, sourceHeadBranch) + + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + + await openStoredWorkspaceContextByHead(page, targetHeadBranch) + + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText(`Cross target ${targetState} content`) + + await expect + .poll(async () => { + const sourceRecord = await getWorkspaceTabsRecord(page, { + headBranch: sourceHeadBranch, + }) + return toRecordIntegritySnapshot(sourceRecord as Record | null) + }) + .toEqual({ + repo: sourceRepositoryFullName, + base: 'main', + head: sourceHeadBranch, + prTitle: 'Cross source active workspace', + prNumber: 2, + prContextState: 'active', + componentContent: + 'export const App = () =>
Cross source active content
', + }) + + await expect + .poll(async () => { + const targetRecord = await getWorkspaceTabsRecord(page, { + headBranch: targetHeadBranch, + }) + return toRecordIntegritySnapshot(targetRecord as Record | null) + }) + .toEqual({ + repo: targetRepositoryFullName, + base: 'main', + head: targetHeadBranch, + prTitle: targetPrTitle, + prNumber: targetPrNumber, + prContextState: expectedTargetPrContextState, + componentContent: `export const App = () =>
Cross target ${targetState} content
`, + }) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const sourceRecord = records.find( + record => + record?.repo === sourceRepositoryFullName && record?.head === sourceHeadBranch, + ) + const targetRecord = records.find( + record => + record?.repo === targetRepositoryFullName && record?.head === targetHeadBranch, + ) + return Boolean(sourceRecord) && Boolean(targetRecord) + }) + .toBe(true) +} diff --git a/playwright/github-pr-drawer/open-pr-confirmation.spec.ts b/playwright/github-pr-drawer/open-pr-confirmation.spec.ts new file mode 100644 index 0000000..544ebaf --- /dev/null +++ b/playwright/github-pr-drawer/open-pr-confirmation.spec.ts @@ -0,0 +1,368 @@ +import { expect, test } from '@playwright/test' +import { + appEntryPath, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + expectOpenPrConfirmationPrompt, + mockRepositoryBranches, + setComponentEditorSource, + submitOpenPrAndConfirm, + triggerOpenPrConfirmation, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +test('Open PR drawer shows confirmation with tab-derived files', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('PR title').fill('Tab-derived summary prompt') + const dialog = await triggerOpenPrConfirmation(page) + await expect(dialog.getByText('Files to commit:', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: 'Cancel' }).click() +}) + +test('Open PR drawer confirmation does not report path traversal errors', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('PR title').fill('No traversal error in default flow') + + await expectOpenPrConfirmationPrompt(page) + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).not.toContainText('File path cannot include parent directory traversal.') +}) + +test('Open PR drawer include entry tab checkbox defaults on and resets on reopen', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + const includeWrapperToggle = page.getByLabel('Include entry tab') + await expect(includeWrapperToggle).toBeChecked() + + await includeWrapperToggle.uncheck() + await expect(includeWrapperToggle).not.toBeChecked() + + await page.getByRole('button', { name: 'Close open pull request drawer' }).click() + await ensureOpenPrDrawerOpen(page) + + await expect(includeWrapperToggle).toBeChecked() +}) + +test('Open PR drawer includes App wrapper in committed component source by default', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 101, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + const componentSource = [ + 'const CounterButton = () => ', + 'const App = () => ', + ].join('\n') + + await setComponentEditorSource(page, componentSource) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') + await page.getByLabel('PR title').fill('Include App wrapper by default') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', + ) + + const treePayload = treeRequests[0]?.tree as Array> + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + expect(componentBlob?.content).toEqual(expect.any(String)) + const fullComponentSource = String(componentBlob?.content) + + expect(fullComponentSource).toContain('const CounterButton = () =>') + expect(fullComponentSource).toContain('const App = () =>') +}) + +test('Open PR drawer strips App wrapper from committed source when toggled off', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 101, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource( + page, + [ + 'const CounterButton = () => ', + 'const App = () => ', + ].join('\n'), + ) + await ensureOpenPrDrawerOpen(page) + + const includeWrapperToggle = page.getByLabel('Include entry tab') + await includeWrapperToggle.uncheck() + + await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') + await page.getByLabel('PR title').fill('Strip App wrapper in commit') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101', + ) + + const treePayload = treeRequests[0]?.tree as Array> + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + expect(componentBlob?.content).toEqual(expect.any(String)) + const strippedComponentSource = String(componentBlob?.content) + expect(strippedComponentSource).toContain('const CounterButton = () =>') + expect(strippedComponentSource).not.toContain('const App = () =>') +}) diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts new file mode 100644 index 0000000..64c59ad --- /dev/null +++ b/playwright/github-pr-drawer/open-pr-create.spec.ts @@ -0,0 +1,2500 @@ +import { expect, test } from '@playwright/test' +import type { + CreateRefRequestBody, + PullRequestCreateBody, +} from '../helpers/app-test-helpers.js' +import { + addWorkspaceTab, + appEntryPath, + buildWorkspaceRecordId, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + getAllWorkspaceRecords, + getLocalContextOptionLabels, + getWorkspaceRecordId, + getWorkspaceTabsRecord, + mockRepositoryBranches, + openStoredWorkspaceContextByHead, + openStoredWorkspaceContextById, + resetWorkbenchStorage, + seedLocalWorkspaceContexts, + selectWorkspacesRepositoryFilter, + setComponentEditorSource, + setStylesEditorSource, + submitOpenPrAndConfirm, + waitForAppReady, +} from './github-pr-drawer.helpers.js' + +test('Open PR drawer confirms and submits component/styles filepaths', async ({ + page, +}) => { + const customCommitMessage = 'chore: sync develop editor outputs' + let createdRefBody: CreateRefRequestBody | null = null + const treeRequests: Array> = [] + const commitRequests: Array> = [] + const updateRefRequests: Array> = [] + const contentsPutRequests: string[] = [] + let pullRequestBody: PullRequestCreateBody | null = null + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createdRefBody = route.request().postDataJSON() as CreateRefRequestBody + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestBody = route.request().postDataJSON() as PullRequestCreateBody + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 42, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/42', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Apply editor updates from develop') + await page + .getByLabel('PR description') + .fill('Generated from editor content in @knighted/develop.') + await page.getByLabel('Commit message').fill(customCommitMessage) + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/42', + ) + + const createdRefPayload = createdRefBody as CreateRefRequestBody | null + const pullRequestPayload = pullRequestBody as PullRequestCreateBody | null + + expect(createdRefPayload?.ref).toBe('refs/heads/Develop/Open-Pr-Test') + expect(createdRefPayload?.sha).toBe('abc123mainsha') + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(customCommitMessage) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('new-commit-sha') + expect(contentsPutRequests).toHaveLength(0) + expect(pullRequestPayload?.head).toBe('Develop/Open-Pr-Test') + expect(pullRequestPayload?.base).toBe('main') + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request base branch')).toHaveValue('main') + await expect(page.getByLabel('Head')).toHaveValue('Develop/Open-Pr-Test') + await expect(page.getByLabel('PR title')).toHaveValue( + 'Apply editor updates from develop', + ) + await expect(page.getByLabel('PR description')).toBeHidden() + await expect(page.getByLabel('Commit message')).toBeVisible() + await expect(page.getByLabel('Commit message')).toHaveValue(customCommitMessage) + await expect( + page.getByRole('button', { name: 'Push commit to active pull request branch' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Close active pull request context' }), + ).toBeVisible() + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch: 'Develop/Open-Pr-Test', + }) + return { + prContextState: + typeof record?.prContextState === 'string' ? record.prContextState : '', + prNumber: + typeof record?.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + prTitle: typeof record?.prTitle === 'string' ? record.prTitle : '', + } + }) + .toEqual({ + prContextState: 'active', + prNumber: 42, + prTitle: 'Apply editor updates from develop', + }) +}) + +test('Open PR ignores stale rename target deletions from workspace metadata', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const workspaceHeadBranch = 'feat/stale-target-path-metadata' + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'stale-open-pr-main-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/stale-open-pr-main-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'stale-open-pr-main-sha', + tree: { sha: 'stale-open-pr-base-tree' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'stale-open-pr-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'stale-open-pr-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/feat/stale-target-path-open-pr' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 144, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/144', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch: workspaceHeadBranch, + }), + repo: repositoryFullName, + base: 'main', + head: workspaceHeadBranch, + prTitle: '', + prNumber: null, + prContextState: 'inactive', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'const App = () => ', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'const App = () => ', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'styles.css', + path: 'src/styles.css', + language: 'css', + role: 'module', + isActive: true, + content: 'button { color: tomato; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: 'button { color: tomato; }', + syncedAt: now, + isDirty: true, + }, + ], + activeTabId: 'styles', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openStoredWorkspaceContextByHead(page, workspaceHeadBranch) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('feat/stale-target-path-open-pr') + await page.getByLabel('PR title').fill('Do not delete stale target path on open PR') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/144', + ) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const paths = treePayload?.map(file => String(file.path ?? '')) ?? [] + + expect(paths).toContain('src/components/App.tsx') + expect(paths).toContain('src/styles.css') + expect(paths).not.toContain('src/styles/app.css') +}) + +test('Push commit in active PR mode commits only dirty module path when entry is unchanged', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/module-only-push' + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Module-only commit PR', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: `refs/heads/${headBranch}`, + object: { type: 'commit', sha: 'module-push-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/module-push-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'module-push-head-sha', + tree: { sha: 'module-push-base-tree' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'module-push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'module-push-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const now = Date.now() + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Module-only commit PR', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: false, + content: 'export const App = () =>
Entry unchanged
', + targetPrFilePath: 'src/components/App.tsx', + syncedContent: 'export const App = () =>
Entry unchanged
', + syncedAt: now, + isDirty: false, + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.entry { color: #111; }', + targetPrFilePath: 'src/styles/app.css', + syncedContent: '.entry { color: #111; }', + syncedAt: now, + isDirty: false, + }, + { + id: 'module-card-tab', + name: 'feature-card.tsx', + path: 'src/components/feature-card.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: true, + content: 'export const FeatureCard = () => ', + targetPrFilePath: 'src/components/feature-card.tsx', + syncedContent: 'export const FeatureCard = () => ', + syncedAt: now, + isDirty: false, + }, + ], + activeTabId: 'module-card-tab', + createdAt: now, + lastModified: now, + }, + ]) + + await connectByotWithSingleRepo(page) + await openStoredWorkspaceContextByHead(page, headBranch) + + await page.getByRole('button', { name: 'Open tab feature-card.tsx' }).click() + await expect(page.getByRole('region', { name: 'feature-card.tsx' })).toBeVisible() + const componentEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await componentEditor.fill( + 'export const FeatureCard = () => ', + ) + await componentEditor.press('End') + await componentEditor.type(' ') + await componentEditor.press('Backspace') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Include entry tab').uncheck() + await page.getByRole('button', { name: 'Push commit' }).last().click() + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await expect( + dialog.getByText('feature-card.tsx -> src/components/feature-card.tsx', { + exact: true, + }), + ).toBeVisible() + await expect( + dialog.getByText('App.tsx -> src/components/App.tsx', { exact: true }), + ).toHaveCount(0) + + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText(`Commit pushed to ${headBranch}`) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + const paths = treePayload?.map(file => String(file.path ?? '')) ?? [] + expect(paths).toContain('src/components/feature-card.tsx') + expect(paths).not.toContain('src/components/App.tsx') +}) + +test('Open PR success normalizes trailing newline without showing Edited indicators', async ({ + page, +}) => { + const treeRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 62, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/62', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource(page, 'const App = () => ') + await setStylesEditorSource(page, '.button { color: red; }') + await addWorkspaceTab(page, { type: 'style' }) + + const moduleStylesEditor = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() + await moduleStylesEditor.fill('.button { padding: 20px; }') + await moduleStylesEditor.press('End') + await moduleStylesEditor.type(' ') + await moduleStylesEditor.press('Backspace') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Normalize trailing newline after open PR') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/62', + ) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'Develop/Open-Pr-Test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const componentTab = tabs.find(tab => tab?.role === 'entry') + const appStylesTab = tabs.find( + tab => + typeof tab?.path === 'string' && tab.path.trim() === 'src/styles/app.css', + ) + const moduleStylesTab = tabs.find( + tab => + typeof tab?.path === 'string' && + tab.path.trim().startsWith('src/styles/module') && + tab.path.trim().endsWith('.css'), + ) + + return { + componentNotDirty: componentTab?.isDirty === false, + appStylesNotDirty: appStylesTab?.isDirty === false, + moduleStylesNotDirty: moduleStylesTab?.isDirty === false, + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + componentNotDirty: true, + appStylesNotDirty: true, + moduleStylesNotDirty: true, + }) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect( + page + .getByRole('listitem', { name: 'Workspace tab app.css' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect(page.locator('#styles-dirty-status')).toBeHidden() + + const treePayload = treeRequests[0]?.tree as Array> + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + const stylesBlob = treePayload?.find(file => file.path === 'src/styles/app.css') + expect(typeof componentBlob?.content).toBe('string') + expect(typeof stylesBlob?.content).toBe('string') + expect(String(componentBlob?.content).endsWith('\n')).toBe(true) + expect(String(stylesBlob?.content).endsWith('\n')).toBe(true) +}) + +test('Workspaces repository selector filters contexts and keeps local-only contexts under Local', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_knightedcodemonkey_develop_feat-local-alpha', + repo: 'knightedcodemonkey/develop', + workspaceScope: 'local', + head: 'feat/local-alpha', + prTitle: 'Alpha local context', + prContextState: 'inactive', + prNumber: null, + }, + { + id: 'workspace_feat-active-alpha', + repo: 'knightedcodemonkey/develop', + head: 'feat/active-alpha', + prTitle: 'Alpha active context', + prContextState: 'active', + prNumber: 41, + }, + { + id: 'repo_knightedcodemonkey_css_feat-active-css', + repo: 'knightedcodemonkey/css', + head: 'feat/active-css', + prTitle: 'CSS active context', + prContextState: 'active', + prNumber: 51, + }, + ]) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + const developLabels = await getLocalContextOptionLabels(page) + expect(developLabels).toEqual(['Select a stored workspace', 'Alpha active context']) + + await selectWorkspacesRepositoryFilter(page, '__local__') + const localLabels = await getLocalContextOptionLabels(page) + expect(localLabels).toContain('Select a stored workspace') + expect(localLabels).toContain('local:Alpha local context') + expect(localLabels).not.toContain('Alpha active context') +}) + +test('Workspaces repository with no stored entries hides Workspace select and supports Initialize', async ({ + page, +}) => { + const seededRecordId = 'local_seed_initialize_preserved' + const seededHead = 'feat/local-preserved' + + await waitForAppReady(page, `${appEntryPath}`) + await seedLocalWorkspaceContexts(page, [ + { + id: seededRecordId, + repo: '', + base: 'main', + head: seededHead, + prTitle: 'Seed local context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + const pullRequestRepository = page.getByLabel('Pull request repository') + const repositoryValueBeforeScopeSelection = await pullRequestRepository.inputValue() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await expect(page.getByLabel('Stored workspace')).toBeHidden() + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeHidden() + await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeHidden() + await expect( + page.getByRole('button', { name: 'New workspace', exact: true }), + ).toBeHidden() + + const beforeInitializeRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === seededRecordId, + ) + expect(beforeInitializeRecord).toBeTruthy() + expect( + typeof beforeInitializeRecord?.repo === 'string' ? beforeInitializeRecord.repo : '', + ).toBe('') + await expect(pullRequestRepository).toHaveValue(repositoryValueBeforeScopeSelection) + + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) + await expect(initializeButton).toBeVisible() + await initializeButton.click() + + await ensureOpenPrDrawerOpen(page) + await expect(pullRequestRepository).toHaveValue('knightedcodemonkey/develop') + + await expect + .poll(async () => { + const updatedRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === seededRecordId, + ) + + const seededWorkspaceKey = + typeof updatedRecord?.workspaceKey === 'string' ? updatedRecord.workspaceKey : '' + + return { + seededRepo: typeof updatedRecord?.repo === 'string' ? updatedRecord.repo : '', + seededWorkspaceKeyHasRepositoryPrefix: seededWorkspaceKey.startsWith( + 'knightedcodemonkey-develop::', + ), + seededWorkspaceKeyIncludesHead: + seededWorkspaceKey.includes('feat/local-preserved'), + repositoryScopedCount: (await getAllWorkspaceRecords(page)).filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo : '' + const workspaceKey = + typeof record?.workspaceKey === 'string' ? record.workspaceKey : '' + return ( + repo === 'knightedcodemonkey/develop' && + workspaceKey.includes('knightedcodemonkey-develop::') + ) + }).length, + } + }) + .toEqual({ + seededRepo: '', + seededWorkspaceKeyHasRepositoryPrefix: false, + seededWorkspaceKeyIncludesHead: false, + repositoryScopedCount: 1, + }) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => record?.head === seededHead).length + }) + .toBe(1) +}) + +test('Local New workspace always creates a new stored workspace snapshot', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'local_seed_duplicate_key_guard', + repo: '', + base: 'main', + head: 'feat/component-seeded', + prTitle: 'Seed local context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.reload() + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + const countLocalRecords = async () => { + return page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + const records = await new Promise>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + resolve(Array.isArray(getAllRequest.result) ? getAllRequest.result : []) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return !repo + }).length + } finally { + db.close() + } + }) + } + + await selectWorkspacesRepositoryFilter(page, '__local__') + + const initialLocalRecordCount = await countLocalRecords() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden() + + await page.getByRole('button', { name: 'Workspaces' }).click() + await expect(page.getByRole('button', { name: 'Remove', exact: true })).toBeDisabled() + + await expect.poll(async () => countLocalRecords()).toBe(initialLocalRecordCount + 1) +}) + +test('Non-Local New workspace forks a new repository-scoped workspace when entries exist', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const seededHead = 'feat/repo-seeded-workspace' + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_seed_for_non_local_fork', + repo: repositoryFullName, + base: 'main', + head: seededHead, + prTitle: 'Seed repository context', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.reload() + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + + const countRepositoryRecords = async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => { + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName + }).length + } + + const initialRepositoryCount = await countRepositoryRecords() + await expect(page.getByRole('button', { name: 'Initialize', exact: true })).toBeHidden() + await expect( + page.getByRole('button', { name: 'New workspace', exact: true }), + ).toBeVisible() + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden() + + await expect.poll(async () => countRepositoryRecords()).toBe(initialRepositoryCount + 1) + + const persistedRecords = await getAllWorkspaceRecords(page) + const forkedRepositoryRecord = persistedRecords.find(record => { + const id = typeof record?.id === 'string' ? record.id.trim() : '' + const repo = typeof record?.repo === 'string' ? record.repo.trim() : '' + return repo === repositoryFullName && id !== 'repo_seed_for_non_local_fork' + }) + + expect(forkedRepositoryRecord).toBeTruthy() + expect(typeof forkedRepositoryRecord?.workspaceKey).toBe('string') + const forkedWorkspaceKey = String(forkedRepositoryRecord?.workspaceKey ?? '') + expect(forkedWorkspaceKey).toContain('knightedcodemonkey-develop::') + expect( + typeof forkedRepositoryRecord?.prTitle === 'string' + ? forkedRepositoryRecord.prTitle + : '', + ).toBe('') + expect(typeof forkedRepositoryRecord?.head).toBe('string') + expect(String(forkedRepositoryRecord?.head ?? '')).not.toBe(seededHead) +}) + +test('Removing a non-active workspace reselects the active workspace in Workspaces select', async ({ + page, +}) => { + const activeWorkspaceId = 'active_workspace_select_fallback_target' + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: activeWorkspaceId, + repo: '', + base: 'main', + head: 'feat/active-workspace', + prTitle: 'Active workspace', + prNumber: null, + prContextState: 'inactive', + }, + { + id: 'workspace_to_remove_from_drawer', + repo: '', + base: 'main', + head: 'feat/removable-workspace', + prTitle: 'Removable workspace', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.reload() + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + + await openStoredWorkspaceContextById(page, activeWorkspaceId, { + repositoryFilter: '__local__', + }) + + await page.getByRole('button', { name: 'Workspaces' }).click() + await selectWorkspacesRepositoryFilter(page, '__local__') + + const storedWorkspaceSelect = page.locator('#workspaces-select') + const removeWorkspaceButton = page.getByRole('button', { + name: 'Remove', + exact: true, + }) + + const resolveRemovableWorkspaceId = () => + storedWorkspaceSelect.evaluate((element, activeId) => { + if (!(element instanceof HTMLSelectElement)) { + return '' + } + + const candidates = Array.from(element.options) + .map(option => option.value) + .filter(value => value && value !== activeId) + + return candidates[0] ?? '' + }, activeWorkspaceId) + + await expect.poll(resolveRemovableWorkspaceId).not.toBe('') + const removableWorkspaceId = await resolveRemovableWorkspaceId() + + await storedWorkspaceSelect.selectOption(removableWorkspaceId) + await expect(storedWorkspaceSelect).toHaveValue(removableWorkspaceId) + await expect(removeWorkspaceButton).toBeEnabled() + await removeWorkspaceButton.click() + + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + + await expect + .poll(async () => storedWorkspaceSelect.inputValue()) + .toBe(activeWorkspaceId) + await expect(removeWorkspaceButton).toBeDisabled() +}) + +test('Switching Workspaces repository scope to Local keeps inactive record repo and shows it as local in drawer', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/contract-case' + const headBranch = 'feat/component-v8zw' + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: 'repo_knightedcodemonkey_contract-case_feat-component-v8zw', + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: '', + prNumber: null, + prContextState: 'inactive', + }, + ]) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', headBranch], + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await openStoredWorkspaceContextByHead(page, headBranch) + await selectWorkspacesRepositoryFilter(page, '__local__') + + await expect + .poll(async () => { + const localLabels = await getLocalContextOptionLabels(page) + return localLabels.includes('local:feat/component-v8zw') + }) + .toBe(true) + + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { + headBranch, + }) + + return typeof record?.repo === 'string' ? record.repo : null + }) + .toBe(repositoryFullName) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + return records.filter(record => record?.head === headBranch).length + }) + .toBe(1) +}) + +test('Blank-slate startup persists inactive local workspace before PAT', async ({ + page, +}) => { + await resetWorkbenchStorage(page) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + if (!Array.isArray(records) || records.length === 0) { + return false + } + + const latest = records.slice().sort((a, b) => { + const aLastModified = + typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified) + ? a.lastModified + : 0 + const bLastModified = + typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified) + ? b.lastModified + : 0 + return bLastModified - aLastModified + })[0] + + return ( + latest?.prContextState === 'inactive' && + latest?.prNumber === null && + typeof latest?.repo === 'string' + ) + }) + .toBe(true) +}) + +test('Fresh PAT bootstrap does not persist drawer head metadata to IDB before submit', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/contract-case' + + await resetWorkbenchStorage(page) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() + + const initialRecord = await getWorkspaceTabsRecord(page) + const initialRecordId = getWorkspaceRecordId(initialRecord) + expect(initialRecordId).not.toBe('') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('develop/fresh-pat-bootstrap') + await page.getByLabel('Head').blur() + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const matching = records.filter(record => record?.id === initialRecordId) + const latest = matching.sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified) + ? left.lastModified + : 0 + const rightModified = + typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified) + ? right.lastModified + : 0 + return rightModified - leftModified + })[0] + + return { + count: matching.length, + id: typeof latest?.id === 'string' ? latest.id : '', + head: typeof latest?.head === 'string' ? latest.head : '', + } + }) + .toEqual({ + count: 1, + id: initialRecordId, + head: typeof initialRecord?.head === 'string' ? initialRecord.head : '', + }) + + const updatedRecord = (await getAllWorkspaceRecords(page)).find( + record => record?.id === initialRecordId, + ) + expect(updatedRecord?.head).toBe(initialRecord?.head) +}) + +test('Changing head does not update current workspace without explicit submit', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/contract-case' + + await resetWorkbenchStorage(page) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await selectWorkspacesRepositoryFilter(page, repositoryFullName) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() + + const initialRecord = await getWorkspaceTabsRecord(page) + const initialRecordId = getWorkspaceRecordId(initialRecord) + expect(initialRecordId).not.toBe('') + + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('develop/head-first') + await page.getByLabel('Head').blur() + await page.getByLabel('Head').fill('develop/head-second') + await page.getByLabel('Head').blur() + + await expect + .poll(async () => { + const records = await getAllWorkspaceRecords(page) + const matching = records.filter(record => record?.id === initialRecordId) + const latest = matching.sort((left, right) => { + const leftModified = + typeof left?.lastModified === 'number' && Number.isFinite(left.lastModified) + ? left.lastModified + : 0 + const rightModified = + typeof right?.lastModified === 'number' && Number.isFinite(right.lastModified) + ? right.lastModified + : 0 + return rightModified - leftModified + })[0] + + return { + count: matching.length, + id: typeof latest?.id === 'string' ? latest.id : '', + head: typeof latest?.head === 'string' ? latest.head : '', + } + }) + .toEqual({ + count: 1, + id: initialRecordId, + head: typeof initialRecord?.head === 'string' ? initialRecord.head : '', + }) +}) + +for (const prContextState of ['inactive', 'closed'] as const) { + test(`Head stays fixed across repository changes for ${prContextState} workspace context`, async ({ + page, + browserName, + }) => { + // WebKit-only quarantine: keep these specs active on Chromium while CI flake is investigated. + test.fixme( + browserName === 'webkit', + 'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.', + ) + + const sourceRepository = 'knightedcodemonkey/contract-case' + const targetRepository = 'knightedcodemonkey/develop-sandbox' + const workspaceHead = 'feat/component-j101' + const workspaceId = buildWorkspaceRecordId({ + repositoryFullName: sourceRepository, + headBranch: workspaceHead, + }) + + await resetWorkbenchStorage(page) + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: sourceRepository, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 13, + owner: { login: 'knightedcodemonkey' }, + name: 'develop-sandbox', + full_name: targetRepository, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [sourceRepository]: ['main', 'release', workspaceHead], + [targetRepository]: ['main', 'release'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: workspaceId, + repo: sourceRepository, + base: 'main', + head: workspaceHead, + prTitle: '', + prNumber: null, + prContextState, + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Workspace context
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + }, + ], + activeTabId: 'component', + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextById(page, workspaceId) + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository) + await expect(page.getByLabel('Head')).toHaveValue(workspaceHead) + + await selectWorkspacesRepositoryFilter(page, targetRepository) + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository) + + await expect(page.getByLabel('Head')).toHaveValue(workspaceHead) + await expect + .poll(async () => { + const record = await getWorkspaceTabsRecord(page, { headBranch: workspaceHead }) + return record?.head === workspaceHead + }) + .toBe(true) + }) +} + +test('Open PR promotes inactive workspace when repository changes', async ({ + page, + browserName, +}) => { + // WebKit-only quarantine: keep this spec active on Chromium while CI flake is investigated. + test.fixme( + browserName === 'webkit', + 'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.', + ) + + const oldRepository = 'knightedcodemonkey/contract-case' + const newRepository = 'knightedcodemonkey/develop-sandbox' + const headBranch = 'feat/component-sync' + const oldWorkspaceId = 'repo_knightedcodemonkey_contract-case_feat-component-sync' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 12, + owner: { login: 'knightedcodemonkey' }, + name: 'contract-case', + full_name: oldRepository, + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 13, + owner: { login: 'knightedcodemonkey' }, + name: 'develop-sandbox', + full_name: newRepository, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [oldRepository]: ['main'], + [newRepository]: ['main', 'release'], + }) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/ref/**`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ object: { sha: 'branch-head-sha' } }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/refs`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/commits/branch-head-sha`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'branch-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/trees`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/commits`, + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/git/refs/**`, + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: `refs/heads/${headBranch}` }), + }) + }, + ) + + await page.route( + `https://api.github.com/repos/${newRepository}/contents/**`, + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route(`https://api.github.com/repos/${newRepository}/pulls`, async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 88, + html_url: `https://github.com/${newRepository}/pull/88`, + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: oldWorkspaceId, + repo: oldRepository, + base: 'main', + head: headBranch, + prTitle: 'Seeded inactive context', + prNumber: null, + prContextState: 'inactive', + renderMode: 'dom', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Seeded workspace
', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'main { color: #111; }', + }, + ], + activeTabId: 'component', + }, + ]) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await openStoredWorkspaceContextById(page, oldWorkspaceId) + + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(oldRepository) + await selectWorkspacesRepositoryFilter(page, newRepository) + await page.getByRole('button', { name: 'Initialize', exact: true }).click() + await ensureOpenPrDrawerOpen(page) + await expect(page.getByLabel('Pull request repository')).toHaveValue(newRepository) + + await page.getByLabel('Head').fill(headBranch) + await page.getByLabel('PR title').fill('Promote inactive context to active PR') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText(`Pull request opened: https://github.com/${newRepository}/pull/88`) + + const workspaceRecords = await getAllWorkspaceRecords(page) + const recordsByHead = workspaceRecords.filter( + record => + typeof record?.head === 'string' && record.head.trim().toLowerCase() === headBranch, + ) + + const promotedActiveRecord = recordsByHead.find(record => record?.prNumber === 88) + + expect(promotedActiveRecord).toBeTruthy() + expect(promotedActiveRecord?.prNumber).toBe(88) + expect(promotedActiveRecord?.head).toBe(headBranch) + expect(promotedActiveRecord?.prContextState).toBe('active') + + expect(recordsByHead).toHaveLength(1) +}) + +test('Open PR drawer uses Git Database API atomic commit path by default', async ({ + page, +}) => { + const treeRequests: Array> = [] + const commitRequests: Array> = [] + const updateRefRequests: Array> = [] + const contentsPutRequests: string[] = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + object: { sha: 'branch-head-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/branch-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'branch-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + if (route.request().method() === 'PATCH') { + updateRefRequests.push(route.request().postDataJSON() as Record) + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + if (route.request().method() === 'PUT') { + contentsPutRequests.push(route.request().url()) + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 52, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/52', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Apply editor updates from develop') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/52', + ) + + expect(treeRequests).toHaveLength(1) + expect((treeRequests[0]?.tree as Array>)?.length).toBe(2) + expect(commitRequests).toHaveLength(1) + expect(updateRefRequests).toHaveLength(1) + expect(updateRefRequests[0]?.sha).toBe('new-commit-sha') + expect(contentsPutRequests).toHaveLength(0) +}) + +test('Open PR drawer surfaces an error when Git Database commit fails', async ({ + page, +}) => { + const treeRequests: Array> = [] + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ object: { sha: 'branch-head-sha' } }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/Develop/Open-Pr-Test' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/branch-head-sha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'branch-head-sha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Tree API unavailable' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 53, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/53', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('Develop/Open-Pr-Test') + await page.getByLabel('PR title').fill('Apply editor updates from develop') + + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText('Open PR failed:') + + expect(treeRequests).toHaveLength(1) + expect(pullRequestRequestCount).toBe(0) +}) + +test('Open PR drawer starts with empty title/description and short default head', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + const headValue = await page.getByLabel('Head').inputValue() + expect(headValue).toMatch(/^feat\/component-[a-z0-9]{4}$/) + await expect(page.getByLabel('PR title')).toHaveValue('') + await expect(page.getByLabel('PR description')).toHaveValue('') +}) + +test('Open PR drawer hard-fails when requested head branch already exists', async ({ + page, +}) => { + let createRefRequestCount = 0 + let treeRequestCount = 0 + let pullRequestRequestCount = 0 + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + createRefRequestCount += 1 + await route.fulfill({ + status: 422, + contentType: 'application/json', + body: JSON.stringify({ + message: 'Reference already exists', + documentation_url: 'https://docs.github.com/rest/git/refs#create-a-reference', + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + pullRequestRequestCount += 1 + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 77, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/77', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('feat/A') + await page.getByLabel('PR title').fill('Should fail for existing branch') + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Open PR failed: Branch feat/A already exists. Choose another branch name and retry.', + ) + + expect(createRefRequestCount).toBe(1) + expect(treeRequestCount).toBe(0) + expect(pullRequestRequestCount).toBe(0) +}) + +test('Open PR drawer base dropdown updates from mocked repo branches', async ({ + page, +}) => { + const branchRequestUrls: string[] = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await page.route('https://api.github.com/repos/**/branches**', async route => { + const url = route.request().url() + branchRequestUrls.push(url) + + const branchNames = url.includes('/repos/knightedcodemonkey/css/branches') + ? ['stable', 'release/1.x'] + : ['main', 'develop-next'] + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(branchNames.map(name => ({ name }))), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'Loaded 2 writable repositories', + ) + + await ensureOpenPrDrawerOpen(page) + + const repoSelect = page.getByLabel('Pull request repository') + const baseSelect = page.getByLabel('Pull request base branch') + await expect(repoSelect).toBeDisabled() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await page.getByRole('button', { name: 'Initialize', exact: true }).click() + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(baseSelect).toHaveValue('main') + await expect(baseSelect.getByRole('option')).toHaveText(['main', 'develop-next']) + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await page.getByRole('button', { name: 'Initialize', exact: true }).click() + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/css') + await expect(baseSelect).toHaveValue('stable') + await expect(baseSelect.getByRole('option')).toHaveText(['stable', 'release/1.x']) + + expect( + branchRequestUrls.some(url => + url.includes('https://api.github.com/repos/knightedcodemonkey/develop/branches'), + ), + ).toBe(true) + expect( + branchRequestUrls.some(url => + url.includes('https://api.github.com/repos/knightedcodemonkey/css/branches'), + ), + ).toBe(true) +}) + +test('Open PR drawer does not persist active PR context in localStorage', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'develop-next'], + 'knightedcodemonkey/css': ['stable', 'release/1.x'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await ensureOpenPrDrawerOpen(page) + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('examples/develop/head') + await page.getByLabel('Head').blur() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('examples/css/head') + await page.getByLabel('Head').blur() + + const legacyKeys = await page.evaluate(() => { + const storagePrefix = 'knighted:develop:github-pr-config:' + return Object.keys(localStorage).filter(key => key.startsWith(storagePrefix)) + }) + + expect(legacyKeys).toHaveLength(0) +}) + +test('Open PR drawer never writes repo PR context keys in localStorage', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'develop-next'], + 'knightedcodemonkey/css': ['stable', 'release/1.x'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await ensureOpenPrDrawerOpen(page) + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await ensureOpenPrDrawerOpen(page) + await page.getByLabel('Head').fill('examples/develop/head') + await page.getByLabel('Head').blur() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + + const legacyKeys = await page.evaluate(() => { + const storagePrefix = 'knighted:develop:github-pr-config:' + return Object.keys(localStorage).filter(key => key.startsWith(storagePrefix)) + }) + + expect(legacyKeys).toHaveLength(0) +}) + +test('Open PR repository field stays read-only while Workspaces controls repository selection', async ({ + page, +}) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'stable', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'develop-next'], + 'knightedcodemonkey/css': ['stable', 'release/1.x'], + }) + + await waitForAppReady(page, `${appEntryPath}`) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + + await ensureOpenPrDrawerOpen(page) + const repoSelect = page.getByLabel('Pull request repository') + const initialRepoValue = await repoSelect.inputValue() + await expect(repoSelect).toBeDisabled() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/develop') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible() + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue(initialRepoValue) + await expect(repoSelect).toBeDisabled() + + await selectWorkspacesRepositoryFilter(page, 'knightedcodemonkey/css') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeVisible() + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue(initialRepoValue) + await expect(repoSelect).toBeDisabled() +}) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 14cb08f..4447f47 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -39,8 +39,40 @@ export type PullRequestCreateBody = { export type BranchesByRepo = Record +const isRetryableGotoError = (error: unknown) => { + if (!(error instanceof Error)) { + return false + } + + return /WebKit encountered an internal error|Test timeout/i.test(error.message) +} + +const navigateToApp = async (page: Page, path: string) => { + const wait = (durationMs: number) => + new Promise(resolve => { + setTimeout(resolve, durationMs) + }) + + let attempt = 0 + + while (attempt < 3) { + attempt += 1 + + try { + await page.goto(path, { waitUntil: 'domcontentloaded' }) + return + } catch (error) { + if (attempt >= 3 || !isRetryableGotoError(error)) { + throw error + } + + await wait(attempt * 200) + } + } +} + export const waitForAppReady = async (page: Page, path = appEntryPath) => { - await page.goto(path) + await navigateToApp(page, path) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() await expect .poll(async () => { @@ -51,12 +83,60 @@ export const waitForAppReady = async (page: Page, path = appEntryPath) => { return ( statusText === 'Rendered' || statusText?.startsWith('Rendered (Type errors:') || - statusText === 'Error' + statusText === 'Error' || + statusText === 'Could not restore local workspace context.' ) }) .toBe(true) } +export const resetWorkbenchStorage = async (page: Page) => { + await page.goto(appEntryPath) + await page.evaluate(async () => { + try { + localStorage.clear() + } catch { + /* noop */ + } + + try { + sessionStorage.clear() + } catch { + /* noop */ + } + + const deleteIndexedDbByName = async (name: string) => { + await new Promise(resolve => { + if (!name) { + resolve() + return + } + + const request = indexedDB.deleteDatabase(name) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + }) + } + + if (typeof indexedDB === 'undefined') { + return + } + + if (typeof indexedDB.databases === 'function') { + const databases = await indexedDB.databases() + const databaseNames = (databases || []) + .map(entry => entry?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + + await Promise.all(databaseNames.map(name => deleteIndexedDbByName(name))) + return + } + + await deleteIndexedDbByName('knighted-develop-workspaces') + }) +} + export const waitForInitialRender = async (page: Page) => { await waitForAppReady(page) await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') @@ -70,19 +150,100 @@ export const expectPreviewHasRenderedContent = async (page: Page) => { .toBeGreaterThan(0) } +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const getPreviewFrame = (page: Page) => page.frameLocator('#preview-host iframe') + +export const addWorkspaceTab = async ( + page: Page, + { type = 'script' }: { type?: 'script' | 'style' } = {}, +) => { + await page.getByRole('button', { name: 'Add workspace tab' }).click() + if (type === 'style') { + await page.getByRole('button', { name: 'Add styles tab' }).click() + return + } + + await page.getByRole('button', { name: 'Add module tab' }).click() +} + +export const openWorkspaceTab = async (page: Page, fileName: string) => { + const pattern = new RegExp(`^Open tab ${escapeRegex(fileName)}$`) + await page.getByRole('button', { name: pattern }).click() +} + +const replaceEditorSource = async ({ + editorContent, + source, +}: { + editorContent: ReturnType + source: string +}) => { + for (let attempt = 0; attempt < 2; attempt += 1) { + await editorContent.fill('') + await editorContent.fill(source) + await editorContent.press('End') + await editorContent.type(' ') + await editorContent.press('Backspace') + } +} + +export const reorderWorkspaceTabBefore = async ( + page: Page, + { from, to }: { from: string; to: string }, +) => { + const tabList = page.getByRole('list', { name: 'Workspace editor tabs' }) + const source = tabList.getByRole('listitem', { + name: new RegExp(`^Workspace tab ${escapeRegex(from)}$`), + }) + const target = tabList.getByRole('listitem', { + name: new RegExp(`^Workspace tab ${escapeRegex(to)}$`), + }) + + await source.dragTo(target) +} + +export const setWorkspaceTabSource = async ( + page: Page, + { + fileName, + source, + kind = 'component', + }: { + fileName: string + source: string + kind?: 'component' | 'styles' + }, +) => { + await openWorkspaceTab(page, fileName) + await expect(page.getByRole('region', { name: fileName })).toBeVisible() + const editorContent = page + .locator(`.editor-panel[data-editor-kind="${kind}"] .cm-content`) + .first() + await replaceEditorSource({ editorContent, source }) +} + export const setComponentEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.component-panel .cm-content').first() - await editorContent.fill(source) + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await expect(page.getByRole('region', { name: 'App.tsx' })).toBeVisible() + const editorContent = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await replaceEditorSource({ editorContent, source }) } export const setStylesEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.styles-panel .cm-content').first() - await editorContent.fill(source) + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await expect(page.getByRole('region', { name: 'app.css' })).toBeVisible() + const editorContent = page + .locator('.editor-panel[data-editor-kind="styles"] .cm-content') + .first() + await replaceEditorSource({ editorContent, source }) } export const getActiveComponentEditorLineNumber = async (page: Page) => { return page - .locator('#component-panel .cm-activeLineGutter') + .locator('#editor-panel-component .cm-activeLineGutter') .first() .innerText() .then(text => text.trim()) @@ -103,18 +264,49 @@ export const runStylesLint = async (page: Page) => { await page.getByRole('button', { name: 'Styles lint' }).click() } +export const waitForLintDiagnosticsIssues = async ( + page: Page, + { + rerunLint, + }: { + rerunLint?: () => Promise + } = {}, +) => { + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + const expectLintIssuesVisible = async () => { + await expect(diagnosticsToggle).toHaveAttribute('aria-busy', 'false') + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toBeVisible() + } + + try { + await expectLintIssuesVisible() + } catch (error) { + if (typeof rerunLint !== 'function') { + throw error + } + + await rerunLint() + await expectLintIssuesVisible() + } + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-styles')).toContainText( + 'Biome reported issues.', + ) +} + export const getActiveStylesEditorLineNumber = async (page: Page) => { return page - .locator('#styles-panel .cm-activeLineGutter') + .locator('#editor-panel-styles .cm-activeLineGutter') .first() .innerText() .then(text => text.trim()) } -export const getCollapseButton = ( - page: Page, - panelName: 'component' | 'styles' | 'preview', -) => page.locator(`#collapse-${panelName}`) +export const getCollapseButton = (page: Page, panelName: 'preview') => + page.locator(`#collapse-${panelName}`) export const getToolsButton = (page: Page, panelName: 'component' | 'styles') => page.locator(`#tools-${panelName}`) @@ -123,6 +315,12 @@ export const ensurePanelToolsVisible = async ( page: Page, panelName: 'component' | 'styles', ) => { + if (panelName === 'styles') { + await page.getByRole('button', { name: 'Open tab app.css' }).click() + } else { + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + } + const button = getToolsButton(page, panelName) const isPressed = await button.getAttribute('aria-pressed') if (isPressed !== 'true') { @@ -137,7 +335,33 @@ export const ensureDiagnosticsDrawerOpen = async (page: Page) => { const isExpanded = await toggle.getAttribute('aria-expanded') if (isExpanded !== 'true') { - await toggle.click() + const waitForExpanded = async () => { + await expect + .poll(async () => { + return toggle.getAttribute('aria-expanded') + }) + .toBe('true') + } + + try { + await toggle.click({ timeout: 2_000 }) + await waitForExpanded() + } catch { + /* WebKit can report pointer interception from the drawer during transitions. */ + try { + await toggle.focus() + await page.keyboard.press('Enter') + await waitForExpanded() + } catch { + /* Fallback for intermittent top-layer/actionability issues. */ + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + await waitForExpanded() + } + } } await expect(page.getByRole('complementary', { name: 'Diagnostics' })).toBeVisible() @@ -175,7 +399,15 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => { const isExpanded = await toggle.getAttribute('aria-expanded') if (isExpanded !== 'true') { - await toggle.click() + try { + await toggle.click({ timeout: 2_000 }) + } catch { + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } } await expect( @@ -183,6 +415,48 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => { ).toBeVisible() } +export const ensureWorkspacesDrawerClosed = async (page: Page) => { + const toggle = page.locator('#workspaces-toggle') + await expect(toggle).toBeVisible() + + const requestClose = async () => { + const closeButton = page.locator('#workspaces-close') + if (await closeButton.isVisible()) { + await closeButton.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + return + } + + await toggle.evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) + } + + const isExpanded = await toggle.getAttribute('aria-expanded') + if (isExpanded === 'true') { + await requestClose() + } + + await expect + .poll(async () => { + const expanded = await toggle.getAttribute('aria-expanded') + if (expanded === 'true') { + await requestClose() + } + + return expanded + }) + .toBe('false') + + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(page.getByRole('complementary', { name: 'Workspaces' })).toBeHidden() +} + export const mockRepositoryBranches = async ( page: Page, branchesByRepo: BranchesByRepo = {}, @@ -205,7 +479,14 @@ export const mockRepositoryBranches = async ( }) } -export const connectByotWithSingleRepo = async (page: Page) => { +export const connectByotWithSingleRepo = async ( + page: Page, + { + branchesByRepo, + }: { + branchesByRepo?: BranchesByRepo + } = {}, +) => { await page.route('https://api.github.com/user/repos**', async route => { await route.fulfill({ status: 200, @@ -223,17 +504,54 @@ export const connectByotWithSingleRepo = async (page: Page) => { }) }) - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - }) + await mockRepositoryBranches( + page, + branchesByRepo ?? { + 'knightedcodemonkey/develop': ['main', 'release'], + }, + ) await page .getByRole('textbox', { name: 'GitHub token' }) .fill('github_pat_fake_chat_1234567890') await page.getByRole('button', { name: 'Add GitHub token' }).click() + const workspacesToggle = page.getByRole('button', { name: 'Workspaces' }) + await expect(workspacesToggle).toBeVisible() + await workspacesToggle.click() + + const workspacesRepositoryFilter = page.getByLabel('Workspace repository filter') + await expect(workspacesRepositoryFilter).toBeVisible() + await workspacesRepositoryFilter.selectOption('knightedcodemonkey/develop') + await expect(workspacesRepositoryFilter).toHaveValue('knightedcodemonkey/develop') + + const initializeButton = page.getByRole('button', { + name: 'Initialize', + exact: true, + }) + + if (await initializeButton.isVisible()) { + await initializeButton.click() + } else { + const storedWorkspace = page.getByLabel('Stored workspace') + if (await storedWorkspace.isVisible()) { + const workspaceValue = await storedWorkspace + .locator('option:not([value=""])') + .first() + .getAttribute('value') + + if (workspaceValue) { + await storedWorkspace.selectOption(workspaceValue) + await page.getByRole('button', { name: 'Open', exact: true }).click() + } + } + } + + await ensureWorkspacesDrawerClosed(page) + const repoSelect = page.getByLabel('Pull request repository') await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + await expect(repoSelect).toBeDisabled() await expect( page.getByRole('button', { @@ -244,7 +562,7 @@ export const connectByotWithSingleRepo = async (page: Page) => { export const expectCollapseButtonState = async ( page: Page, - panelName: 'component' | 'styles' | 'preview', + panelName: 'preview', { axis, direction, diff --git a/playwright/layout-panels.spec.ts b/playwright/layout-panels.spec.ts index 5030d8d..51048a2 100644 --- a/playwright/layout-panels.spec.ts +++ b/playwright/layout-panels.spec.ts @@ -3,6 +3,7 @@ import { expectCollapseButtonState, expectPreviewHasRenderedContent, getCollapseButton, + getPreviewFrame, getToolsButton, waitForInitialRender, } from './helpers/app-test-helpers.js' @@ -10,17 +11,13 @@ import { test('renders default playground preview', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('ShadowRoot').uncheck() await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') await expectPreviewHasRenderedContent(page) }) -test('supports layout and theme toggles', async ({ page }) => { +test('supports theme toggles', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await page.getByLabel('Use light theme').click() await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') @@ -33,14 +30,74 @@ test('supports layout and theme toggles', async ({ page }) => { expect(previewBackgroundColor).toBe('rgb(36, 86, 168)') }) -test('side layout keeps preview panel height within editor stack height', async ({ +test('light theme defaults preview background to white', async ({ page }) => { + await waitForInitialRender(page) + + await page.getByLabel('Use light theme').click() + + const previewBackgroundColor = await page.evaluate(() => { + const previewHost = document.getElementById('preview-host') + return previewHost ? getComputedStyle(previewHost).backgroundColor : '' + }) + + expect(previewBackgroundColor).toBe('rgb(255, 255, 255)') +}) + +test('dark theme defaults preview background to editor background', async ({ page }) => { + await waitForInitialRender(page) + + const colors = await page.evaluate(() => { + const previewHost = document.getElementById('preview-host') + const componentPanel = document.getElementById('editor-panel-component') + + return { + preview: previewHost ? getComputedStyle(previewHost).backgroundColor : '', + editor: componentPanel ? getComputedStyle(componentPanel).backgroundColor : '', + } + }) + + const toRgbChannels = (value: string) => + (value.match(/\d+/g) ?? []).slice(0, 3).map(entry => Number.parseInt(entry, 10)) + + expect(toRgbChannels(colors.preview)).toEqual(toRgbChannels(colors.editor)) +}) + +test('changing preview background keeps applied preview styles', async ({ page }) => { + await waitForInitialRender(page) + + const previewFrameRoot = getPreviewFrame(page).locator('html') + + await expect(previewFrameRoot).toHaveCount(1) + const hasComponentStylesBefore = await previewFrameRoot.evaluate(() => { + const styleElement = document.getElementById('knighted-preview-styles') + if (!(styleElement instanceof HTMLStyleElement)) { + return false + } + + return styleElement.textContent?.includes('.counter-button') ?? false + }) + expect(hasComponentStylesBefore).toBe(true) + + await page.getByLabel('Background').fill('#b1aaaa') + + const hasComponentStylesAfter = await previewFrameRoot.evaluate(() => { + const styleElement = document.getElementById('knighted-preview-styles') + if (!(styleElement instanceof HTMLStyleElement)) { + return false + } + + return styleElement.textContent?.includes('.counter-button') ?? false + }) + expect(hasComponentStylesAfter).toBe(true) + + await expect(previewFrameRoot).toHaveCSS('background-color', 'rgb(177, 170, 170)') +}) + +test('fixed layout keeps preview panel height within editor stack height', async ({ page, }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - const metrics = await page.evaluate(() => { const stack = document.querySelector('.panels-stack--editors') const previewPanel = document.getElementById('preview-panel') @@ -56,13 +113,9 @@ test('side layout keeps preview panel height within editor stack height', async expect(metrics.previewOverflowY).toBe('hidden') }) -test('side layout config keeps preview scrolling inside preview host', async ({ - page, -}) => { +test('fixed layout keeps preview scrolling inside preview host', async ({ page }) => { await waitForInitialRender(page) - await page.getByLabel('Use side preview layout').click() - const scrollConfig = await page.evaluate(() => { const previewPanel = document.getElementById('preview-panel') const previewHost = document.getElementById('preview-host') @@ -87,97 +140,57 @@ test('side layout config keeps preview scrolling inside preview host', async ({ expect(scrollConfig?.minHeight).toBe('0px') }) -test('expanded component and styles can shrink consistently in side layouts', async ({ +test('expanded component and styles can shrink consistently in fixed layout', async ({ page, }) => { await waitForInitialRender(page) - for (const layoutLabel of ['Use side preview layout', 'Use left preview layout']) { - await page.getByLabel(layoutLabel).click() - - const minHeights = await page.evaluate(() => { - const component = document.getElementById('component-panel') - const styles = document.getElementById('styles-panel') - return { - component: component - ? Number.parseFloat(getComputedStyle(component).minHeight) - : 0, - styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0, - } - }) - - expect(minHeights.component).toBeGreaterThanOrEqual(0) - expect(minHeights.styles).toBeGreaterThanOrEqual(0) - expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1) - } + const minHeights = await page.evaluate(() => { + const component = document.getElementById('editor-panel-component') + const styles = document.getElementById('editor-panel-styles') + return { + component: component ? Number.parseFloat(getComputedStyle(component).minHeight) : 0, + styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0, + } + }) + + expect(minHeights.component).toBeGreaterThanOrEqual(0) + expect(minHeights.styles).toBeGreaterThanOrEqual(0) + expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1) }) -test('panel collapse axis and direction adapt to active layout', async ({ page }) => { +test('panel collapse axis and direction match fixed layout', async ({ page }) => { await waitForInitialRender(page) - await expect(page.getByRole('main')).toHaveClass(/app-grid/) - - await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', - collapsed: false, - }) - await expectCollapseButtonState(page, 'styles', { - axis: 'horizontal', - direction: 'right', - collapsed: false, - }) - await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', - collapsed: false, - }) + await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) + await expect(page.locator('#collapse-component')).toHaveCount(0) + await expect(page.locator('#collapse-styles')).toHaveCount(0) - await page.getByLabel('Use side preview layout').click() await expectCollapseButtonState(page, 'preview', { axis: 'horizontal', direction: 'right', collapsed: false, }) - await expectCollapseButtonState(page, 'component', { - axis: 'vertical', - direction: 'none', - collapsed: false, - }) - - await page.getByLabel('Use left preview layout').click() - await expectCollapseButtonState(page, 'preview', { - axis: 'horizontal', - direction: 'left', - collapsed: false, - }) }) -test('prevents collapsing all three panels at once', async ({ page }) => { +test('preview panel can collapse and expand', async ({ page }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) - const stylesPanel = page.getByRole('region', { name: 'Styles' }) - - await getCollapseButton(page, 'component').click() - await getCollapseButton(page, 'styles').click() + const previewPanel = page.locator('#preview-panel') - await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) - await expect(stylesPanel).toHaveClass(/panel--collapsed-horizontal/) + await getCollapseButton(page, 'preview').click() + await expect(previewPanel).toHaveClass(/panel--collapsed-horizontal/) await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', - collapsed: false, - disabled: true, + axis: 'horizontal', + direction: 'right', + collapsed: true, + disabled: false, }) - await expect(getCollapseButton(page, 'preview')).toHaveAttribute( - 'title', - 'At least one panel must remain expanded.', - ) - await getCollapseButton(page, 'component').click() + await getCollapseButton(page, 'preview').click() + await expect(previewPanel).not.toHaveClass(/panel--collapsed-horizontal/) await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', + axis: 'horizontal', + direction: 'right', collapsed: false, disabled: false, }) @@ -185,25 +198,25 @@ test('prevents collapsing all three panels at once', async ({ page }) => { test('does not persist panel collapse state across reload', async ({ page }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) + const previewPanel = page.locator('#preview-panel') - await getCollapseButton(page, 'component').click() - await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) - await expectCollapseButtonState(page, 'component', { + await getCollapseButton(page, 'preview').click() + await expect(previewPanel).toHaveClass(/panel--collapsed-horizontal/) + await expectCollapseButtonState(page, 'preview', { axis: 'horizontal', - direction: 'left', + direction: 'right', collapsed: true, }) await page.reload() await waitForInitialRender(page) - await expect(componentPanel).not.toHaveClass( + await expect(previewPanel).not.toHaveClass( /panel--collapsed-horizontal|panel--collapsed-vertical/, ) - await expectCollapseButtonState(page, 'component', { + await expectCollapseButtonState(page, 'preview', { axis: 'horizontal', - direction: 'left', + direction: 'right', collapsed: false, }) }) @@ -213,8 +226,8 @@ test('gear tools toggles default inactive and switch active/inactive per panel', }) => { await waitForInitialRender(page) - const componentPanel = page.getByRole('region', { name: 'Component' }) - const stylesPanel = page.getByRole('region', { name: 'Styles' }) + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') const componentTools = getToolsButton(page, 'component') const stylesTools = getToolsButton(page, 'styles') @@ -233,8 +246,24 @@ test('gear tools toggles default inactive and switch active/inactive per panel', await expect(componentTools).toHaveAttribute('aria-pressed', 'false') await expect(componentTools).toHaveAttribute('title', 'Show component tools') + await page.getByRole('button', { name: 'Open tab app.css' }).click() await stylesTools.click() await expect(stylesPanel).not.toHaveClass(/panel--tools-hidden/) await expect(stylesTools).toHaveAttribute('aria-pressed', 'true') await expect(stylesTools).toHaveAttribute('title', 'Hide styles tools') }) + +test('fixed layout keeps inactive editor panel hidden', async ({ page }) => { + await waitForInitialRender(page) + + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') + + const assertEntryPanelVisible = async () => { + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await expect(componentPanel).toBeVisible() + await expect(stylesPanel).toBeHidden() + } + + await assertEntryPanelVisible() +}) diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts deleted file mode 100644 index 2e237b9..0000000 --- a/playwright/rendering-modes.spec.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { expect, test } from '@playwright/test' -import { - ensureDiagnosticsDrawerOpen, - ensurePanelToolsVisible, - expectPreviewHasRenderedContent, - runTypecheck, - setComponentEditorSource, - setStylesEditorSource, - waitForInitialRender, -} from './helpers/app-test-helpers.js' - -test('renders in react mode with css modules', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) -}) - -test('transpiles TypeScript annotations in component source', async ({ page }) => { - await waitForInitialRender(page) - - await page.getByLabel('ShadowRoot').uncheck() - await setComponentEditorSource( - page, - [ - 'const Button = ({ label }: { label: string }): unknown => ', - 'const App = () => ', - '}', - ].join('\n'), - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect(page.locator('#preview-host pre')).toHaveCount(0) - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('typed children import') -}) - -test('react mode typecheck loads types without malformed URL fetches', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - const typeRequestUrls: string[] = [] - page.on('request', request => { - const url = request.url() - if (url.includes('@types/')) { - typeRequestUrls.push(url) - } - }) - - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await page.getByRole('button', { name: 'Typecheck' }).click() - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-component')).toContainText( - 'No TypeScript errors found.', - ) - - const diagnosticsText = await page.locator('#diagnostics-component').innerText() - expect(diagnosticsText).not.toContain("Cannot find type definition file for 'react'") - expect(diagnosticsText).not.toContain( - "Cannot find type definition file for 'react-dom'", - ) - expect(diagnosticsText).not.toContain("Cannot find module 'react-dom/client'") - expect(diagnosticsText).not.toContain('Cannot find module "react-dom/client"') - - expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeTruthy() - - const malformedTypeRequestPatterns = [ - '/@types/global.d.ts/package.json', - '/user-context', - '/https:/', - ] - - for (const pattern of malformedTypeRequestPatterns) { - expect(typeRequestUrls.some(url => url.includes(pattern))).toBeFalsy() - } -}) - -test('dom mode typecheck does not hydrate react type graph', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - const typeRequestUrls: string[] = [] - page.on('request', request => { - const url = request.url() - if (url.includes('@types/')) { - typeRequestUrls.push(url) - } - }) - - await runTypecheck(page) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeFalsy() - expect(typeRequestUrls.some(url => url.includes('@types/react-dom'))).toBeFalsy() -}) - -test('react mode executes default React import without TDZ runtime failure', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await setComponentEditorSource( - page, - [ - "import React from 'react'", - 'const App = () => ', - ].join('\n'), - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('react default import works') - await expect(page.locator('#preview-host pre')).toHaveCount(0) -}) - -test('clearing component source reports clear action without error status', async ({ - page, -}) => { - await waitForInitialRender(page) - - const dialog = page.getByRole('dialog', { name: 'Clear Component source?' }) - await page.getByLabel('Clear component source').click() - await expect(dialog).toHaveAttribute('open', '') - await dialog.getByRole('button', { name: 'Clear' }).click() - - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(0) - await expect(page.locator('#preview-host pre')).toHaveCount(0) - await expect(page.getByRole('status', { name: 'App status' })).toHaveText( - 'Component cleared', - ) - await expect(page.getByRole('status', { name: 'App status' })).toHaveClass( - /status--neutral/, - ) -}) - -test('jsx syntax errors affect status but not diagnostics toggle severity', async ({ - page, -}) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - ['const App = () => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await ensurePanelToolsVisible(page, 'styles') - - const autoRenderToggle = page.getByLabel('Auto render') - const renderButton = page.getByRole('button', { name: 'Render' }) - const styleMode = page.getByRole('combobox', { name: 'Style mode' }) - - await autoRenderToggle.uncheck() - await expect(renderButton).toBeVisible() - - await styleMode.selectOption('module') - - await renderButton.click() - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect(page.locator('#preview-host pre')).toHaveCount(0) -}) - -test('clears preview when auto render is toggled', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - const autoRenderToggle = page.getByLabel('Auto render') - - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(1) - - await autoRenderToggle.uncheck() - - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(0) - await expect(page.locator('#preview-host pre')).toHaveCount(0) -}) - -test('shows App-only error when auto render is disabled and App is missing', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - const autoRenderToggle = page.getByLabel('Auto render') - const renderButton = page.getByRole('button', { name: 'Render' }) - - await autoRenderToggle.uncheck() - await setComponentEditorSource( - page, - 'const Button = () => ', - ) - - await renderButton.click() - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') - await expect(page.locator('#preview-host pre')).toContainText( - 'Expected a function or const named App.', - ) -}) - -test('auto render implicitly wraps source with App in dom and react modes', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - 'const Button = () => ', - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app dom') - - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await setComponentEditorSource( - page, - 'const Button = () => ', - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app react') -}) - -test('auto render implicit App includes multiple component declarations', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - [ - 'const OtherButton = () => ', - 'const Button = () => ', - ].join('\n'), - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toHaveCount(2) - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText(['bar', 'foo']) -}) - -test('auto render does not treat lowercase helpers as implicit components', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - [ - 'const helper = () => ', - 'function render() { return
}', - ].join('\n'), - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') - await expect(page.locator('#preview-host pre')).toContainText( - 'Expected a function or const named App.', - ) -}) - -test('auto render wraps standalone JSX with trailing semicolon and comment', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - '() as any; // trailing', - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('implicit app from jsx expression') -}) - -test('auto render requires explicit App for declarations plus top-level JSX expression', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - [ - "const label = 'kept declarations'", - 'const Button = () => ', - '(', - ) - - await page.getByRole('button', { name: 'Render' }).click() - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button'), - ).toContainText('default export arrow') -}) - -test('renders export default class component in react mode', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') - await page.getByLabel('Auto render').uncheck() - - await setComponentEditorSource( - page, - [ - "import React from 'react'", - 'export default class extends React.Component {', - ' render() {', - ' return ', - ' }', - '}', - ].join('\n'), - ) - - await page.getByRole('button', { name: 'Render' }).click() - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - const previewButtons = page - .getByRole('region', { name: 'Preview output' }) - .getByRole('button') - await expect(previewButtons.first()).toContainText('default export class') -}) - -test('supports export default App without redeclaration', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - await page.getByLabel('Auto render').uncheck() - - await setComponentEditorSource( - page, - [ - 'function App() {', - ' return ', - '}', - 'export default App', - ].join('\n'), - ) - - await page.getByRole('button', { name: 'Render' }).click() - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), - ).toContainText('export default App') -}) - -test('auto render supports export default named component without App redeclaration', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - await page.getByLabel('ShadowRoot').uncheck() - - await setComponentEditorSource( - page, - [ - 'const Button = () => ', - 'export default Button', - ].join('\n'), - ) - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expect( - page.getByRole('region', { name: 'Preview output' }).getByRole('button').first(), - ).toContainText('export default Button') - await expect(page.locator('#preview-host pre')).toHaveCount(0) -}) - -test('persists layout and theme across reload', async ({ page }) => { - await waitForInitialRender(page) - - await page.getByLabel('Use side preview layout').click() - await page.getByLabel('Use light theme').click() - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') - - await page.reload() - await waitForInitialRender(page) - - await expect(page.getByRole('main')).toHaveClass(/app-grid--preview-right/) - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') -}) - -test('renders with less style mode', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('less') - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) -}) - -test('renders with sass style mode', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByLabel('ShadowRoot').uncheck() - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) -}) - -test('style compilation errors populate styles diagnostics scope', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.getByRole('combobox', { name: 'Style mode' }).selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') - await expect(page.getByRole('button', { name: 'Diagnostics' })).toHaveClass( - /diagnostics-toggle--error/, - ) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-styles')).toContainText( - 'Style compilation failed.', - ) - await expect(page.locator('#diagnostics-styles')).toContainText('Undefined variable') -}) diff --git a/playwright/rendering-modes/auto-render-scope.spec.ts b/playwright/rendering-modes/auto-render-scope.spec.ts new file mode 100644 index 0000000..32db7e7 --- /dev/null +++ b/playwright/rendering-modes/auto-render-scope.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test' +import { + addWorkspaceTab, + ensurePanelToolsVisible, + resetWorkbenchStorage, + setWorkspaceTabSource, + waitForInitialRender, +} from '../helpers/app-test-helpers.js' + +test.beforeEach(async ({ page }) => { + await resetWorkbenchStorage(page) +}) + +test('auto-render skips unrelated component tab edits outside entry dependency graph', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const value = 'first'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: "export const App = () => ", + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + const pendingWatcher = page.evaluate(() => { + const status = document.getElementById('status') + + return new Promise(resolve => { + if (!status) { + resolve(false) + return + } + + let sawPending = false + const observer = new MutationObserver(() => { + if (status.textContent?.trim() === 'Rendering…') { + sawPending = true + } + }) + + observer.observe(status, { + childList: true, + subtree: true, + characterData: true, + }) + + setTimeout(() => { + observer.disconnect() + resolve(sawPending) + }, 700) + }) + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const value = 'second'", + }) + + await expect(pendingWatcher).resolves.toBe(false) +}) diff --git a/playwright/rendering-modes/core.spec.ts b/playwright/rendering-modes/core.spec.ts new file mode 100644 index 0000000..8724a65 --- /dev/null +++ b/playwright/rendering-modes/core.spec.ts @@ -0,0 +1,1022 @@ +import { expect, test } from '@playwright/test' +import { + addWorkspaceTab, + ensureDiagnosticsDrawerOpen, + ensurePanelToolsVisible, + expectPreviewHasRenderedContent, + getPreviewFrame, + openWorkspaceTab, + resetWorkbenchStorage, + runTypecheck, + setComponentEditorSource, + setWorkspaceTabSource, + waitForInitialRender, +} from '../helpers/app-test-helpers.js' + +const renameWorkspaceTab = async ( + page: import('@playwright/test').Page, + { + from, + to, + }: { + from: string + to: string + }, +) => { + await page.getByRole('button', { name: `Rename tab ${from}` }).click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') +} + +const renameWorkspaceTabFromCandidates = async ( + page: import('@playwright/test').Page, + { + fromCandidates, + to, + }: { + fromCandidates: string[] + to: string + }, +) => { + for (const from of fromCandidates) { + const button = page.getByRole('button', { name: `Rename tab ${from}` }) + if ((await button.count()) === 0) { + continue + } + + await button.click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') + return + } + + throw new Error( + `Could not find a rename target from candidates: ${fromCandidates.join(', ')}`, + ) +} + +const readLatestWorkspaceSnapshot = async (page: import('@playwright/test').Page) => { + return page.evaluate(async () => { + const dbName = 'knighted-develop-workspaces' + const storeName = 'prWorkspaces' + + const openDb = await new Promise(resolve => { + try { + const request = indexedDB.open(dbName) + request.onsuccess = () => resolve(request.result) + request.onerror = () => resolve(null) + } catch { + resolve(null) + } + }) + + if (!openDb) { + return null + } + + const records = await new Promise>>(resolve => { + try { + const transaction = openDb.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.getAll() + request.onsuccess = () => { + const value = Array.isArray(request.result) ? request.result : [] + resolve(value as Array>) + } + request.onerror = () => resolve([]) + } catch { + resolve([]) + } + }) + + openDb.close() + + if (!Array.isArray(records) || records.length === 0) { + return null + } + + const sorted = [...records].sort((a, b) => { + const first = + typeof a.lastModified === 'number' && Number.isFinite(a.lastModified) + ? a.lastModified + : 0 + const second = + typeof b.lastModified === 'number' && Number.isFinite(b.lastModified) + ? b.lastModified + : 0 + return second - first + }) + + const latest = sorted[0] ?? {} + const tabs = Array.isArray(latest.tabs) ? latest.tabs : [] + const primaryStylesTab = tabs.find(tab => { + if (!tab || typeof tab !== 'object') { + return false + } + + const tabRecord = tab as Record + const language = typeof tabRecord.language === 'string' ? tabRecord.language : '' + const path = typeof tabRecord.path === 'string' ? tabRecord.path : '' + const name = typeof tabRecord.name === 'string' ? tabRecord.name : '' + const isStyleLanguage = ['css', 'less', 'sass', 'module'].includes(language) + const styleIdentity = `${path} ${name}`.toLowerCase() + const looksLikeStyle = /\.(css|less|sass|scss)\b/.test(styleIdentity) + return isStyleLanguage && looksLikeStyle + }) as Record | undefined + + return { + renderMode: typeof latest.renderMode === 'string' ? latest.renderMode : '', + styleLanguage: + typeof primaryStylesTab?.language === 'string' ? primaryStylesTab.language : '', + } + }) +} + +test.beforeEach(async ({ page }) => { + await resetWorkbenchStorage(page) +}) + +test('renders in react mode with css modules', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expectPreviewHasRenderedContent(page) +}) + +test('react mode keeps App.ts entry but surfaces rename guidance until compatible', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + + await renameWorkspaceTab(page, { + from: 'App.tsx', + to: 'App.ts', + }) + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + const expectedMessage = + 'React mode requires the entry tab to end in .tsx, .jsx, or .js.' + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + expectedMessage, + ) + await expect(page.locator('#preview-host pre.preview-runtime-error')).toContainText( + expectedMessage, + ) + + await renameWorkspaceTab(page, { + from: 'App.ts', + to: 'App.jsx', + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expectPreviewHasRenderedContent(page) +}) + +test('css module imports expose class map for module tabs', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await renameWorkspaceTab(page, { + from: 'app.css', + to: 'app.module.css', + }) + + await setWorkspaceTabSource(page, { + fileName: 'app.module.css', + kind: 'styles', + source: [ + '.list {', + ' display: grid;', + '}', + '', + '.item {', + ' color: rgb(10, 20, 30);', + '}', + ].join('\n'), + }) + + await addWorkspaceTab(page, { type: 'script' }) + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'list.tsx', + }) + await setWorkspaceTabSource(page, { + fileName: 'list.tsx', + source: [ + "import styles from '../styles/app.module.css'", + "import { Item } from './item'", + '', + 'type ListProps = {', + ' items: string[]', + '}', + '', + 'export const List = ({ items }: ListProps) => (', + '
    ', + ' {items.map(item => (', + ' ', + ' ))}', + '
', + ')', + ].join('\n'), + }) + + await addWorkspaceTab(page, { type: 'script' }) + await renameWorkspaceTabFromCandidates(page, { + fromCandidates: ['module-2.tsx', 'module.tsx', 'module-1.tsx'], + to: 'item.tsx', + }) + await setWorkspaceTabSource(page, { + fileName: 'item.tsx', + source: [ + "import styles from '../styles/app.module.css'", + '', + 'type ItemProps = {', + ' value: string', + '}', + '', + 'export const Item = ({ value }: ItemProps) => (', + '
  • {value}
  • ', + ')', + ].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import { List } from './list'", + '', + "const items = ['one', 'two']", + '', + 'const App = () => ', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByRole('listitem')).toHaveCount(2) + await expect(getPreviewFrame(page).getByRole('listitem').first()).toHaveCSS( + 'color', + 'rgb(10, 20, 30)', + ) +}) + +test('preview styles require explicit import from entry graph', async ({ page }) => { + await waitForInitialRender(page) + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['.counter-button { color: rgb(1, 2, 3); }'].join('\n'), + }) + + await expect + .poll(async () => { + const styleContent = await getPreviewFrame(page) + .locator('style') + .first() + .textContent() + return styleContent ?? '' + }) + .toContain('rgb(1, 2, 3)') + + await setComponentEditorSource( + page, + [ + 'const App = () => ', + '', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect + .poll(async () => { + const styleContent = await getPreviewFrame(page) + .locator('style') + .first() + .textContent() + return styleContent ?? '' + }) + .not.toContain('rgb(1, 2, 3)') +}) + +test('nested module imports can bring styles into preview graph', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page, { type: 'script' }) + await addWorkspaceTab(page, { type: 'style' }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + kind: 'component', + source: [ + "import '../styles/module.css'", + '', + 'export const ModuleButton = () => ', + ].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.css', + kind: 'styles', + source: ['.module-button { color: rgb(9, 8, 7); }'].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import { ModuleButton } from './module'", + '', + 'const App = () => ', + '', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText('Nested style') + await expect + .poll(async () => { + const styleContent = await getPreviewFrame(page) + .locator('style') + .first() + .textContent() + return styleContent ?? '' + }) + .toContain('rgb(9, 8, 7)') +}) + +test('transpiles TypeScript annotations in component source', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + [ + 'const Button = ({ label }: { label: string }): unknown => ', + 'const App = () => ', + '}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'typed children import', + ) +}) + +test('react mode typecheck loads types without malformed URL fetches', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const typeRequestUrls: string[] = [] + page.on('request', request => { + const url = request.url() + if (url.includes('@types/')) { + typeRequestUrls.push(url) + } + }) + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await runTypecheck(page) + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.locator('#diagnostics-component')).not.toContainText('Type checking…') + + const diagnosticsText = await page.locator('#diagnostics-component').innerText() + expect(diagnosticsText).not.toContain("Cannot find type definition file for 'react'") + expect(diagnosticsText).not.toContain( + "Cannot find type definition file for 'react-dom'", + ) + expect(diagnosticsText).not.toContain("Cannot find module 'react-dom/client'") + expect(diagnosticsText).not.toContain('Cannot find module "react-dom/client"') + + expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeTruthy() + + const malformedTypeRequestPatterns = [ + '/@types/global.d.ts/package.json', + '/user-context', + '/https:/', + ] + + for (const pattern of malformedTypeRequestPatterns) { + expect(typeRequestUrls.some(url => url.includes(pattern))).toBeFalsy() + } +}) + +test('dom mode typecheck does not hydrate react type graph', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const typeRequestUrls: string[] = [] + page.on('request', request => { + const url = request.url() + if (url.includes('@types/')) { + typeRequestUrls.push(url) + } + }) + + await runTypecheck(page) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeFalsy() + expect(typeRequestUrls.some(url => url.includes('@types/react-dom'))).toBeFalsy() +}) + +test('react mode executes default React import without TDZ runtime failure', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'const App = () => ', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'react default import works', + ) + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('react mode mounts into internal non-div host to avoid div selector bleed', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + await openWorkspaceTab(page, 'App.tsx') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setWorkspaceTabSource(page, { + fileName: 'app.css', + kind: 'styles', + source: ['div { border: 1px dotted green; }'].join('\n'), + }) + + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'export const App = () => (', + ' <>', + '
    inner
    ', + ' ', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await expect(getPreviewFrame(page).locator('body > knighted-preview-root')).toHaveCount( + 1, + ) +}) + +test('clearing component source reports clear action without error status', async ({ + page, +}) => { + await waitForInitialRender(page) + + const dialog = page.getByRole('dialog', { name: 'Clear Component source?' }) + await page.getByLabel('Clear component source').click() + await expect(dialog).toHaveAttribute('open', '') + await dialog.getByRole('button', { name: 'Clear' }).click() + + await expect(getPreviewFrame(page).getByRole('button')).toHaveCount(0) + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'Component cleared', + ) + await expect(page.getByRole('status', { name: 'App status' })).toHaveClass( + /status--neutral/, + ) +}) + +test('jsx syntax errors affect status but not diagnostics toggle severity', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ['const App = () => { + await waitForInitialRender(page) + + const pageErrors: string[] = [] + page.on('pageerror', error => { + pageErrors.push(error.message) + }) + + await setComponentEditorSource( + page, + ["const App = () => { throw new TypeError('intentional runtime failure') }"].join( + '\n', + ), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('[runtime]') + await expect(page.locator('#preview-host pre')).toContainText( + 'intentional runtime failure', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'Entry: @knighted/workspace/', + ) + await expect(page.locator('#preview-host pre')).toContainText('Source:') + + expect(pageErrors).toEqual([]) +}) + +test('editing-transient missing reference runtime errors are suppressed', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import { useState, useCallback } from 'react'", + '', + 'const App = () => {', + ' const [count, setCount] = useState(0)', + ' const handleOnClick = useCallback(() => {', + ' setCount(count + 1)', + ' }, [count])', + ' co', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(page.getByRole('status', { name: 'App status' })).not.toHaveText('Error') +}) + +test('missing component identifiers in App render as runtime errors', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + 'const App = () => (', + ' ', + ' ', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('[runtime]') + await expect(page.locator('#preview-host pre')).toContainText( + /List is not defined|Can't find variable:\s*List/, + ) +}) + +test('preview iframe sandbox isolates parent origin access', async ({ page }) => { + await waitForInitialRender(page) + + const iframe = page.locator('#preview-host iframe') + const sandbox = await iframe.getAttribute('sandbox') + + expect(typeof sandbox).toBe('string') + expect(sandbox?.includes('allow-same-origin')).toBeFalsy() + + await setComponentEditorSource( + page, + [ + 'const canReadParentStorage = (() => {', + ' try {', + ' return Boolean(window.parent.localStorage)', + ' } catch {', + ' return false', + ' }', + '})()', + '', + 'export const App = () => (', + " ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText('parent-blocked') +}) + +test('post-render runtime exceptions from iframe are reported in preview panel', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import React from 'react'", + 'export const App = () => (', + ' ', + ')', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await getPreviewFrame(page).getByRole('button', { name: 'click boom' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('[runtime]') + await expect(page.locator('#preview-host pre')).toContainText('clicked boom') +}) + +test('post-render runtime errors fully recover after source fix', async ({ page }) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + + await setComponentEditorSource( + page, + [ + "import React, { useState } from 'react'", + 'export const App = () => {', + ' const [count, setCount] = useState(0)', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await getPreviewFrame(page).getByRole('button', { name: 'click boom' }).click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText('clicked boom') + + await setComponentEditorSource( + page, + [ + "import React, { useState } from 'react'", + 'export const App = () => {', + ' const [count, setCount] = useState(0)', + ' return (', + ' ', + ' )', + '}', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await getPreviewFrame(page).getByRole('button', { name: 'safe click 0' }).click() + await expect( + getPreviewFrame(page).getByRole('button', { name: 'safe click 1' }), + ).toBeVisible() + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('requires render button when auto render is disabled', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await ensurePanelToolsVisible(page, 'styles') + + const autoRenderToggle = page.getByLabel('Auto render') + const renderButton = page.getByRole('button', { name: 'Render' }) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await autoRenderToggle.uncheck() + await expect(renderButton).toBeVisible() + + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await page.getByRole('combobox', { name: 'Style mode' }).selectOption('module') + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + + await renderButton.click() + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('clears preview when auto render is toggled', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + const previewHost = page.locator('#preview-host') + + await expect + .poll(() => previewHost.evaluate(node => node.childElementCount)) + .toBeGreaterThan(0) + + await autoRenderToggle.uncheck() + + await expect.poll(() => previewHost.evaluate(node => node.childElementCount)).toBe(0) + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('shows App-only error when auto render is disabled and App is missing', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + const renderButton = page.getByRole('button', { name: 'Render' }) + + await autoRenderToggle.uncheck() + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await renderButton.click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render shows App-only error in dom and react modes when App is missing', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await expect(page.getByRole('combobox', { name: 'Render mode' })).toHaveValue('react') + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + await expect( + page.locator('.editor-panel[data-editor-kind="component"] .cm-content').first(), + ).toContainText('implicit app react') + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render renders successfully when explicit App is defined in dom and react modes', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + 'const App = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'explicit app dom', + ) + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await expect(page.getByRole('combobox', { name: 'Render mode' })).toHaveValue('react') + await setComponentEditorSource( + page, + 'const App = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'explicit app react', + ) +}) + +test('auto render does not treat lowercase helpers as implicit components', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + [ + 'const helper = () => ', + 'function render() { return
    }', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render shows App-only error for standalone JSX expression', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + '() as any; // trailing', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render shows App-only error for declarations plus top-level JSX expression', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + [ + "const label = 'kept declarations'", + 'const Button = () => ', + '(', + }) + + await openWorkspaceTab(page, 'App.tsx') + await setComponentEditorSource( + page, + [ + "import { Button as WorkspaceButton } from './module'", + 'const Button = () => ', + 'export const App = () => (', + ' <>', + ' ', + ].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { Button } from './module'", + 'export const App = () => ', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(getPreviewFrame(page).getByRole('button')).toContainText( + 'js specifier to tsx fallback', + ) +}) + +test('workspace graph errors are deterministic for ambiguous extension compatibility matches', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'module.ts', + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: "export const label = 'from tsx'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'module.ts', + source: "export const label = 'from ts'", + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { label } from './module.js'", + 'export const App = () => ', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references ambiguous workspace module: ./module.js', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'src/components/module.ts', + ) + await expect(page.locator('#preview-host pre')).toContainText( + 'src/components/module.tsx', + ) +}) + +test('workspace graph errors for missing modules remain deterministic', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await setComponentEditorSource( + page, + [ + "import { MissingThing } from './does-not-exist'", + 'export const App = () => ', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references missing workspace module: ./does-not-exist', + ) +}) + +test('renaming an imported module tab re-renders and surfaces missing import errors', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + 'export const ItemWrap = ({ children }: { children: string }) => {', + ' return {children}', + '}', + ].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { ItemWrap } from './module'", + 'export const App = () => hello', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + + await page.getByRole('button', { name: 'Rename tab module.tsx' }).click() + const renameInput = page.getByLabel('Rename module.tsx') + await renameInput.fill('module-renamed.tsx') + await renameInput.press('Enter') + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references missing workspace module: ./module', + ) +}) + +test('renaming default styles tab updates graph resolution and surfaces stale import', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + + await page.getByRole('button', { name: 'Rename tab app.css' }).click() + const renameInput = page.getByLabel('Rename app.css') + await renameInput.fill('app.less') + await renameInput.press('Enter') + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry references missing workspace module: ../styles/app.css', + ) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import '../styles/app.less'", + '', + 'type CounterButtonProps = {', + ' label: string', + ' onClick: (event: MouseEvent) => void', + '}', + '', + 'const CounterButton = ({ label, onClick }: CounterButtonProps) => (', + ' ', + ')', + '', + 'const App = () => {', + ' let count = 0', + ' const handleClick = (event: MouseEvent) => {', + ' count += 1', + ' const button = event.currentTarget as HTMLButtonElement', + ' button.textContent = `Clicks: ${count}`', + " button.dataset.active = count % 2 === 0 ? 'false' : 'true'", + " button.classList.toggle('is-even', count % 2 === 0)", + ' }', + '', + " return ", + '}', + '', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('workspace graph errors for circular imports remain deterministic', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: ["import { App } from './App'", 'export const ping = () => App'].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { ping } from './module'", + 'export const App = () => ', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Preview entry contains circular workspace import:', + ) + await expect(page.locator('#preview-host pre')).toContainText('Import chain: ./module') +}) + +test('children runtime errors recover after module fix and mode switches', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await addWorkspaceTab(page) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + 'export const ItemWrap = ({ children: string }) => {', + ' return {children}', + '}', + ].join('\n'), + }) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: [ + "import { ItemWrap } from './module.tsx'", + 'export const App = () => (', + '
    ', + ' hello children', + '
    ', + ')', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + /\[runtime\]\s+(children is not defined|Can't find variable: children)/, + ) + + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: [ + 'export const ItemWrap = ({ children }: { children: string }) => {', + ' return {children}', + '}', + ].join('\n'), + }) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() + + await openWorkspaceTab(page, 'App.tsx') + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('dom') + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect(page.locator('#preview-host pre')).toHaveCount(0) + await expect(getPreviewFrame(page).getByText('hello children')).toBeVisible() +}) diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts new file mode 100644 index 0000000..577f558 --- /dev/null +++ b/playwright/workspace-tabs.spec.ts @@ -0,0 +1,621 @@ +import { expect, test } from '@playwright/test' +import { + addWorkspaceTab, + ensurePanelToolsVisible, + reorderWorkspaceTabBefore, + setWorkspaceTabSource, + waitForAppReady, + waitForInitialRender, +} from './helpers/app-test-helpers.js' + +const confirmRemoveDialog = async (page: import('@playwright/test').Page) => { + const dialog = page.locator('#clear-confirm-dialog') + await expect(dialog).toBeVisible() + + await dialog.locator('button[value="confirm"]').evaluate(element => { + if (element instanceof HTMLButtonElement) { + element.click() + } + }) +} + +const renameWorkspaceTab = async ( + page: import('@playwright/test').Page, + { + from, + to, + }: { + from: string + to: string + }, +) => { + await page.getByRole('button', { name: `Rename tab ${from}` }).click() + const renameInput = page.getByLabel(`Rename ${from}`) + await renameInput.fill(to) + await renameInput.press('Enter') +} + +const seedSyncedComponentTab = async (page: import('@playwright/test').Page) => { + await page.evaluate(async () => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readwrite') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) ? getAllRequest.result : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + const now = Date.now() + for (const record of records) { + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const nextTabs = tabs.map(tab => { + if (!tab || typeof tab !== 'object') { + return tab + } + + if ((tab as { role?: unknown }).role !== 'entry') { + return tab + } + + const pathValue = + typeof (tab as { path?: unknown }).path === 'string' + ? ((tab as { path: string }).path ?? '') + : '' + + return { + ...(tab as Record), + targetPrFilePath: pathValue, + syncedAt: now, + isDirty: false, + } + }) + + const putRequest = store.put({ + ...record, + tabs: nextTabs, + lastModified: now, + }) + + await new Promise((resolve, reject) => { + putRequest.onsuccess = () => resolve() + putRequest.onerror = () => reject(putRequest.error) + }) + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error) + }) + } finally { + db.close() + } + }) +} + +const waitForWorkspaceTabOrderPersistence = async ( + page: import('@playwright/test').Page, + expectedLeadingTabNames: string[], +) => { + await expect + .poll(async () => { + return page.evaluate(async expectedTabNames => { + const request = indexedDB.open('knighted-develop-workspaces') + + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('Could not open IndexedDB.')) + }) + + try { + const tx = db.transaction('prWorkspaces', 'readonly') + const store = tx.objectStore('prWorkspaces') + const getAllRequest = store.getAll() + + const records = await new Promise>>( + (resolve, reject) => { + getAllRequest.onsuccess = () => { + const value = Array.isArray(getAllRequest.result) + ? getAllRequest.result + : [] + resolve(value as Array>) + } + getAllRequest.onerror = () => reject(getAllRequest.error) + }, + ) + + return records.some(record => { + const tabs = Array.isArray(record.tabs) ? record.tabs : [] + const tabNames = tabs + .map(tab => { + if (!tab || typeof tab !== 'object') { + return '' + } + + return typeof (tab as { name?: unknown }).name === 'string' + ? ((tab as { name: string }).name ?? '') + : '' + }) + .filter(name => name.length > 0) + + return expectedTabNames.every((name, index) => tabNames[index] === name) + }) + } finally { + db.close() + } + }, expectedLeadingTabNames) + }) + .toBe(true) +} + +test('removing active tab selects deterministic adjacent tab', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('button', { name: 'Open tab module-2.tsx' }).click() + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('aria-current', 'true') + + await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click() + await confirmRemoveDialog(page) + + await expect(page.getByRole('button', { name: 'Open tab module-2.tsx' })).toHaveCount(0) + await expect( + page.getByRole('button', { name: 'Open tab module-3.tsx' }), + ).toHaveAttribute('aria-current', 'true') +}) + +test('removing non-active tab does not change active tab', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('button', { name: 'Open tab module-3.tsx' }).click() + await expect( + page.getByRole('button', { name: 'Open tab module-3.tsx' }), + ).toHaveAttribute('aria-current', 'true') + + await page.getByRole('button', { name: 'Remove tab module-2.tsx' }).click() + await confirmRemoveDialog(page) + + await expect(page.getByRole('button', { name: 'Open tab module-2.tsx' })).toHaveCount(0) + await expect( + page.getByRole('button', { name: 'Open tab module-3.tsx' }), + ).toHaveAttribute('aria-current', 'true') +}) + +test('renaming module tab keeps name and path synchronized', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'card-item.tsx', + }) + + const tab = page.getByRole('button', { name: 'Open tab card-item.tsx' }) + await expect(tab).toHaveAttribute('title', 'src/components/card-item.tsx') + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(0) +}) + +test('renaming module tab input starts with full path and supports directory changes', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await page.getByRole('button', { name: 'Rename tab module.tsx' }).click() + + const renameInput = page.getByLabel('Rename module.tsx') + await expect(renameInput).toHaveValue('src/components/module.tsx') + await renameInput.fill('src/ui/cards/card-item.tsx') + await renameInput.press('Enter') + + const tab = page.getByRole('button', { name: 'Open tab card-item.tsx' }) + await expect(tab).toHaveAttribute('title', 'src/ui/cards/card-item.tsx') + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(0) +}) + +test('renaming module tab ignores invalid path-style input', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + + const invalidPathInputs = [ + 'src/ui/cards/', + '/src/ui/cards/card-item.tsx', + '../card-item.tsx', + 'src/ui/../card-item.tsx', + 'src/ui/card item.tsx', + ] + + for (const input of invalidPathInputs) { + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: input, + }) + + const moduleTab = page.getByRole('button', { name: 'Open tab module.tsx' }) + await expect(moduleTab).toHaveAttribute('title', 'src/components/module.tsx') + await expect( + page.getByRole('button', { name: 'Open tab card-item.tsx' }), + ).toHaveCount(0) + } +}) + +test('renaming module tab rejects path collisions with existing tabs', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'module.tsx', + }) + + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Open tab module-2.tsx' })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Open tab module.tsx' })).toHaveAttribute( + 'title', + 'src/components/module.tsx', + ) + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('title', 'src/components/module-2.tsx') + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + 'A tab with that file path already exists.', + ) +}) + +test('renaming module tab preserves source content', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await setWorkspaceTabSource(page, { + fileName: 'module.tsx', + source: 'export const Value = () =>

    Kept

    ', + kind: 'component', + }) + + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'value-card.tsx', + }) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('button', { name: 'Open tab value-card.tsx' }).click() + + const editorContent = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(editorContent).toContainText('export const Value = () =>

    Kept

    ') +}) + +test('rapid tab churn keeps module content isolated from entry content', async ({ + page, +}) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await renameWorkspaceTab(page, { + from: 'module.tsx', + to: 'boop.tsx', + }) + await renameWorkspaceTab(page, { + from: 'module-2.tsx', + to: 'beep.tsx', + }) + + const appSource = [ + "import './styles/styles.css'", + "import { Boop } from './components/boop.js'", + "import { Beep } from './components/beep.js'", + '', + 'export function App() {', + ' return (', + ' <>', + ' ', + ' ', + ' ', + ' )', + '}', + ].join('\n') + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + kind: 'component', + source: appSource, + }) + await setWorkspaceTabSource(page, { + fileName: 'boop.tsx', + kind: 'component', + source: 'export const Boop = () =>

    boop sentinel

    ', + }) + await setWorkspaceTabSource(page, { + fileName: 'beep.tsx', + kind: 'component', + source: 'export const Beep = () =>

    beep sentinel

    ', + }) + + await page.getByRole('button', { name: 'Open tab boop.tsx' }).click() + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + + await page.getByRole('button', { name: 'Remove tab module-3.tsx' }).click() + await confirmRemoveDialog(page) + await addWorkspaceTab(page) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + const entryEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(entryEditor).toContainText("import './styles/styles.css'") + await expect(entryEditor).toContainText("import { Beep } from './components/beep.js'") + + await page.getByRole('button', { name: 'Open tab beep.tsx' }).click() + const beepEditor = page + .locator('.editor-panel[data-editor-kind="component"] .cm-content') + .first() + await expect(beepEditor).toContainText('export const Beep = () =>

    beep sentinel

    ') + await expect(beepEditor).not.toContainText("import './styles/styles.css'") +}) + +test('active tab remains source of truth for visible editor panel', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + const componentPanel = page.locator('#editor-panel-component') + const stylesPanel = page.locator('#editor-panel-styles') + + await page.getByRole('button', { name: 'Open tab app.css' }).click() + await expect(page.getByRole('button', { name: 'Open tab app.css' })).toHaveAttribute( + 'aria-current', + 'true', + ) + await expect(stylesPanel).not.toHaveAttribute('hidden', '') + await expect(componentPanel).toHaveAttribute('hidden', '') + + await page.getByRole('button', { name: 'Open tab module-2.tsx' }).click() + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('aria-current', 'true') + await expect(componentPanel).not.toHaveAttribute('hidden', '') + await expect(stylesPanel).toHaveAttribute('hidden', '') + await page.getByRole('button', { name: 'Open tab app.css' }).click() + + await expect(page.getByRole('button', { name: 'Open tab app.css' })).toHaveAttribute( + 'aria-current', + 'true', + ) + await expect(stylesPanel).not.toHaveAttribute('hidden', '') + await expect(componentPanel).toHaveAttribute('hidden', '') +}) + +test('render mode can only be changed from entry tab', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await ensurePanelToolsVisible(page, 'component') + + const renderMode = page.getByRole('combobox', { name: 'Render mode' }) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await expect(renderMode).toBeEnabled() + await renderMode.selectOption('react') + await expect(renderMode).toHaveValue('react') + + await page.getByRole('button', { name: 'Open tab module.tsx' }).click() + await expect(renderMode).toBeDisabled() + await expect(renderMode).toHaveValue('react') + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await expect(renderMode).toBeEnabled() + await expect(renderMode).toHaveValue('react') +}) + +test('startup restores last active workspace tab after reload', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await page.getByRole('button', { name: 'Open tab module-2.tsx' }).click() + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('aria-current', 'true') + + await page.reload() + await waitForInitialRender(page) + + await expect( + page.getByRole('button', { name: 'Open tab module-2.tsx' }), + ).toHaveAttribute('aria-current', 'true') + await expect(page.locator('#editor-panel-component')).not.toHaveAttribute('hidden', '') + await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '') +}) + +test('editing a synced tab keeps dirty state local without Edited indicators', async ({ + page, +}) => { + await waitForInitialRender(page) + + await seedSyncedComponentTab(page) + await page.reload() + await waitForInitialRender(page) + + await setWorkspaceTabSource(page, { + fileName: 'App.tsx', + source: 'export default function App() { return
    Dirty
    }', + kind: 'component', + }) + + const componentTab = page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .first() + await expect(componentTab.locator('.workspace-tab__dirty-indicator')).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() +}) + +test('removed default styles tab stays removed after reload', async ({ page }) => { + await waitForInitialRender(page) + + await expect(page.getByRole('button', { name: 'Open tab app.css' })).toHaveCount(1) + await page.getByRole('button', { name: 'Remove tab app.css' }).click() + await confirmRemoveDialog(page) + + await expect(page.getByRole('button', { name: 'Open tab app.css' })).toHaveCount(0) + + await page.reload() + await waitForAppReady(page) + + await expect(page.getByRole('button', { name: 'Open tab app.css' })).toHaveCount(0) +}) + +test('workspace tab drag reorder persists across reload', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await reorderWorkspaceTabBefore(page, { + from: 'module-2.tsx', + to: 'App.tsx', + }) + + const orderedTabs = page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + await expect(orderedTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx') + await expect(orderedTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx') + + /* Reorder persistence is debounced; wait until IndexedDB reflects the new order. */ + await waitForWorkspaceTabOrderPersistence(page, ['module-2.tsx', 'App.tsx']) + + await page.reload() + await waitForInitialRender(page) + + const restoredTabs = page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + await expect(restoredTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx') + await expect(restoredTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx') +}) + +test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + const labelsBefore = await page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + .evaluateAll(nodes => + nodes + .map(node => node.getAttribute('aria-label')) + .filter((label): label is string => typeof label === 'string'), + ) + + await reorderWorkspaceTabBefore(page, { + from: 'App.tsx', + to: 'App.tsx', + }) + + const labelsAfter = await page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + .evaluateAll(nodes => + nodes + .map(node => node.getAttribute('aria-label')) + .filter((label): label is string => typeof label === 'string'), + ) + + expect(labelsAfter).toEqual(labelsBefore) +}) + +test('add menu can create styles tab while component tab is active', async ({ page }) => { + await waitForInitialRender(page) + + await page.getByRole('button', { name: 'Open tab App.tsx' }).click() + await addWorkspaceTab(page, { type: 'style' }) + + await expect(page.getByRole('button', { name: 'Open tab module.css' })).toHaveAttribute( + 'aria-current', + 'true', + ) + await expect(page.locator('#editor-panel-styles')).not.toHaveAttribute('hidden', '') + await expect(page.locator('#editor-panel-component')).toHaveAttribute('hidden', '') + await expect(page.getByRole('status', { name: 'App status' })).toContainText( + 'Added style tab.', + ) +}) + +test('add menu stays closed until triggered and closes on outside click', async ({ + page, +}) => { + await waitForInitialRender(page) + + const addButton = page.getByRole('button', { name: 'Add workspace tab' }) + const addMenu = page.getByRole('group', { name: 'Add workspace tab' }) + + await expect(addMenu).toBeHidden() + await addButton.click() + await expect(addMenu).toBeVisible() + + await page.getByRole('status', { name: 'App status' }).click() + await expect(addMenu).toBeHidden() +}) + +test('add menu keyboard interaction manages focus on open and escape close', async ({ + page, +}) => { + await waitForInitialRender(page) + + const addButton = page.getByRole('button', { name: 'Add workspace tab' }) + const addMenu = page.getByRole('group', { name: 'Add workspace tab' }) + const addModuleButton = page.getByRole('button', { name: 'Add module tab' }) + + await addButton.focus() + await page.keyboard.press('ArrowDown') + + await expect(addMenu).toBeVisible() + await expect(addModuleButton).toBeFocused() + + await page.keyboard.press('Escape') + + await expect(addMenu).toBeHidden() + await expect(addButton).toBeFocused() +}) diff --git a/src/app.js b/src/app.js index 81d6ef8..4143d1d 100644 --- a/src/app.js +++ b/src/app.js @@ -4,24 +4,106 @@ import { getTypeScriptLibUrls, importFromCdnWithFallback, } from './modules/cdn.js' -import { createCodeMirrorEditor } from './modules/editor-codemirror.js' -import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' -import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' -import { createGitHubChatDrawer } from './modules/github-chat-drawer/drawer.js' -import { createGitHubByotControls } from './modules/github-byot-controls.js' +import { createCodeMirrorEditor } from './modules/editor/editor-codemirror.js' +import { createCompactAiControlsUiController } from './modules/app-core/compact-ai-controls-ui.js' +import { bindAppEventsAndStart } from './modules/app-core/app-bindings-startup.js' +import { + createEditorBootstrapOptions, + createRuntimeCoreOptions, +} from './modules/app-core/app-composition-options.js' +import { createDiagnosticsFlowController } from './modules/app-core/diagnostics-flow-controller.js' +import { createEditorBootstrapController } from './modules/app-core/editor-bootstrap-controller.js' +import { + getStyleEditorLanguage, + normalizeRenderMode, + normalizeStyleMode, + setCssSourceValue, + setJsxSourceValue, + updateRenderModeEditability as updateRenderModeEditabilityValue, +} from './modules/app-core/runtime-editor-utils.js' +import { createSourceSetters } from './modules/app-core/source-setters.js' +import { createRuntimeCoreSetup } from './modules/app-core/runtime-core-setup.js' +import { + createWorkspaceContextSnapshotGetter, + toStyleModeForTabLanguage, +} from './modules/app-core/workspace-local-helpers.js' +import { createWorkspaceTabSelectors } from './modules/app-core/workspace-tab-selectors.js' +import { createDiagnosticsTabStateHelpers } from './modules/app-core/diagnostics-tab-state-helpers.js' +import { createWorkspaceEditorHelpers } from './modules/app-core/workspace-editor-helpers.js' +import { createEditedIndicatorVisibilityController } from './modules/app-core/edited-indicator-visibility-controller.js' +import { createPublishTrailingNewlineNormalizer } from './modules/app-core/publish-trailing-newline-normalizer.js' +import { createLayoutDiagnosticsSetup } from './modules/app-core/layout-diagnostics-setup.js' +import { createWorkspaceControllersSetup } from './modules/app-core/workspace-controllers-setup.js' +import { createWorkspaceScopeForkActions } from './modules/app-core/workspace-scope-fork-actions.js' +import { createGitHubWorkflowsSetup } from './modules/app-core/github-workflows-setup.js' +import { defaultCss, defaultJsx } from './modules/app-core/defaults.js' +import { createGitHubPrContextUiController } from './modules/app-core/github-pr-context-ui.js' +import { createGitHubTokenInfoUiController } from './modules/app-core/github-token-info-ui.js' +import { + githubPrOpenIcon, + githubPrPushCommitIcon, +} from './modules/app-core/github-pr-icons.js' +import { createWorkspaceSyncController } from './modules/app-core/workspace-sync-controller.js' +import { createWorkspaceTabAddMenuUiController } from './modules/app-core/workspace-tab-add-menu-ui.js' +import { createPersistedActivePrContextGetter } from './modules/app-core/persisted-active-pr-context.js' +import { createWorkspacePrSessionHandoffController } from './modules/app-core/workspace-pr-session-handoff-controller.js' +import { persistClosedPrContextRecords } from './modules/app-core/pr-context-records.js' +import { createPrContextStateChangeHandler } from './modules/app-core/pr-context-state-change-handler.js' +import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' +import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' +import { createGitHubChatWorkspaceActions } from './modules/app-core/github-chat-workspace-actions.js' +import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' +import { createGitHubChatDrawer } from './modules/github/chat/drawer.js' +import { createGitHubByotControls } from './modules/github/byot-controls.js' import { formatActivePrReference, getActivePrContextSyncKey, -} from './modules/github-pr-context.js' -import { createGitHubPrEditorSyncController } from './modules/github-pr-editor-sync.js' -import { createGitHubPrDrawer } from './modules/github-pr-drawer.js' -import { createLayoutThemeController } from './modules/layout-theme.js' -import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' -import { createPreviewBackgroundController } from './modules/preview-background.js' -import { createRenderRuntimeController } from './modules/render-runtime.js' -import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' -import { collectTopLevelDeclarations } from './modules/jsx-top-level-declarations.js' -import { ensureJsxTransformSource } from './modules/jsx-transform-runtime.js' + parsePullRequestNumberFromUrl, +} from './modules/github/pr/context.js' +import { createGitHubPrEditorSyncController } from './modules/github/pr/editor-sync.js' +import { createGitHubPrDrawer } from './modules/github/pr/drawer/controller/create-controller.js' +import { createLayoutThemeController } from './modules/ui/layout-theme.js' +import { createLintDiagnosticsController } from './modules/diagnostics/lint-diagnostics.js' +import { createPreviewBackgroundController } from './modules/preview/preview-background.js' +import { getReactEntryTabCompatibilityError } from './modules/preview/preview-entry-resolver.js' +import { createRenderRuntimeController } from './modules/preview/render-runtime.js' +import { createTypeDiagnosticsController } from './modules/diagnostics/type-diagnostics.js' +import { collectTopLevelDeclarations } from './modules/preview/jsx-top-level-declarations.js' +import { ensureJsxTransformSource } from './modules/preview/jsx-transform-runtime.js' +import { createEditorPoolManager } from './modules/editor/editor-pool-manager.js' +import { createWorkspaceTabsState } from './modules/workspace/workspace-tabs-state.js' +import { createWorkspacesDrawer } from './modules/workspace/workspaces-drawer/drawer.js' +import { + createDebouncedWorkspaceSaver, + createWorkspaceStorageAdapter, +} from './modules/workspace/workspace-storage.js' +import { + createWorkspaceTabId as createWorkspaceTabIdFactory, + makeUniqueTabPath as makeUniqueTabPathFactory, +} from './modules/workspace/workspace-tab-factory.js' +import { createEnsureWorkspaceTabsShape } from './modules/workspace/workspace-tab-shape.js' +import { + createWorkspaceRecordId, + getDirtyStateForTabChange, + getAllowedEntryTabFileNames, + getPathFileName, + getTabKind, + getTabTargetPrFilePath, + getWorkspaceTabDisplay, + hasTabCommittedSyncState, + isStyleTabLanguage, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + normalizeWorkspacePathValue, + resolveSyncedBaselineContent, + resolveWorkspaceActiveTabId, + resolveWorkspaceRecordIdentity, + toNonEmptyWorkspaceText, + toWorkspaceRecordKey, + toWorkspaceSyncSha, + toWorkspaceSyncedContent, + toWorkspaceSyncTimestamp, +} from './modules/workspace/workspace-tab-helpers.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -47,7 +129,6 @@ const githubPrToggleLabel = document.getElementById('github-pr-toggle-label') const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon') const githubPrToggleIconPath = document.getElementById('github-pr-toggle-icon-path') const githubPrContextClose = document.getElementById('github-pr-context-close') -const githubPrContextDisconnect = document.getElementById('github-pr-context-disconnect') const githubPrDrawer = document.getElementById('github-pr-drawer') const openPrTitle = document.getElementById('open-pr-title') const githubPrClose = document.getElementById('github-pr-close') @@ -55,26 +136,47 @@ const githubPrStatus = document.getElementById('github-pr-status') const githubPrRepoSelect = document.getElementById('github-pr-repo-select') const githubPrBaseBranch = document.getElementById('github-pr-base-branch') const githubPrHeadBranch = document.getElementById('github-pr-head-branch') -const githubPrComponentPath = document.getElementById('github-pr-component-path') -const githubPrStylesPath = document.getElementById('github-pr-styles-path') const githubPrTitle = document.getElementById('github-pr-title') const githubPrBody = document.getElementById('github-pr-body') const githubPrCommitMessage = document.getElementById('github-pr-commit-message') const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app-wrapper') const githubPrSubmit = document.getElementById('github-pr-submit') +const workspacesToggle = document.getElementById('workspaces-toggle') +const workspacesDrawer = document.getElementById('workspaces-drawer') +const workspacesClose = document.getElementById('workspaces-close') +const workspacesStatus = document.getElementById('workspaces-status') +const workspacesRepository = document.getElementById('workspaces-repository') +const workspacesInitialize = document.getElementById('workspaces-initialize') +const workspacesNew = document.getElementById('workspaces-new') +const workspacesSelect = document.getElementById('workspaces-select') +const workspacesOpen = document.getElementById('workspaces-open') +const workspacesRemove = document.getElementById('workspaces-remove') const componentPrSyncIcon = document.getElementById('component-pr-sync-icon') const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path') const stylesPrSyncIcon = document.getElementById('styles-pr-sync-icon') const stylesPrSyncIconPath = document.getElementById('styles-pr-sync-icon-path') -const viewControlsToggle = document.getElementById('view-controls-toggle') -const viewControlsDrawer = document.getElementById('view-controls-drawer') +const componentEditorHeaderLabel = document.querySelector( + '#editor-header-component [data-editor-header-label]', +) +const stylesEditorHeaderLabel = document.querySelector( + '#editor-header-styles [data-editor-header-label]', +) +const componentEditorDirtyStatus = document.getElementById('component-dirty-status') +const stylesEditorDirtyStatus = document.getElementById('styles-dirty-status') const aiControlsToggle = document.getElementById('ai-controls-toggle') -const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') +const workspaceTabsShell = document.getElementById('workspace-tabs-shell') +const workspaceTabsStrip = document.getElementById('workspace-tabs-strip') +const workspaceTabAddWrap = document.getElementById('workspace-tab-add-wrap') +const workspaceContextStatus = document.getElementById('workspace-context-status') +const workspaceTabAddButton = document.getElementById('workspace-tab-add') +const workspaceTabAddMenu = document.getElementById('workspace-tab-add-menu') +const workspaceTabAddModule = document.getElementById('workspace-tab-add-module') +const workspaceTabAddStyles = document.getElementById('workspace-tab-add-styles') const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') const panelCollapseButtons = document.querySelectorAll('[data-panel-collapse]') -const componentPanel = document.getElementById('component-panel') -const stylesPanel = document.getElementById('styles-panel') +const componentEditorPanel = document.getElementById('editor-panel-component') +const stylesEditorPanel = document.getElementById('editor-panel-styles') const previewPanel = document.getElementById('preview-panel') const renderMode = document.getElementById('render-mode') const autoRenderToggle = document.getElementById('auto-render') @@ -87,7 +189,6 @@ const clearComponentButton = document.getElementById('clear-component') const styleMode = document.getElementById('style-mode') const copyStylesButton = document.getElementById('copy-styles') const clearStylesButton = document.getElementById('clear-styles') -const shadowToggle = document.getElementById('shadow-toggle') const jsxEditor = document.getElementById('jsx-editor') const cssEditor = document.getElementById('css-editor') const diagnosticsToggle = document.getElementById('diagnostics-toggle') @@ -98,7 +199,14 @@ const diagnosticsClearStyles = document.getElementById('diagnostics-clear-styles const diagnosticsClearAll = document.getElementById('diagnostics-clear-all') const diagnosticsComponent = document.getElementById('diagnostics-component') const diagnosticsStyles = document.getElementById('diagnostics-styles') -const cdnLoading = document.getElementById('cdn-loading') +const diagnosticsComponentSection = document.querySelector( + '[data-diagnostics-scope="component"]', +) +const diagnosticsStylesSection = document.querySelector( + '[data-diagnostics-scope="styles"]', +) +const diagnosticsComponentHeading = diagnosticsComponentSection?.querySelector('h3') +const diagnosticsStylesHeading = diagnosticsStylesSection?.querySelector('h3') const appToast = document.getElementById('app-toast') const previewBgColorInput = document.getElementById('preview-bg-color') const clearConfirmDialog = document.getElementById('clear-confirm-dialog') @@ -106,28 +214,91 @@ const clearConfirmTitle = document.getElementById('clear-confirm-title') const clearConfirmCopy = document.getElementById('clear-confirm-copy') const clearConfirmButton = clearConfirmDialog?.querySelector('button[value="confirm"]') +const defaultComponentTabPath = 'src/components/App.tsx' +const defaultStylesTabPath = 'src/styles/app.css' +const defaultComponentTabName = 'App.tsx' +const defaultStylesTabName = 'app.css' +const editorKinds = ['component', 'styles'] +const editorPanelsByKind = { + component: componentEditorPanel, + styles: stylesEditorPanel, +} +const editorHeaderLabelByKind = { + component: componentEditorHeaderLabel, + styles: stylesEditorHeaderLabel, +} +const editorHeaderDirtyStatusByKind = { + component: componentEditorDirtyStatus, + styles: stylesEditorDirtyStatus, +} +const defaultTabNameByKind = { + component: defaultComponentTabName, + styles: defaultStylesTabName, +} + jsxEditor.value = defaultJsx cssEditor.value = defaultCss let previewHost = document.getElementById('preview-host') let jsxCodeEditor = null let cssCodeEditor = null +let diagnosticsFlowController = null +let runtimeCore = null let getJsxSource = () => jsxEditor.value let getCssSource = () => cssEditor.value let renderRuntime = null let pendingClearAction = null let suppressEditorChangeSideEffects = false -let hasAppliedReactModeDefault = false let appToastDismissTimer = null +const workspaceStorage = createWorkspaceStorageAdapter() +let activeWorkspaceRecordId = '' +let activeWorkspaceCreatedAt = null +let workspacesDrawerController = null +let isApplyingWorkspaceSnapshot = false +let hasCompletedInitialWorkspaceBootstrap = false +const workspaceTabsState = createWorkspaceTabsState({ + tabs: [ + { + id: 'entry', + name: defaultComponentTabName, + path: defaultComponentTabPath, + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: defaultJsx, + }, + { + id: 'styles', + name: defaultStylesTabName, + path: defaultStylesTabPath, + language: 'css', + role: 'module', + isActive: false, + content: defaultCss, + }, + ], + activeTabId: 'entry', +}) +const editorPool = createEditorPoolManager({ maxMounted: 2 }) +let workspaceTabRenameState = { + tabId: '', +} +let isRenderingWorkspaceTabs = false +let hasPendingWorkspaceTabsRender = false +let draggedWorkspaceTabId = '' +let dragOverWorkspaceTabId = '' +let suppressWorkspaceTabClick = false const clipboardSupported = Boolean(navigator.clipboard?.writeText) -const githubPrOpenIcon = { - viewBox: '0 0 16 16', - path: 'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z', -} -const githubPrPushCommitIcon = { - viewBox: '0 0 24 24', - path: 'M16.944 11h4.306a.75.75 0 0 1 0 1.5h-4.306a5.001 5.001 0 0 1-9.888 0H2.75a.75.75 0 0 1 0-1.5h4.306a5.001 5.001 0 0 1 9.888 0Zm-1.444.75a3.5 3.5 0 1 0-7 0 3.5 3.5 0 0 0 7 0Z', -} +const { setJsxSource, setCssSource } = createSourceSetters({ + setJsxSourceValue, + setCssSourceValue, + getJsxCodeEditor: () => jsxCodeEditor, + getCssCodeEditor: () => cssCodeEditor, + setSuppressEditorChangeSideEffects: nextValue => + (suppressEditorChangeSideEffects = nextValue), + jsxEditor, + cssEditor, +}) const showAppToast = message => { if (!(appToast instanceof HTMLElement)) { @@ -154,374 +325,74 @@ const showAppToast = message => { const previewBackground = createPreviewBackgroundController({ previewBgColorInput, getPreviewHost: () => previewHost, + onBackgroundColorChange: color => { + if ( + renderRuntime && + typeof renderRuntime.updatePreviewBackgroundColor === 'function' + ) { + renderRuntime.updatePreviewBackgroundColor(color) + } + }, + getDefaultPreviewBackgroundColor: () => { + if (document.documentElement.dataset.theme === 'light') { + return '#ffffff' + } + + if (componentEditorPanel instanceof HTMLElement) { + return getComputedStyle(componentEditorPanel).backgroundColor + } + + return '' + }, }) const layoutTheme = createLayoutThemeController({ - appGrid, - appGridLayoutButtons, appThemeButtons, syncPreviewBackgroundPickerFromTheme: () => previewBackground.syncPreviewBackgroundPickerFromTheme(), }) -const { applyAppGridLayout, applyTheme, getInitialAppGridLayout, getInitialTheme } = - layoutTheme - -const compactViewportMediaQuery = window.matchMedia('(max-width: 900px)') -const stackedRailMediaQuery = window.matchMedia('(max-width: 1090px)') -let stackedRailViewControlsOpen = false -let compactAiControlsOpen = false -let githubTokenInfoOpen = false - -const isStackedRailViewport = () => stackedRailMediaQuery.matches - -const setStackedRailViewControlsOpen = isOpen => { - if (!(viewControlsToggle instanceof HTMLButtonElement) || !viewControlsDrawer) { - return - } - - if (!isStackedRailViewport()) { - stackedRailViewControlsOpen = false - viewControlsToggle.setAttribute('aria-expanded', 'false') - viewControlsDrawer.removeAttribute('hidden') - return - } - - stackedRailViewControlsOpen = Boolean(isOpen) - viewControlsToggle.setAttribute( - 'aria-expanded', - stackedRailViewControlsOpen ? 'true' : 'false', - ) - - if (stackedRailViewControlsOpen) { - viewControlsDrawer.removeAttribute('hidden') - return - } - - viewControlsDrawer.setAttribute('hidden', '') -} - -const setGitHubTokenInfoOpen = isOpen => { - if (!(githubTokenInfo instanceof HTMLButtonElement) || !githubTokenInfoPanel) { - return - } - - githubTokenInfoOpen = Boolean(isOpen) - githubTokenInfo.setAttribute('aria-expanded', githubTokenInfoOpen ? 'true' : 'false') - - if (githubTokenInfoOpen) { - githubTokenInfoPanel.removeAttribute('hidden') - return - } - - githubTokenInfoPanel.setAttribute('hidden', '') -} - -const setCompactAiControlsOpen = isOpen => { - if (!(aiControlsToggle instanceof HTMLButtonElement) || !githubAiControls) { - return - } - - aiControlsToggle.removeAttribute('hidden') - - if (!isCompactViewport()) { - compactAiControlsOpen = false - setGitHubTokenInfoOpen(false) - aiControlsToggle.setAttribute('aria-expanded', 'false') - githubAiControls.removeAttribute('data-compact-open') - githubAiControls.removeAttribute('hidden') - return - } - - compactAiControlsOpen = Boolean(isOpen) - aiControlsToggle.setAttribute('aria-expanded', compactAiControlsOpen ? 'true' : 'false') - githubAiControls.dataset.compactOpen = compactAiControlsOpen ? 'true' : 'false' - - if (!compactAiControlsOpen) { - setGitHubTokenInfoOpen(false) - } -} - -const getCurrentLayout = () => { - if (appGrid.classList.contains('app-grid--preview-right')) { - return 'preview-right' - } - - if (appGrid.classList.contains('app-grid--preview-left')) { - return 'preview-left' - } - - return 'default' -} - -const isCompactViewport = () => compactViewportMediaQuery.matches - -const getPanelCollapseAxis = panelName => { - if (isCompactViewport()) { - return 'vertical' - } - - const layout = getCurrentLayout() - - if (panelName === 'preview') { - return layout === 'default' ? 'vertical' : 'horizontal' - } - - if (panelName === 'component' || panelName === 'styles') { - return layout === 'default' ? 'horizontal' : 'vertical' - } - - return 'vertical' -} - -const getPanelCollapseDirection = panelName => { - const axis = getPanelCollapseAxis(panelName) - if (axis !== 'horizontal') { - return 'none' - } - - const layout = getCurrentLayout() - - if (panelName === 'preview') { - return layout === 'preview-left' ? 'left' : 'right' - } - - if (panelName === 'component') { - return 'left' - } - - if (panelName === 'styles') { - return 'right' - } - - return 'right' -} - -const panelCollapseState = { - component: false, - styles: false, - preview: false, -} - -const panelToolsState = { - component: false, - styles: false, -} - -const applyEditorToolsVisibility = () => { - componentPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.component) - stylesPanel?.classList.toggle('panel--tools-hidden', !panelToolsState.styles) - - for (const button of editorToolsButtons) { - const panelName = button.dataset.editorToolsToggle - if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { - continue - } - - const isVisible = panelToolsState[panelName] - button.setAttribute('aria-pressed', isVisible ? 'true' : 'false') - button.setAttribute('aria-label', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) - button.setAttribute('title', `${isVisible ? 'Hide' : 'Show'} ${panelName} tools`) - } -} - -const normalizePanelCollapseState = () => { - const collapsedPanels = Object.entries(panelCollapseState) - .filter(([, isCollapsed]) => isCollapsed) - .map(([panelName]) => panelName) - - if (collapsedPanels.length === Object.keys(panelCollapseState).length) { - panelCollapseState.preview = false - } -} - -const syncPanelCollapseButtons = () => { - const collapsedCount = Object.values(panelCollapseState).filter(Boolean).length - - for (const button of panelCollapseButtons) { - const panelName = button.dataset.panelCollapse - if (!panelName || !Object.hasOwn(panelCollapseState, panelName)) { - continue - } - - const axis = getPanelCollapseAxis(panelName) - const direction = getPanelCollapseDirection(panelName) - const isCollapsed = panelCollapseState[panelName] === true - const panelTitle = `${panelName.charAt(0).toUpperCase()}${panelName.slice(1)}` - const canCollapse = isCollapsed || collapsedCount < 2 - - button.dataset.collapseAxis = axis - button.dataset.collapseDirection = direction - button.dataset.collapsed = isCollapsed ? 'true' : 'false' - button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true') - button.disabled = !canCollapse - button.setAttribute('aria-disabled', canCollapse ? 'false' : 'true') - button.setAttribute( - 'aria-label', - `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel`, - ) - button.setAttribute( - 'title', - canCollapse - ? `${isCollapsed ? 'Expand' : 'Collapse'} ${panelTitle.toLowerCase()} panel` - : 'At least one panel must remain expanded.', - ) - } -} - -const applyPanelCollapseState = () => { - normalizePanelCollapseState() - - const previewAxis = getPanelCollapseAxis('preview') - const componentAxis = getPanelCollapseAxis('component') - const stylesAxis = getPanelCollapseAxis('styles') - - if (componentPanel) { - const isCollapsed = panelCollapseState.component - componentPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && componentAxis === 'vertical', - ) - componentPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && componentAxis === 'horizontal', - ) - } - - if (stylesPanel) { - const isCollapsed = panelCollapseState.styles - stylesPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && stylesAxis === 'vertical', - ) - stylesPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && stylesAxis === 'horizontal', - ) - } - - if (previewPanel) { - const isCollapsed = panelCollapseState.preview - previewPanel.classList.toggle( - 'panel--collapsed-vertical', - isCollapsed && previewAxis === 'vertical', - ) - previewPanel.classList.toggle( - 'panel--collapsed-horizontal', - isCollapsed && previewAxis === 'horizontal', - ) - } - - appGrid.classList.toggle( - 'app-grid--preview-collapsed-horizontal', - panelCollapseState.preview && previewAxis === 'horizontal', - ) - appGrid.classList.toggle('app-grid--preview-collapsed', panelCollapseState.preview) - appGrid.classList.toggle('app-grid--component-collapsed', panelCollapseState.component) - appGrid.classList.toggle('app-grid--styles-collapsed', panelCollapseState.styles) - appGrid.classList.toggle( - 'app-grid--component-collapsed-horizontal', - panelCollapseState.component && componentAxis === 'horizontal', - ) - appGrid.classList.toggle( - 'app-grid--styles-collapsed-horizontal', - panelCollapseState.styles && stylesAxis === 'horizontal', - ) - - syncPanelCollapseButtons() -} - -const togglePanelCollapse = panelName => { - if (!Object.hasOwn(panelCollapseState, panelName)) { - return - } - - panelCollapseState[panelName] = !panelCollapseState[panelName] - applyPanelCollapseState() -} - -const toTextareaOffset = (source, line, column = 1) => { - if (typeof source !== 'string' || source.length === 0) { - return 0 - } - - const targetLine = Number.isFinite(line) ? Math.max(1, Number(line)) : 1 - const targetColumn = Number.isFinite(column) ? Math.max(1, Number(column)) : 1 - - let currentLine = 1 - let lineStartOffset = 0 - - for (let index = 0; index < source.length; index += 1) { - if (currentLine === targetLine) { - lineStartOffset = index - break - } - - if (source[index] === '\n') { - currentLine += 1 - lineStartOffset = index + 1 - } - } - - const nextNewlineOffset = source.indexOf('\n', lineStartOffset) - const lineEndOffset = nextNewlineOffset === -1 ? source.length : nextNewlineOffset - return Math.min(lineStartOffset + targetColumn - 1, lineEndOffset) -} - -const navigateToComponentDiagnostic = ({ line, column }) => { - if (jsxCodeEditor && typeof jsxCodeEditor.revealPosition === 'function') { - jsxCodeEditor.revealPosition({ line, column }) - return - } - - if (!(jsxEditor instanceof HTMLTextAreaElement)) { - return - } - - const source = jsxEditor.value - const offset = toTextareaOffset(source, line, column) - jsxEditor.focus() - jsxEditor.setSelectionRange(offset, offset) -} - -const navigateToStylesDiagnostic = ({ line, column }) => { - if (cssCodeEditor && typeof cssCodeEditor.revealPosition === 'function') { - cssCodeEditor.revealPosition({ line, column }) - return - } - - if (!(cssEditor instanceof HTMLTextAreaElement)) { - return - } +const { applyTheme, getInitialTheme } = layoutTheme - const source = cssEditor.value - const offset = toTextareaOffset(source, line, column) - cssEditor.focus() - cssEditor.setSelectionRange(offset, offset) -} +const githubTokenInfoUi = createGitHubTokenInfoUiController({ + tokenInfoButton: githubTokenInfo, + tokenInfoPanel: githubTokenInfoPanel, +}) +const compactAiControlsUi = createCompactAiControlsUiController({ + toggleButton: aiControlsToggle, + controlsRoot: githubAiControls, + closeTokenInfo: () => githubTokenInfoUi.close(), +}) +const workspaceTabAddMenuUi = createWorkspaceTabAddMenuUiController({ + addButton: workspaceTabAddButton, + addMenu: workspaceTabAddMenu, + addModuleButton: workspaceTabAddModule, +}) -const diagnosticsUi = createDiagnosticsUiController({ +const { + panelToolsState, + applyEditorToolsVisibility, + applyPanelCollapseState, + togglePanelCollapse, + diagnosticsUi, +} = createLayoutDiagnosticsSetup({ + compactAiControlsUi, + appGrid, + previewPanel, + panelCollapseButtons, + editorKinds, + editorPanelsByKind, + editorToolsButtons, + createDiagnosticsUiController, diagnosticsToggle, diagnosticsDrawer, diagnosticsComponent, diagnosticsStyles, statusNode, - onNavigateDiagnostic: diagnostic => { - if (diagnostic?.scope === 'component') { - navigateToComponentDiagnostic({ - line: diagnostic.line, - column: diagnostic.column, - }) - return - } - - if (diagnostic?.scope === 'styles') { - navigateToStylesDiagnostic({ - line: diagnostic.line, - column: diagnostic.column, - }) - } - }, + getJsxCodeEditor: () => jsxCodeEditor, + getCssCodeEditor: () => cssCodeEditor, + jsxEditor, + cssEditor, }) const { @@ -533,15 +404,12 @@ const { getDiagnosticsDrawerOpen, incrementLintDiagnosticsRuns, incrementTypeDiagnosticsRuns, - renderDiagnosticsScope, setDiagnosticsDrawerOpen, setLintDiagnosticsPending, setTypeDiagnosticsPending, setStatus, setStyleDiagnosticsDetails, setTypeDiagnosticsDetails, - updateDiagnosticsToggleLabel, - updateUiIssueIndicators, } = diagnosticsUi const githubAiContextState = { @@ -553,9 +421,45 @@ const githubAiContextState = { hasSyncedActivePrEditorContent: false, } +let workspacePrContextState = 'inactive' +let workspacePrNumber = null +let workspaceRepositoryFullName = '' +let workspaceScopeMarker = 'local' +let hasObservedActivePrContextInSession = false +let workspaceContextStatusController = { + render: () => {}, + renderForRepositoryChange: () => {}, + syncTokenState: () => {}, + syncWritableRepositoriesState: () => {}, +} + +const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') +const setWorkspaceScopeMarker = nextScope => { + workspaceScopeMarker = toWorkspaceScopeMarker(nextScope) + workspaceContextStatusController.render() +} + +const toPullRequestNumber = value => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + + return null +} + +const setActiveWorkspaceRecordId = nextValue => { + activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue) + if (!activeWorkspaceRecordId) { + workspaceRepositoryFullName = '' + workspaceScopeMarker = 'local' + } + workspaceContextStatusController.render() +} + let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, + onActiveWorkspaceTabChange: () => {}, setToken: () => {}, dispose: () => {}, } @@ -564,119 +468,44 @@ let prDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, getActivePrContext: () => null, + hydrateActivePrContext: () => false, clearActivePrContext: () => {}, + clearSelectedRepositoryActivePrContext: () => false, closeActivePullRequestOnGitHub: async () => null, setToken: () => {}, syncRepositories: () => {}, dispose: () => {}, } -const setGitHubPrToggleVisual = mode => { - if ( - !(githubPrToggle instanceof HTMLButtonElement) || - !(githubPrToggleLabel instanceof HTMLElement) || - !(githubPrToggleIcon instanceof SVGElement) || - !(githubPrToggleIconPath instanceof SVGPathElement) - ) { - return - } - - const isPushCommitMode = mode === 'push-commit' - const label = isPushCommitMode ? 'Push' : 'Open PR' - const title = isPushCommitMode - ? 'Push commit to active pull request branch' - : 'Open pull request' - const icon = isPushCommitMode ? githubPrPushCommitIcon : githubPrOpenIcon - - githubPrToggleLabel.textContent = label - githubPrToggle.title = title - githubPrToggle.setAttribute('aria-label', title) - githubPrToggleIcon.setAttribute('viewBox', icon.viewBox) - githubPrToggleIconPath.setAttribute('d', icon.path) -} - -const syncEditorPrContextIndicators = shouldShow => { - const iconNodes = [componentPrSyncIcon, stylesPrSyncIcon] - const iconPathNodes = [componentPrSyncIconPath, stylesPrSyncIconPath] - - for (const iconPath of iconPathNodes) { - if (iconPath instanceof SVGPathElement) { - iconPath.setAttribute('d', githubPrOpenIcon.path) - } - } - - for (const icon of iconNodes) { - if (!(icon instanceof SVGElement)) { - continue - } - - icon.setAttribute('viewBox', githubPrOpenIcon.viewBox) - icon.dataset.visible = shouldShow ? 'true' : 'false' - icon.toggleAttribute('hidden', !shouldShow) - } -} - -const syncActivePrContextUi = activeContext => { - githubAiContextState.activePrContext = activeContext ?? null - const nextSyncKey = getActivePrContextSyncKey(activeContext) - - if (!nextSyncKey) { - githubAiContextState.activePrEditorSyncKey = '' - githubAiContextState.hasSyncedActivePrEditorContent = false - } else if (githubAiContextState.activePrEditorSyncKey !== nextSyncKey) { - githubAiContextState.activePrEditorSyncKey = nextSyncKey - githubAiContextState.hasSyncedActivePrEditorContent = false - } - - const hasActiveContext = Boolean(activeContext?.prTitle) - const shouldShowEditorSyncIndicators = - hasActiveContext && githubAiContextState.hasSyncedActivePrEditorContent - - setGitHubPrToggleVisual(hasActiveContext ? 'push-commit' : 'open-pr') - syncEditorPrContextIndicators(shouldShowEditorSyncIndicators) - - if (!hasActiveContext) { - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - return - } - - githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') -} - -const syncAiChatTokenVisibility = token => { - const hasToken = typeof token === 'string' && token.trim().length > 0 - - if (hasToken) { - aiChatToggle?.removeAttribute('hidden') - - githubPrToggle?.removeAttribute('hidden') - - if (githubAiContextState.activePrContext) { - githubPrContextClose?.removeAttribute('hidden') - githubPrContextDisconnect?.removeAttribute('hidden') - } else { - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - } - return - } +const prContextUi = createGitHubPrContextUiController({ + contextState: githubAiContextState, + getActivePrContextSyncKey, + githubPrToggle, + githubPrToggleLabel, + githubPrToggleIcon, + githubPrToggleIconPath, + componentPrSyncIcon, + componentPrSyncIconPath, + stylesPrSyncIcon, + stylesPrSyncIconPath, + githubPrContextClose, + aiChatToggle, + workspacesToggle, + githubPrOpenIcon, + githubPrPushCommitIcon, + closeChatDrawer: () => { + chatDrawerController.setOpen(false) + }, + closePrDrawer: () => { + prDrawerController.setOpen(false) + }, + closeWorkspacesDrawer: () => workspacesDrawerController?.setOpen(false), +}) - aiChatToggle?.setAttribute('hidden', '') - aiChatToggle?.setAttribute('aria-expanded', 'false') - githubAiContextState.activePrContext = null - githubAiContextState.activePrEditorSyncKey = '' - githubAiContextState.hasSyncedActivePrEditorContent = false - syncEditorPrContextIndicators(false) - setGitHubPrToggleVisual('open-pr') - githubPrToggle?.setAttribute('hidden', '') - githubPrToggle?.setAttribute('aria-expanded', 'false') - githubPrContextClose?.setAttribute('hidden', '') - githubPrContextDisconnect?.setAttribute('hidden', '') - chatDrawerController.setOpen(false) - prDrawerController.setOpen(false) -} +const editedIndicatorVisibilityController = createEditedIndicatorVisibilityController({ + getToken: () => githubAiContextState.token, + getActivePrContext: () => githubAiContextState.activePrContext, +}) const byotControls = createGitHubByotControls({ controlsRoot: githubAiControls, @@ -688,12 +517,47 @@ const byotControls = createGitHubByotControls({ githubAiContextState.selectedRepository = repository chatDrawerController.setSelectedRepository(repository) prDrawerController.setSelectedRepository(repository) + hasObservedActivePrContextInSession = false + prDrawerController.syncRepositories() + workspaceContextStatusController.renderForRepositoryChange() }, - onWritableRepositoriesChange: ({ repositories }) => { + onWritableRepositoriesChange: ({ + repositories, + selectedRepository, + isLoadingRepositories, + }) => { githubAiContextState.writableRepositories = Array.isArray(repositories) ? [...repositories] : [] - prDrawerController.syncRepositories() + + workspaceContextStatusController.syncWritableRepositoriesState({ + token: githubAiContextState.token, + isLoadingRepositories, + }) + + if (selectedRepository || githubAiContextState.selectedRepository) { + githubAiContextState.selectedRepository = selectedRepository ?? null + chatDrawerController.setSelectedRepository(selectedRepository) + prDrawerController.setSelectedRepository(selectedRepository) + prDrawerController.syncRepositories() + workspaceContextStatusController.renderForRepositoryChange() + return + } + + const workspaceScopedRepository = toNonEmptyWorkspaceText(workspaceRepositoryFullName) + if (!workspaceScopedRepository) { + return + } + + if (byotControls.setSelectedRepository(workspaceScopedRepository)) { + const synchronizedRepository = byotControls.getSelectedRepository() + githubAiContextState.selectedRepository = synchronizedRepository + chatDrawerController.setSelectedRepository(synchronizedRepository) + prDrawerController.setSelectedRepository(synchronizedRepository) + prDrawerController.syncRepositories() + } + + workspaceContextStatusController.renderForRepositoryChange() }, onTokenDeleteRequest: onConfirm => { confirmAction({ @@ -705,9 +569,11 @@ const byotControls = createGitHubByotControls({ }, onTokenChange: token => { githubAiContextState.token = token - syncAiChatTokenVisibility(token) + workspaceContextStatusController.syncTokenState(token) + prContextUi.syncAiChatTokenVisibility(token) chatDrawerController.setToken(token) prDrawerController.setToken(token) + editedIndicatorVisibilityController.refreshIndicators() }, setStatus, }) @@ -721,1061 +587,916 @@ const getCurrentGitHubToken = () => githubAiContextState.token ?? byotControls.g const getCurrentSelectedRepository = () => githubAiContextState.selectedRepository ?? byotControls.getSelectedRepository() -const getCurrentWritableRepositories = () => - githubAiContextState.writableRepositories.length > 0 - ? [...githubAiContextState.writableRepositories] - : byotControls.getWritableRepositories() - -const setCurrentSelectedRepository = fullName => - byotControls.setSelectedRepository(fullName) - -const getTopLevelDeclarations = async source => { - if (typeof source !== 'string' || !source.trim()) { - return [] +const getCurrentSelectedRepositoryFullName = () => { + const selectedRepositoryFullName = getCurrentSelectedRepository()?.fullName + if ( + typeof selectedRepositoryFullName === 'string' && + selectedRepositoryFullName.trim() + ) { + return selectedRepositoryFullName.trim() } - const transformJsxSource = await ensureJsxTransformSource({ - cdnImports, - importFromCdnWithFallback, - }) - return collectTopLevelDeclarations({ source, transformJsxSource }) + return '' } -const prEditorSyncController = createGitHubPrEditorSyncController({ - setComponentSource: setJsxSource, - setStylesSource: setCssSource, - scheduleRender: () => { - if ( - autoRenderToggle?.checked && - typeof renderRuntime?.scheduleRender === 'function' - ) { - renderRuntime.scheduleRender() - } - }, +workspaceContextStatusController = createWorkspaceContextStatusController({ + statusNode: workspaceContextStatus, + toNonEmptyWorkspaceText, + getWorkspacePrTitle: () => githubPrTitle?.value, + getWorkspaceHeadBranch: () => githubPrHeadBranch?.value, + getWorkspaceScopeMarker: () => workspaceScopeMarker, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + getSelectedRepositoryFullName: getCurrentSelectedRepositoryFullName, }) -chatDrawerController = createGitHubChatDrawer({ - toggleButton: aiChatToggle, - drawer: aiChatDrawer, - closeButton: aiChatClose, - promptInput: aiChatPrompt, - modelSelect: aiChatModel, - includeEditorsContextToggle: aiChatIncludeEditors, - sendButton: aiChatSend, - clearButton: aiChatClear, - statusNode: aiChatStatus, - repositoryNode: aiChatRepository, - messagesNode: aiChatMessages, - getToken: getCurrentGitHubToken, - getSelectedRepository: getCurrentSelectedRepository, - getComponentSource: () => getJsxSource(), - setComponentSource: value => setJsxSource(value), - getStylesSource: () => getCssSource(), - setStylesSource: value => setCssSource(value), - scheduleRender: () => { - if ( - autoRenderToggle?.checked && - typeof renderRuntime?.scheduleRender === 'function' - ) { - renderRuntime.scheduleRender() - } - }, - getRenderMode: () => renderMode.value, - getStyleMode: () => styleMode.value, - getDrawerSide: () => { - const layout = getCurrentLayout() - return layout === 'preview-left' ? 'left' : 'right' - }, +workspaceContextStatusController.render() + +const getPersistedActivePrContext = createPersistedActivePrContextGetter({ + getCurrentSelectedRepositoryFullName, + getWorkspacePrContextState: () => workspacePrContextState, + getWorkspacePrNumber: () => workspacePrNumber, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + renderMode, + styleMode, }) -prDrawerController = createGitHubPrDrawer({ - toggleButton: githubPrToggle, - drawer: githubPrDrawer, - closeButton: githubPrClose, - repositorySelect: githubPrRepoSelect, - baseBranchInput: githubPrBaseBranch, - headBranchInput: githubPrHeadBranch, - componentPathInput: githubPrComponentPath, - stylesPathInput: githubPrStylesPath, - prTitleInput: githubPrTitle, - prBodyInput: githubPrBody, - commitMessageInput: githubPrCommitMessage, - includeAppWrapperToggle: githubPrIncludeAppWrapper, - submitButton: githubPrSubmit, - titleNode: openPrTitle, - statusNode: githubPrStatus, - getToken: getCurrentGitHubToken, - getSelectedRepository: getCurrentSelectedRepository, - getWritableRepositories: getCurrentWritableRepositories, - setSelectedRepository: setCurrentSelectedRepository, - getComponentSource: () => getJsxSource(), - getStylesSource: () => getCssSource(), - getTopLevelDeclarations, - getRenderMode: () => renderMode.value, - getStyleMode: () => styleMode.value, - getDrawerSide: () => { - const layout = getCurrentLayout() - return layout === 'preview-left' ? 'left' : 'right' - }, - confirmBeforeSubmit: options => { - confirmAction(options) - }, - onPullRequestOpened: ({ url }) => { - const activeContextSyncKey = getActivePrContextSyncKey( - githubAiContextState.activePrContext, - ) - if ( - activeContextSyncKey && - activeContextSyncKey === githubAiContextState.activePrEditorSyncKey - ) { - githubAiContextState.hasSyncedActivePrEditorContent = true - syncEditorPrContextIndicators(true) - } +const getWorkspaceContextSnapshot = createWorkspaceContextSnapshotGetter({ + getCurrentSelectedRepository: () => + workspaceScopeMarker === 'repository' + ? workspaceRepositoryFullName || getCurrentSelectedRepositoryFullName() + : '', + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + getActivePrContext: () => githubAiContextState.activePrContext, + getPrContextState: () => workspacePrContextState, + getPrNumber: () => workspacePrNumber, +}) - const message = url - ? `Pull request opened: ${url}` - : 'Pull request opened successfully.' - showAppToast(message) - }, - onPullRequestCommitPushed: ({ branch, fileUpdates }) => { - const fileCount = Array.isArray(fileUpdates) ? fileUpdates.length : 0 - const message = - fileCount > 0 - ? `Pushed commit to ${branch} (${fileCount} file${fileCount === 1 ? '' : 's'}).` - : `Pushed commit to ${branch}.` - showAppToast(message) - }, - onActivePrContextChange: activeContext => { - syncActivePrContextUi(activeContext) - syncAiChatTokenVisibility(githubAiContextState.token) - }, - onSyncActivePrEditorContent: async args => { - const result = await prEditorSyncController.syncFromActiveContext(args) - const syncedContextKey = getActivePrContextSyncKey(args?.activeContext) +const { getActiveWorkspaceTab, getEntryWorkspaceTab, getPrimaryStyleWorkspaceTab } = + createWorkspaceTabSelectors({ + workspaceTabsState, + getTabKind, + toNonEmptyWorkspaceText, + }) +const isStyleWorkspaceTab = tab => isStyleTabLanguage(tab?.language) - if ( - !syncedContextKey || - syncedContextKey !== githubAiContextState.activePrEditorSyncKey - ) { - return result - } - - if (result?.synced === true) { - githubAiContextState.hasSyncedActivePrEditorContent = true - syncEditorPrContextIndicators(true) - } - - return result - }, - onRestoreRenderMode: mode => { - applyRenderMode({ mode, fromActivePrContext: true }) - }, - onRestoreStyleMode: mode => { - applyStyleMode({ mode }) - }, +const { + clearTrackedWorkspaceTab, + getWorkspaceTabByKind, + syncHeaderLabels, + persistActiveTabEditorContent, + loadWorkspaceTabIntoEditor, +} = createWorkspaceEditorHelpers({ + workspaceTabsState, + isStyleWorkspaceTab, + editorKinds, + editorPanelsByKind, + editorHeaderLabelByKind, + editorHeaderDirtyStatusByKind, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, + defaultTabNameByKind, + toNonEmptyWorkspaceText, + getEntryWorkspaceTab, + getPrimaryStyleWorkspaceTab, + getCssSource: () => getCssSource(), + getJsxSource: () => getJsxSource(), + getDirtyStateForTabChange, + setCssSource, + setJsxSource, + styleMode, + toStyleModeForTabLanguage, + getStyleEditorLanguage, + getCssCodeEditor: () => cssCodeEditor, + setSuppressEditorChangeSideEffects: value => (suppressEditorChangeSideEffects = value), + editorPool, }) -prDrawerController.setToken(githubAiContextState.token) -prDrawerController.setSelectedRepository(githubAiContextState.selectedRepository) -prDrawerController.syncRepositories() -syncActivePrContextUi(prDrawerController.getActivePrContext()) - -githubPrContextClose?.addEventListener('click', () => { - if (!githubAiContextState.activePrContext) { - return - } - - const activePrReference = formatActivePrReference(githubAiContextState.activePrContext) - const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' - - confirmAction({ - title: 'Close pull request on GitHub?', - copy: `${referenceLine}PR title: ${githubAiContextState.activePrContext.prTitle}\nHead branch: ${githubAiContextState.activePrContext.headBranch}\n\nThis will close the pull request on GitHub and clear the active pull request context for the selected repository.`, - confirmButtonText: 'Close PR on GitHub', - onConfirm: () => { - void prDrawerController - .closeActivePullRequestOnGitHub() - .then(result => { - const reference = result?.reference - setStatus( - reference - ? `Closed pull request on GitHub and cleared active context (${reference}).` - : 'Closed pull request on GitHub and cleared active context.', - 'neutral', - ) - showAppToast( - reference - ? `Closed pull request on GitHub and cleared active context (${reference}).` - : 'Closed pull request on GitHub and cleared active context.', - ) - }) - .catch(error => { - const message = - error instanceof Error - ? error.message - : 'Could not close pull request context on GitHub.' - setStatus(`Close context failed: ${message}`, 'error') - showAppToast(`Close context failed: ${message}`) - }) - }, - }) +const workspaceSyncController = createWorkspaceSyncController({ + workspaceTabsState, + isStyleWorkspaceTab, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncedContent, + toWorkspaceSyncSha, + toNonEmptyWorkspaceText, + toWorkspaceRecordKey, + hasTabCommittedSyncState, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + queueWorkspaceSave: () => queueWorkspaceSave(), + resolveWorkspaceRecordIdentity, + getWorkspaceContextSnapshot, + getWorkspaceScopeMarker: () => workspaceScopeMarker, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + getRenderModeValue: () => renderMode.value, + normalizeRenderMode: mode => normalizeRenderMode(mode), }) -githubPrContextDisconnect?.addEventListener('click', () => { - if (!githubAiContextState.activePrContext) { - return - } +const getTypecheckSourcePath = () => + toNonEmptyWorkspaceText(getEntryWorkspaceTab()?.path) || defaultComponentTabPath - const activePrReference = formatActivePrReference(githubAiContextState.activePrContext) - const referenceLine = activePrReference ? `PR: ${activePrReference}\n` : '' - - confirmAction({ - title: 'Disconnect PR context?', - copy: `${referenceLine}This will disconnect the active pull request context in this app only.\nYour pull request will stay open on GitHub.\nYour GitHub token and selected repository will stay connected.`, - confirmButtonText: 'Disconnect', - onConfirm: () => { - const result = prDrawerController.disconnectActivePrContext() - const reference = result?.reference - setStatus( - reference - ? `Disconnected PR context (${reference}). Pull request remains open on GitHub.` - : 'Disconnected PR context. Pull request remains open on GitHub.', - 'neutral', - ) - }, - }) +const { + clearDiagnosticsOnTabSwitch, + getComponentLintTarget, + getStylesLintTarget, + syncDiagnosticsDrawerLayout, +} = createDiagnosticsTabStateHelpers({ + getActiveWorkspaceTab, + getEntryWorkspaceTab, + getPrimaryStyleWorkspaceTab, + isStyleWorkspaceTab, + toNonEmptyWorkspaceText, + diagnosticsComponentSection, + diagnosticsStylesSection, + diagnosticsComponentHeading, + diagnosticsStylesHeading, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + clearAllDiagnostics, + setTypeDiagnosticsPending, + setLintDiagnosticsPending, + statusNode, + setStatus, + getDiagnosticsFlowController: () => diagnosticsFlowController, }) -const getStyleEditorLanguage = mode => { - if (mode === 'less') return 'less' - if (mode === 'sass') return 'sass' - return 'css' -} +const createWorkspaceTabId = prefix => createWorkspaceTabIdFactory(prefix) -const normalizeStyleMode = mode => { - if (mode === 'module') return 'module' - if (mode === 'less') return 'less' - if (mode === 'sass') return 'sass' - return 'css' -} +const makeUniqueTabPath = ({ basePath, suffix = '' }) => + makeUniqueTabPathFactory({ + basePath, + suffix, + tabs: workspaceTabsState.getTabs(), + toNonEmptyWorkspaceText, + }) -const createEditorHost = textarea => { - const host = document.createElement('div') - host.className = 'editor-host' - textarea.before(host) - return host -} +const ensureWorkspaceTabsShape = createEnsureWorkspaceTabsShape({ + defaultComponentTabName, + defaultComponentTabPath, + defaultStylesTabName, + defaultStylesTabPath, + defaultJsx, + normalizeEntryTabPath, + getAllowedEntryTabFileNames, + getPathFileName, + getTabTargetPrFilePath, + normalizeWorkspacePathValue, + toWorkspaceSyncTimestamp, + toWorkspaceSyncSha, + resolveSyncedBaselineContent, + toNonEmptyWorkspaceText, + isStyleTabLanguage, +}) -const initializeCodeEditors = async () => { - const jsxHost = createEditorHost(jsxEditor) - const cssHost = createEditorHost(cssEditor) - - try { - const [nextJsxEditor, nextCssEditor] = await Promise.all([ - createCodeMirrorEditor({ - parent: jsxHost, - value: defaultJsx, - language: 'javascript-jsx', - contentAttributes: { - 'aria-label': 'Component source editor', - 'aria-multiline': 'true', - }, - onChange: () => { - if (suppressEditorChangeSideEffects) { - return - } - maybeRender() - markTypeDiagnosticsStale() - markComponentLintDiagnosticsStale() - }, - }), - createCodeMirrorEditor({ - parent: cssHost, - value: defaultCss, - language: getStyleEditorLanguage(styleMode.value), - contentAttributes: { - 'aria-label': 'Styles source editor', - 'aria-multiline': 'true', - }, - onChange: () => { - if (suppressEditorChangeSideEffects) { - return - } - maybeRender() - markStylesLintDiagnosticsStale() - }, - }), - ]) - - jsxCodeEditor = nextJsxEditor - cssCodeEditor = nextCssEditor - getJsxSource = () => jsxCodeEditor.getValue() - getCssSource = () => cssCodeEditor.getValue() - - jsxEditor.classList.add('source-textarea--hidden') - cssEditor.classList.add('source-textarea--hidden') - } catch (error) { - jsxHost.remove() - cssHost.remove() - const message = error instanceof Error ? error.message : String(error) - setStatus(`Editor fallback: ${message}`, 'neutral') - } -} +const buildWorkspaceTabsSnapshot = () => + workspaceSyncController.buildWorkspaceTabsSnapshot() -const setTypecheckButtonLoading = isLoading => { - if (!typecheckButton) { - return - } +const getWorkspacePrFileCommits = options => + workspaceSyncController.getWorkspacePrFileCommits(options) - typecheckButton.classList.toggle('render-button--loading', isLoading) - typecheckButton.setAttribute('aria-busy', isLoading ? 'true' : 'false') - typecheckButton.disabled = isLoading -} +const getEditorSyncTargets = () => workspaceSyncController.getEditorSyncTargets() -const setLintButtonLoading = ({ button, isLoading }) => { - if (!(button instanceof HTMLButtonElement)) { - return - } +const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => + workspaceSyncController.reconcileWorkspaceTabsWithEditorSync({ tabTargets }) - button.classList.toggle('render-button--loading', isLoading) - button.setAttribute('aria-busy', isLoading ? 'true' : 'false') - button.disabled = isLoading -} +const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => + workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId }) -const setCdnLoading = isLoading => { - if (!cdnLoading) return - cdnLoading.hidden = !isLoading -} - -const setRenderedStatus = () => { - if (typeDiagnostics.getLastTypeErrorCount() > 0) { - setStatus( - `Rendered (Type errors: ${typeDiagnostics.getLastTypeErrorCount()})`, - 'error', - ) - return - } - - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } +const setWorkspaceRepositoryFullName = value => { + workspaceRepositoryFullName = toNonEmptyWorkspaceText(value) + workspaceContextStatusController.render() } +const onWorkspaceRecordApplied = createWorkspaceRecordAppliedHandler({ + getPrDrawerController: () => prDrawerController, + setWorkspaceRepositoryFullName, + byotControls, + getGithubPrBodyValue: () => + typeof githubPrBody?.value === 'string' ? githubPrBody.value : '', + normalizeRenderMode, + getStyleModeValue: () => styleMode.value, +}) -const typeDiagnostics = createTypeDiagnosticsController({ - cdnImports, - importFromCdnWithFallback, - getTypeScriptLibUrls, - getTypePackageFileUrls, - getJsxSource: () => getJsxSource(), - getRenderMode: () => renderMode.value, - setTypecheckButtonLoading, - setTypeDiagnosticsDetails, - setTypeDiagnosticsPending, +const { + workspaceSaveController, + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + queueWorkspaceSave, + flushWorkspaceSave, + setActiveWorkspaceTab, + addWorkspaceTab, + renderWorkspaceTabs, + loadPreferredWorkspaceContext, + bindWorkspaceMetadataPersistence, +} = createWorkspaceControllersSetup({ + createDebouncedWorkspaceSaver, + workspaceStorage, + getWorkspacesDrawerController: () => workspacesDrawerController, + toNonEmptyWorkspaceText, + buildWorkspaceRecordSnapshot, setStatus, - setRenderedStatus, - isRenderedStatus: () => - statusNode.textContent === 'Rendered' || - statusNode.textContent.startsWith('Rendered (Type errors:'), - isRenderedTypeErrorStatus: () => - statusNode.textContent.startsWith('Rendered (Type errors:'), - incrementTypeDiagnosticsRuns, - decrementTypeDiagnosticsRuns, - getActiveTypeDiagnosticsRuns, - onIssuesDetected: ({ issueCount }) => { - if (issueCount > 0) { - setDiagnosticsDrawerOpen(true) - } + getIsApplyingWorkspaceSnapshot: () => isApplyingWorkspaceSnapshot, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + setWorkspacePrContextState: value => (workspacePrContextState = value), + setWorkspacePrNumber: value => (workspacePrNumber = toPullRequestNumber(value)), + setWorkspaceScopeMarker, + getCurrentSelectedRepository: getCurrentSelectedRepositoryFullName, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setIsApplyingWorkspaceSnapshot: value => (isApplyingWorkspaceSnapshot = value), + ensureWorkspaceTabsShape, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabsState, + resolveWorkspaceActiveTabId, + normalizeRenderMode: mode => normalizeRenderMode(mode), + getRenderModeValue: () => renderMode.value, + setRenderModeValue: value => { + renderMode.value = value }, -}) + getActiveWorkspaceTab, + onActiveWorkspaceTabChange: (_tab, { changed } = {}) => { + syncDiagnosticsDrawerLayout() + chatDrawerController.onActiveWorkspaceTabChange() -const lintDiagnostics = createLintDiagnosticsController({ - cdnImports, - importFromCdnWithFallback, - getComponentSource: () => getJsxSource(), - getStylesSource: () => getCssSource(), - getStyleMode: () => styleMode.value, - setComponentDiagnostics: setTypeDiagnosticsDetails, - setStyleDiagnostics: setStyleDiagnosticsDetails, - setStatus, - onIssuesDetected: ({ issueCount }) => { - if (issueCount > 0) { - setDiagnosticsDrawerOpen(true) + if (changed) { + clearDiagnosticsOnTabSwitch() } }, + loadWorkspaceTabIntoEditor, + updateRenderModeEditability: () => updateRenderModeEditability(), + getHasCompletedInitialWorkspaceBootstrap: () => hasCompletedInitialWorkspaceBootstrap, + maybeRender: () => maybeRender(), + toWorkspaceRecordKey, + workspaceTabsStrip, + getWorkspaceTabRenameState: () => workspaceTabRenameState, + getDraggedWorkspaceTabId: () => draggedWorkspaceTabId, + setDraggedWorkspaceTabId: value => (draggedWorkspaceTabId = value), + getDragOverWorkspaceTabId: () => dragOverWorkspaceTabId, + setDragOverWorkspaceTabId: value => (dragOverWorkspaceTabId = value), + getSuppressWorkspaceTabClick: () => suppressWorkspaceTabClick, + setSuppressWorkspaceTabClick: value => (suppressWorkspaceTabClick = value), + getIsRenderingWorkspaceTabs: () => isRenderingWorkspaceTabs, + setIsRenderingWorkspaceTabs: value => (isRenderingWorkspaceTabs = value), + getHasPendingWorkspaceTabsRender: () => hasPendingWorkspaceTabsRender, + setHasPendingWorkspaceTabsRender: value => (hasPendingWorkspaceTabsRender = value), + persistActiveTabEditorContent, + getWorkspaceTabDisplay, + getShouldShowEditedDesign: + editedIndicatorVisibilityController.getShouldShowEditedDesign, + workspaceTabsShell, + workspaceTabAddWrap, + setWorkspaceTabRenameState: value => (workspaceTabRenameState = value), + getAllowedEntryTabFileNames, + getPathFileName, + normalizeEntryTabPath, + normalizeModuleTabPathForRename, + defaultComponentTabName, + getDirtyStateForTabChange, + syncHeaderLabels, + setWorkspaceTabAddMenuOpen: isOpen => { + workspaceTabAddMenuUi.setOpen(isOpen) + }, + confirmAction: options => confirmAction(options), + isStyleWorkspaceTab, + clearTrackedWorkspaceTab, + getWorkspaceTabByKind, + makeUniqueTabPath, + createWorkspaceTabId, + onWorkspaceRecordApplied, }) -let activeComponentLintAbortController = null -let activeStylesLintAbortController = null -let lastComponentLintIssueCount = 0 -let lastStylesLintIssueCount = 0 -let scheduledComponentLintRecheck = null -let scheduledStylesLintRecheck = null -let componentLintPending = false -let stylesLintPending = false - -const clearComponentLintRecheckTimer = () => { - if (scheduledComponentLintRecheck) { - clearTimeout(scheduledComponentLintRecheck) - scheduledComponentLintRecheck = null - } -} - -const clearStylesLintRecheckTimer = () => { - if (scheduledStylesLintRecheck) { - clearTimeout(scheduledStylesLintRecheck) - scheduledStylesLintRecheck = null - } -} - -const syncLintPendingState = () => { - setLintDiagnosticsPending(componentLintPending || stylesLintPending) -} - -const runComponentLint = async ({ userInitiated = false } = {}) => { - activeComponentLintAbortController?.abort() - const controller = new AbortController() - activeComponentLintAbortController = controller - componentLintPending = false - syncLintPendingState() - incrementLintDiagnosticsRuns() - - setLintButtonLoading({ button: lintComponentButton, isLoading: true }) - - try { - const result = await lintDiagnostics.lintComponent({ - signal: controller.signal, - userInitiated, - }) - if (result) { - lastComponentLintIssueCount = result.issueCount - } - } finally { - decrementLintDiagnosticsRuns() - if (activeComponentLintAbortController === controller) { - activeComponentLintAbortController = null - setLintButtonLoading({ button: lintComponentButton, isLoading: false }) - } - } -} - -const runStylesLint = async ({ userInitiated = false } = {}) => { - activeStylesLintAbortController?.abort() - const controller = new AbortController() - activeStylesLintAbortController = controller - stylesLintPending = false - syncLintPendingState() - incrementLintDiagnosticsRuns() - - setLintButtonLoading({ button: lintStylesButton, isLoading: true }) - - try { - const result = await lintDiagnostics.lintStyles({ - signal: controller.signal, - userInitiated, - }) - if (result) { - lastStylesLintIssueCount = result.issueCount - } - } finally { - decrementLintDiagnosticsRuns() - if (activeStylesLintAbortController === controller) { - activeStylesLintAbortController = null - setLintButtonLoading({ button: lintStylesButton, isLoading: false }) - } - } -} - -const markTypeDiagnosticsStale = () => { - typeDiagnostics.markTypeDiagnosticsStale() -} - -const markComponentLintDiagnosticsStale = () => { - clearComponentLintRecheckTimer() - - if (lastComponentLintIssueCount > 0) { - componentLintPending = true - syncLintPendingState() - setTypeDiagnosticsDetails({ - headline: 'Source changed. Re-checking lint issues…', - level: 'muted', - }) - - scheduledComponentLintRecheck = setTimeout(() => { - scheduledComponentLintRecheck = null - void runComponentLint() - }, 450) - return - } - - componentLintPending = false - syncLintPendingState() - setTypeDiagnosticsDetails({ - headline: 'Source changed. Click Lint to run diagnostics.', - level: 'muted', - }) - - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } -} - -const markStylesLintDiagnosticsStale = () => { - clearStylesLintRecheckTimer() - - if (lastStylesLintIssueCount > 0) { - stylesLintPending = true - syncLintPendingState() - setStyleDiagnosticsDetails({ - headline: 'Source changed. Re-checking lint issues…', - level: 'muted', - }) - - scheduledStylesLintRecheck = setTimeout(() => { - scheduledStylesLintRecheck = null - void runStylesLint() - }, 450) - return - } - - stylesLintPending = false - syncLintPendingState() - setStyleDiagnosticsDetails({ - headline: 'Source changed. Click Lint to run diagnostics.', - level: 'muted', +const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = + createWorkspaceScopeForkActions({ + toNonEmptyWorkspaceText, + workspaceStorage, + flushWorkspaceSave, + refreshLocalContextOptions, + createWorkspaceRecordId, + buildWorkspaceRecordSnapshot, + toWorkspaceRecordKey, + getWorkspacePrContextState: () => workspacePrContextState, + setWorkspacePrContextState: value => { + setWorkspacePrContextState(value) + }, + setWorkspacePrNumber: value => { + setWorkspacePrNumber(value) + }, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + setWorkspaceRepositoryFullName, + setWorkspaceScopeMarker, + setHeadBranchValue: value => { + if (githubPrHeadBranch) { + githubPrHeadBranch.value = value + } + }, + setPrTitleValue: value => { + if (githubPrTitle) { + githubPrTitle.value = value + } + }, }) - if (statusNode.textContent.startsWith('Rendered (Lint issues:')) { - setStatus('Rendered', 'neutral') - } -} - -const clearComponentLintDiagnosticsState = () => { - lastComponentLintIssueCount = 0 - componentLintPending = false - clearComponentLintRecheckTimer() - syncLintPendingState() -} - -const clearStylesLintDiagnosticsState = () => { - lastStylesLintIssueCount = 0 - stylesLintPending = false - clearStylesLintRecheckTimer() - syncLintPendingState() -} - -const resetDiagnosticsFlow = () => { - activeComponentLintAbortController?.abort() - activeStylesLintAbortController?.abort() - activeComponentLintAbortController = null - activeStylesLintAbortController = null - - lintDiagnostics.cancelAll() - typeDiagnostics.cancelTypeDiagnostics() - clearComponentLintDiagnosticsState() - clearStylesLintDiagnosticsState() - clearAllDiagnostics() - - setLintButtonLoading({ button: lintComponentButton, isLoading: false }) - setLintButtonLoading({ button: lintStylesButton, isLoading: false }) - setStatus('Rendered', 'neutral') -} - -const renderPreview = async () => { - await renderRuntime.renderPreview() -} - -const maybeRender = () => { - if (autoRenderToggle.checked) { - renderRuntime.scheduleRender() - } -} - -renderRuntime = createRenderRuntimeController({ - cdnImports, - importFromCdnWithFallback, - renderMode, - styleMode, - shadowToggle, - isAutoRenderEnabled: () => autoRenderToggle.checked, - getCssSource: () => getCssSource(), - getJsxSource: () => getJsxSource(), - getPreviewHost: () => previewHost, - setPreviewHost: nextHost => { - previewHost = nextHost - }, - applyPreviewBackgroundColor: color => - previewBackground.applyPreviewBackgroundColor(color), - getPreviewBackgroundColor: () => previewBackground.getPreviewBackgroundColor(), - clearStyleDiagnostics: () => clearDiagnosticsScope('styles'), - setStyleDiagnosticsDetails, - setStatus, - setRenderedStatus, - onFirstRenderComplete: () => {}, - setCdnLoading, +editedIndicatorVisibilityController.setRefreshHandlers({ + syncHeaderLabels, + renderWorkspaceTabs, }) -function setJsxSource(value) { - if (jsxCodeEditor) { - suppressEditorChangeSideEffects = true - try { - jsxCodeEditor.setValue(value) - } finally { - suppressEditorChangeSideEffects = false - } - } - jsxEditor.value = value -} - -function setCssSource(value) { - if (cssCodeEditor) { - suppressEditorChangeSideEffects = true - try { - cssCodeEditor.setValue(value) - } finally { - suppressEditorChangeSideEffects = false - } - } - cssEditor.value = value -} - -const clearComponentSource = () => { - setJsxSource('') - clearDiagnosticsScope('component') - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - setStatus('Component cleared', 'neutral') - renderRuntime.clearPreview() -} - -const clearStylesSource = () => { - setCssSource('') - clearDiagnosticsScope('styles') - clearStylesLintDiagnosticsState() - setStatus('Styles cleared', 'neutral') - maybeRender() -} - -const confirmAction = ({ title, copy, confirmButtonText = 'Clear', onConfirm }) => { - const toConfirmText = value => (typeof value === 'string' ? value.trim() : '') - if ( - !(clearConfirmDialog instanceof HTMLDialogElement) || - typeof clearConfirmDialog.showModal !== 'function' - ) { - return - } - - if (clearConfirmDialog.open) { - return - } - - if (clearConfirmTitle) { - clearConfirmTitle.textContent = title - } - - if (clearConfirmCopy instanceof HTMLUListElement) { - const lines = toConfirmText(copy) - .split('\n') - .map(line => line.replace(/^\s*[-*]\s*/, '').trim()) - .filter(Boolean) - - clearConfirmCopy.replaceChildren() - const items = lines.length > 0 ? lines : [toConfirmText(copy)] - - for (const line of items) { - if (!line) { - continue +const normalizeWorkspaceEditorsTrailingNewlineAfterPublish = + createPublishTrailingNewlineNormalizer({ + workspaceTabsState, + getTabPublishPath: tab => + getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '', + normalizePublishPath: path => normalizeWorkspacePathValue(path), + getActiveTabId: () => workspaceTabsState.getActiveTabId(), + getCurrentEditorSource: () => { + const activeTab = getActiveWorkspaceTab() + return isStyleWorkspaceTab(activeTab) ? getCssSource() : getJsxSource() + }, + setCurrentEditorSource: value => { + const activeTab = getActiveWorkspaceTab() + if (isStyleWorkspaceTab(activeTab)) { + setCssSource(value) + return } - const listItem = document.createElement('li') - listItem.textContent = line - clearConfirmCopy.append(listItem) - } - } else if (clearConfirmCopy) { - clearConfirmCopy.textContent = copy - } - - if (clearConfirmButton instanceof HTMLButtonElement) { - clearConfirmButton.textContent = confirmButtonText - clearConfirmButton.removeAttribute('aria-label') - } - - pendingClearAction = onConfirm - clearConfirmDialog.showModal() -} - -const confirmClearSource = ({ label, onConfirm }) => { - confirmAction({ - title: `Clear ${label} source?`, - copy: 'This action will remove all text from the editor. This cannot be undone.', - onConfirm, + setJsxSource(value) + }, + setSuppressEditorChangeSideEffects: value => { + suppressEditorChangeSideEffects = value + }, + queueWorkspaceSave: () => queueWorkspaceSave(), }) -} - -const copyTextToClipboard = async text => { - if (!clipboardSupported) { - throw new Error('Clipboard API is not available in this browser context.') - } - - await navigator.clipboard.writeText(text) -} - -const copyComponentSource = async () => { - try { - await copyTextToClipboard(getJsxSource()) - setStatus('Component copied', 'neutral') - } catch { - setStatus('Copy failed', 'error') - } -} - -const copyStylesSource = async () => { - try { - await copyTextToClipboard(getCssSource()) - setStatus('Styles copied', 'neutral') - } catch { - setStatus('Copy failed', 'error') - } -} -const initializePreviewBackgroundPicker = () => { - previewBackground.initializePreviewBackgroundPicker() -} - -const updateRenderButtonVisibility = () => { - renderButton.hidden = autoRenderToggle.checked -} - -function applyRenderMode({ mode, fromActivePrContext = false }) { - const nextMode = mode === 'react' ? 'react' : 'dom' - - if (renderMode.value !== nextMode) { - renderMode.value = nextMode - } +const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { + normalizeWorkspaceEditorsTrailingNewlineAfterPublish({ fileUpdates }) + const updatedCount = + workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) - if (fromActivePrContext === true && nextMode === 'react') { - hasAppliedReactModeDefault = true + if (updatedCount > 0) { + editedIndicatorVisibilityController.refreshIndicators() } - resetDiagnosticsFlow() - - if ( - nextMode === 'react' && - !hasAppliedReactModeDefault && - fromActivePrContext !== true - ) { - hasAppliedReactModeDefault = true - setJsxSource(defaultReactJsx) - } - - maybeRender() + return updatedCount } -function applyStyleMode({ mode }) { - const nextMode = normalizeStyleMode(mode) - - if (styleMode.value !== nextMode) { - styleMode.value = nextMode - } - - resetDiagnosticsFlow() - - if (cssCodeEditor) { - suppressEditorChangeSideEffects = true - try { - cssCodeEditor.setLanguage(getStyleEditorLanguage(nextMode)) - } finally { - suppressEditorChangeSideEffects = false - } +const setWorkspacePrContextState = nextState => { + if (typeof nextState !== 'string' || !nextState.trim()) { + return } - - maybeRender() + workspacePrContextState = nextState.trim() + workspaceContextStatusController.render() } -renderMode.addEventListener('change', () => { - applyRenderMode({ mode: renderMode.value }) -}) -styleMode.addEventListener('change', () => { - applyStyleMode({ mode: styleMode.value }) -}) -shadowToggle.addEventListener('change', maybeRender) -autoRenderToggle.addEventListener('change', () => { - renderRuntime.clearPreview() - updateRenderButtonVisibility() - if (autoRenderToggle.checked) { - renderPreview() - } -}) -if (diagnosticsToggle) { - diagnosticsToggle.addEventListener('click', () => { - setDiagnosticsDrawerOpen(!getDiagnosticsDrawerOpen()) - }) +const setWorkspacePrNumber = nextValue => { + workspacePrNumber = toPullRequestNumber(nextValue) } -if (diagnosticsClose) { - diagnosticsClose.addEventListener('click', () => { - setDiagnosticsDrawerOpen(false) - }) -} -if (diagnosticsClearComponent) { - diagnosticsClearComponent.addEventListener('click', () => { - clearDiagnosticsScope('component') - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } - }) -} -if (diagnosticsClearStyles) { - diagnosticsClearStyles.addEventListener('click', () => { - clearDiagnosticsScope('styles') - clearStylesLintDiagnosticsState() - }) -} -if (diagnosticsClearAll) { - diagnosticsClearAll.addEventListener('click', () => { - clearAllDiagnostics() - typeDiagnostics.clearTypeDiagnosticsState() - clearComponentLintDiagnosticsState() - clearStylesLintDiagnosticsState() - if (statusNode.textContent.startsWith('Rendered (Type errors:')) { - setStatus('Rendered', 'neutral') - } - }) -} -if (typecheckButton) { - typecheckButton.addEventListener('click', () => { - typeDiagnostics.triggerTypeDiagnostics({ userInitiated: true }) - }) -} -if (lintComponentButton) { - lintComponentButton.addEventListener('click', () => { - void runComponentLint({ userInitiated: true }) - }) -} -if (lintStylesButton) { - lintStylesButton.addEventListener('click', () => { - void runStylesLint({ userInitiated: true }) - }) -} -renderButton.addEventListener('click', renderPreview) -if (clipboardSupported) { - copyComponentButton.addEventListener('click', () => { - void copyComponentSource() - }) - copyStylesButton.addEventListener('click', () => { - void copyStylesSource() - }) -} else { - copyComponentButton.hidden = true - copyStylesButton.hidden = true -} -if (clearConfirmDialog instanceof HTMLDialogElement) { - clearConfirmDialog.addEventListener('close', () => { - if (clearConfirmDialog.returnValue === 'confirm') { - pendingClearAction?.() - } - pendingClearAction = null +const persistWorkspacePrContextState = nextState => { + setWorkspacePrContextState(nextState) + queueWorkspaceSave({ preserveRecordId: true }) + void flushWorkspaceSave({ preserveRecordId: true }).catch(() => { + /* Save failures are already surfaced through saver onError. */ }) } -clearComponentButton.addEventListener('click', () => { - confirmClearSource({ - label: 'Component', - onConfirm: clearComponentSource, - }) +const workspacePrSessionHandoffController = createWorkspacePrSessionHandoffController({ + defaults: { + defaultComponentTabName, + defaultComponentTabPath, + defaultComponentTabContent: defaultJsx, + }, + state: { + getWorkspacePrNumber: () => workspacePrNumber, + setWorkspacePrContextState, + setWorkspacePrNumber, + setWorkspaceScopeMarker, + getActiveWorkspaceCreatedAt: () => activeWorkspaceCreatedAt, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + }, + ui: { + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + setStatus, + }, + workspace: { + workspaceStorage, + workspaceTabsState, + buildWorkspaceRecordSnapshot, + buildWorkspaceTabsSnapshot, + flushWorkspaceSave, + refreshLocalContextOptions, + renderWorkspaceTabs, + syncHeaderLabels, + loadWorkspaceTabIntoEditor, + getActiveWorkspaceTab, + }, + runtime: { + getRenderRuntime: () => renderRuntime, + getUpdateRenderModeEditability: () => updateRenderModeEditability, + }, + selectors: { + getCurrentSelectedRepositoryFullName, + }, + utils: { + toNonEmptyWorkspaceText, + createWorkspaceRecordId, + toWorkspaceRecordKey, + }, }) -clearStylesButton.addEventListener('click', () => { - confirmClearSource({ - label: 'Styles', - onConfirm: clearStylesSource, - }) +const onPrContextStateChange = createPrContextStateChangeHandler({ + toNonEmptyWorkspaceText, + toPullRequestNumber, + parsePullRequestNumberFromUrl, + getCurrentSelectedRepositoryFullName, + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + setWorkspaceRepositoryFullName, + getWorkspacePrContextState: () => workspacePrContextState, + getHasObservedActivePrContextInSession: () => hasObservedActivePrContextInSession, + setHasObservedActivePrContextInSession: value => + (hasObservedActivePrContextInSession = Boolean(value)), + githubPrStatus, + githubPrHeadBranch, + githubPrTitle, + workspacePrSessionHandoffController, + setWorkspacePrNumber, + persistWorkspacePrContextState, + editedIndicatorVisibilityController, }) -jsxEditor.addEventListener('input', maybeRender) -jsxEditor.addEventListener('input', markTypeDiagnosticsStale) -jsxEditor.addEventListener('input', markComponentLintDiagnosticsStale) -cssEditor.addEventListener('input', maybeRender) -cssEditor.addEventListener('input', markStylesLintDiagnosticsStale) - -for (const button of appGridLayoutButtons) { - button.addEventListener('click', () => { - const nextLayout = button.dataset.appGridLayout - if (!nextLayout) { - return - } - applyAppGridLayout(nextLayout) - applyPanelCollapseState() - - if (isStackedRailViewport()) { - setStackedRailViewControlsOpen(false) - } - }) -} - -for (const button of appThemeButtons) { - button.addEventListener('click', () => { - const nextTheme = button.dataset.appTheme - if (!nextTheme) { - return - } - applyTheme(nextTheme) - - if (isStackedRailViewport()) { - setStackedRailViewControlsOpen(false) - } - }) -} - -if (viewControlsToggle instanceof HTMLButtonElement) { - viewControlsToggle.addEventListener('click', () => { - if (!isStackedRailViewport()) { - return - } - - if (isCompactViewport()) { - setCompactAiControlsOpen(false) - } - setStackedRailViewControlsOpen(!stackedRailViewControlsOpen) - }) -} +const githubChatWorkspaceActions = createGitHubChatWorkspaceActions({ + getActiveWorkspaceTab, + isStyleWorkspaceTab, + getCssSource: () => getCssSource(), + getJsxSource: () => getJsxSource(), + workspaceTabsState, + getDirtyStateForTabChange, + loadWorkspaceTabIntoEditor, + renderWorkspaceTabs, + queueWorkspaceSave: () => queueWorkspaceSave(), +}) -if (aiControlsToggle instanceof HTMLButtonElement) { - aiControlsToggle.addEventListener('click', () => { - if (!isCompactViewport()) { - return - } +const githubWorkflows = createGitHubWorkflowsSetup({ + factories: { + createGitHubPrEditorSyncController, + createGitHubChatDrawer, + createGitHubPrDrawer, + createWorkspacesDrawer, + }, + platform: { + ensureJsxTransformSource, + collectTopLevelDeclarations, + cdnImports, + importFromCdnWithFallback, + }, + state: { + githubAiContextState, + }, + byot: { + byotControls, + getCurrentGitHubToken, + getCurrentSelectedRepository, + setCurrentSelectedRepository: fullName => + byotControls.setSelectedRepository(fullName), + clearCurrentSelectedRepository: () => + byotControls.clearSelectedRepositoryPreference(), + }, + ui: { + aiChatToggle, + aiChatDrawer, + aiChatClose, + aiChatPrompt, + aiChatModel, + aiChatIncludeEditors, + aiChatSend, + aiChatClear, + aiChatStatus, + aiChatRepository, + aiChatMessages, + githubPrToggle, + githubPrDrawer, + githubPrClose, + githubPrRepoSelect, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + githubPrBody, + githubPrCommitMessage, + githubPrIncludeAppWrapper, + githubPrSubmit, + openPrTitle, + githubPrStatus, + workspacesToggle, + workspacesDrawer, + workspacesClose, + workspacesStatus, + workspacesRepository, + workspacesInitialize, + workspacesNew, + workspacesSelect, + workspacesOpen, + workspacesRemove, + }, + workspace: { + workspaceStorage, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + setActiveWorkspaceRecordId, + setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), + buildWorkspaceRecordSnapshot, + listLocalContextRecords, + refreshLocalContextOptions, + applyWorkspaceRecord, + syncActiveWorkspaceRepositoryScope, + forkWorkspaceFromCurrentState, + flushWorkspaceSave, + getWorkspacePrFileCommits, + getEditorSyncTargets, + reconcileWorkspaceTabsWithPushUpdates, + }, + runtime: { + getRenderMode: () => renderMode.value, + getStyleMode: () => styleMode.value, + getActivePrContextSyncKey, + prContextUi, + onPrContextStateChange, + onPrContextVerifiedClosed: result => { + hasObservedActivePrContextInSession = false + const nextPrNumber = + toPullRequestNumber(result?.pullRequestNumber) ?? + parsePullRequestNumberFromUrl(result?.pullRequestUrl) + if (nextPrNumber !== null) { + setWorkspacePrNumber(nextPrNumber) + } + setWorkspacePrContextState('closed') + + const persistClosedRecords = async () => { + const activeWorkspaceId = toNonEmptyWorkspaceText(activeWorkspaceRecordId) + const activeWorkspaceRecord = activeWorkspaceId + ? await workspaceStorage.getWorkspaceById(activeWorkspaceId) + : null + const preservedPrTitle = + toNonEmptyWorkspaceText(activeWorkspaceRecord?.prTitle) || + toNonEmptyWorkspaceText(githubPrTitle?.value) + + await persistClosedPrContextRecords({ + workspaceStorage, + selectedRepository: toNonEmptyWorkspaceText( + getCurrentSelectedRepositoryFullName(), + ), + nextPrNumber, + normalizedHead: toNonEmptyWorkspaceText(githubPrHeadBranch?.value), + fallbackPrTitle: preservedPrTitle, + toNonEmptyWorkspaceText, + refreshLocalContextOptions, + }) + } - setStackedRailViewControlsOpen(false) - setCompactAiControlsOpen(!compactAiControlsOpen) - }) -} + void persistClosedRecords().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + }, + onPrContextClosed: result => { + hasObservedActivePrContextInSession = false + const nextPrNumber = + toPullRequestNumber(result?.pullRequestNumber) ?? + parsePullRequestNumberFromUrl(result?.pullRequestUrl) + if (nextPrNumber !== null) { + setWorkspacePrNumber(nextPrNumber) + } + setWorkspacePrContextState('closed') + + const persistClosedRecords = async () => { + const activeWorkspaceId = toNonEmptyWorkspaceText(activeWorkspaceRecordId) + const activeWorkspaceRecord = activeWorkspaceId + ? await workspaceStorage.getWorkspaceById(activeWorkspaceId) + : null + const preservedPrTitle = + toNonEmptyWorkspaceText(result?.prTitle) || + toNonEmptyWorkspaceText(activeWorkspaceRecord?.prTitle) || + toNonEmptyWorkspaceText(githubPrTitle?.value) + + await persistClosedPrContextRecords({ + workspaceStorage, + selectedRepository: toNonEmptyWorkspaceText( + getCurrentSelectedRepositoryFullName(), + ), + nextPrNumber, + normalizedHead: toNonEmptyWorkspaceText(githubPrHeadBranch?.value), + fallbackPrTitle: preservedPrTitle, + toNonEmptyWorkspaceText, + refreshLocalContextOptions, + }) + } -if (githubTokenInfo instanceof HTMLButtonElement && githubTokenInfoPanel) { - githubTokenInfo.addEventListener('click', event => { - event.preventDefault() - setGitHubTokenInfoOpen(!githubTokenInfoOpen) - }) -} + void persistClosedRecords().catch(() => { + /* Save failures are already surfaced through saver onError. */ + }) + }, + getPersistedActivePrContext, + getTokenForVisibility: () => githubAiContextState.token, + getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, + syncFromActiveContext: ({ tabTargets }) => { + const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId() + reconcileWorkspaceTabsWithEditorSync({ tabTargets }) + const activeTabAfterSync = + workspaceTabsState.getTab(activeTabIdBeforeSync) ?? getActiveWorkspaceTab() + if (activeTabAfterSync) { + loadWorkspaceTabIntoEditor(activeTabAfterSync) + } + }, + formatActivePrReference, + githubPrContextClose, + }, + actions: { + applyRenderMode: options => runtimeCore?.applyRenderMode(options), + applyStyleMode: options => runtimeCore?.applyStyleMode(options), + confirmAction: options => confirmAction(options), + setStatus, + showAppToast, + ...githubChatWorkspaceActions, + scheduleRender: () => { + if ( + autoRenderToggle?.checked && + typeof renderRuntime?.scheduleRender === 'function' + ) { + renderRuntime.scheduleRender() + } + }, + }, +}) -document.addEventListener('click', event => { - const clickTarget = event.target - if (!(clickTarget instanceof Node)) { - return - } +chatDrawerController = githubWorkflows.chatDrawerController +prDrawerController = githubWorkflows.prDrawerController +workspacesDrawerController = githubWorkflows.workspacesDrawerController - if (isStackedRailViewport() && stackedRailViewControlsOpen) { - if ( - !viewControlsDrawer?.contains(clickTarget) && - !viewControlsToggle?.contains(clickTarget) - ) { - setStackedRailViewControlsOpen(false) - } - } +const getInitialRenderMode = () => 'dom' - if (isCompactViewport() && compactAiControlsOpen) { - if ( - !githubAiControls.contains(clickTarget) && - !aiControlsToggle?.contains(clickTarget) - ) { - setCompactAiControlsOpen(false) - } - } +const updateRenderModeEditability = () => + updateRenderModeEditabilityValue({ renderMode, getActiveWorkspaceTab }) - if (githubTokenInfoOpen) { - if ( - !githubTokenInfo?.contains(clickTarget) && - !githubTokenInfoPanel?.contains(clickTarget) - ) { - setGitHubTokenInfoOpen(false) - } - } +const editorBootstrapOptions = createEditorBootstrapOptions({ + createCodeMirrorEditor, + jsxEditor, + cssEditor, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + getStyleEditorLanguage, + styleMode, + getSuppressEditorChangeSideEffects: () => suppressEditorChangeSideEffects, + getActiveWorkspaceTab, + isStyleWorkspaceTab, + getDirtyStateForTabChange, + workspaceTabsState, + toWorkspaceSyncedContent, + renderWorkspaceTabs, + queueWorkspaceSave, + maybeRenderFromComponentEditorChange: () => maybeRenderFromComponentEditorChange(), + markTypeDiagnosticsStale: () => markTypeDiagnosticsStale(), + markComponentLintDiagnosticsStale: () => markComponentLintDiagnosticsStale(), + maybeRender: () => maybeRender(), + markStylesLintDiagnosticsStale: () => markStylesLintDiagnosticsStale(), + flushWorkspaceSave, + setJsxCodeEditor: value => (jsxCodeEditor = value), + setCssCodeEditor: value => (cssCodeEditor = value), + setGetJsxSource: value => (getJsxSource = value), + setGetCssSource: value => (getCssSource = value), + editorPool, + componentEditorPanel, + stylesEditorPanel, + loadWorkspaceTabIntoEditor, + setStatus, }) +const editorBootstrapController = createEditorBootstrapController(editorBootstrapOptions) -document.addEventListener('keydown', event => { - if (event.key !== 'Escape') { - return - } +const initializeCodeEditors = async () => + editorBootstrapController.initializeCodeEditors() - setStackedRailViewControlsOpen(false) - setCompactAiControlsOpen(false) - setGitHubTokenInfoOpen(false) +const runtimeCoreOptions = createRuntimeCoreOptions({ + createDiagnosticsFlowController, + createRenderRuntimeController, + createTypeDiagnosticsController, + createLintDiagnosticsController, + cdnImports, + importFromCdnWithFallback, + getTypeScriptLibUrls, + getTypePackageFileUrls, + getJsxSource: () => getJsxSource(), + getCssSource: () => getCssSource(), + getTypecheckSourcePath, + getComponentLintTarget, + getStylesLintTarget, + buildWorkspaceTabsSnapshot, + renderMode, + styleMode, + setTypeDiagnosticsDetails, + setTypeDiagnosticsPending, + setStyleDiagnosticsDetails, + setLintDiagnosticsPending, + setStatus, + statusNode, + incrementTypeDiagnosticsRuns, + decrementTypeDiagnosticsRuns, + getActiveTypeDiagnosticsRuns, + incrementLintDiagnosticsRuns, + decrementLintDiagnosticsRuns, + setDiagnosticsDrawerOpen, + clearAllDiagnostics, + lintComponentButton, + lintStylesButton, + autoRenderToggle, + getActiveWorkspaceTab, + isStyleWorkspaceTab, + getRenderRuntime: () => renderRuntime, + getPreviewHost: () => previewHost, + previewBackground, + clearDiagnosticsScope, + clearConfirmDialog, + clearConfirmTitle, + clearConfirmCopy, + clearConfirmButton, + setPendingClearAction: value => (pendingClearAction = value), + normalizeRenderMode, + normalizeStyleMode, + resetDiagnosticsFlow: () => diagnosticsFlowController.resetDiagnosticsFlow(), + maybeRender: () => diagnosticsFlowController.maybeRender(), + flushWorkspaceSave, + getCssCodeEditor: () => cssCodeEditor, + setSuppressEditorChangeSideEffects: value => (suppressEditorChangeSideEffects = value), + getStyleEditorLanguage, + workspaceTabsState, + queueWorkspaceSave, + getRenderModeCompatibilityError: mode => + normalizeRenderMode(mode) === 'react' + ? getReactEntryTabCompatibilityError(getEntryWorkspaceTab()) + : null, }) +runtimeCore = createRuntimeCoreSetup(runtimeCoreOptions) + +diagnosticsFlowController = runtimeCore.diagnosticsFlowController +renderRuntime = runtimeCore.renderRuntime +const setCdnLoading = runtimeCore.setCdnLoading +const typeDiagnostics = diagnosticsFlowController.typeDiagnostics +const runComponentLint = options => diagnosticsFlowController.runComponentLint(options) +const runStylesLint = options => diagnosticsFlowController.runStylesLint(options) +const markTypeDiagnosticsStale = () => + diagnosticsFlowController.markTypeDiagnosticsStale() +const markComponentLintDiagnosticsStale = () => + diagnosticsFlowController.markComponentLintDiagnosticsStale() +const markStylesLintDiagnosticsStale = () => + diagnosticsFlowController.markStylesLintDiagnosticsStale() +const clearComponentLintDiagnosticsState = () => + diagnosticsFlowController.clearComponentLintDiagnosticsState() +const clearStylesLintDiagnosticsState = () => + diagnosticsFlowController.clearStylesLintDiagnosticsState() +const renderPreview = async () => diagnosticsFlowController.renderPreview() +const maybeRender = () => diagnosticsFlowController.maybeRender() +const maybeRenderFromComponentEditorChange = () => + diagnosticsFlowController.maybeRenderFromComponentEditorChange() + +const confirmAction = options => runtimeCore.confirmAction(options) + +bindAppEventsAndStart({ + editorUi: { + renderMode, + styleMode, + autoRenderToggle, + renderButton, + typecheckButton, + lintComponentButton, + lintStylesButton, + copyComponentButton, + copyStylesButton, + clearConfirmDialog, + clearComponentButton, + clearStylesButton, + jsxEditor, + cssEditor, + }, + diagnosticsUi: { + diagnosticsToggle, + diagnosticsClose, + diagnosticsClearComponent, + diagnosticsClearStyles, + diagnosticsClearAll, + statusNode, + }, + sourceActions: { + applyRenderMode: options => runtimeCore.applyRenderMode(options), + applyStyleMode: options => runtimeCore.applyStyleMode(options), + updateRenderButtonVisibility: () => (renderButton.hidden = autoRenderToggle.checked), + clearDiagnosticsScope, + clearComponentLintDiagnosticsState, + clearStylesLintDiagnosticsState, + clearAllDiagnostics, + setStatus, + getJsxSource, + getCssSource, + getTypecheckSourcePath, + runComponentLint, + runStylesLint, + renderPreview, + setJsxSource, + setCssSource, + persistActiveTabEditorContent, + getWorkspaceTabsSnapshot: () => workspaceTabsState.getTabs(), + queueWorkspaceSave, + maybeRender, + maybeRenderFromComponentEditorChange, + markTypeDiagnosticsStale, + markComponentLintDiagnosticsStale, + markStylesLintDiagnosticsStale, + flushWorkspaceSave, + confirmAction, + getPendingClearAction: () => pendingClearAction, + setPendingClearAction: value => (pendingClearAction = value), + getDiagnosticsDrawerOpen, + setDiagnosticsDrawerOpen, + setTypeDiagnosticsDetails, + setCdnLoading, + }, + workspaceUi: { + githubPrRepoSelect, + githubPrBaseBranch, + githubPrHeadBranch, + githubPrTitle, + workspaceTabAddMenuUi, + workspaceTabAddButton, + workspaceTabAddModule, + workspaceTabAddStyles, + addWorkspaceTab, + syncHeaderLabels, + renderWorkspaceTabs, + updateRenderModeEditability, + loadPreferredWorkspaceContext, + getActiveWorkspaceTab, + isStyleWorkspaceTab, + setActiveWorkspaceTab, + workspaceTabsState, + getPrimaryStyleWorkspaceTab, + syncDiagnosticsDrawerLayout, + workspaceSaveController, + workspaceStorage, + bindWorkspaceMetadataPersistence, + setHasCompletedInitialWorkspaceBootstrap: value => + (hasCompletedInitialWorkspaceBootstrap = value), + }, + themeUi: { + appThemeButtons, + applyTheme, + getInitialTheme, + getInitialRenderMode, + }, + githubUi: { + aiControlsToggle, + compactAiControlsUi, + githubTokenInfo, + githubTokenInfoPanel, + githubTokenInfoUi, + prContextUi, + githubAiContextState, + }, + panelUi: { + editorToolsButtons, + panelToolsState, + applyEditorToolsVisibility, + panelCollapseButtons, + togglePanelCollapse, + applyPanelCollapseState, + }, + lifecycle: { + clearToastTimer: () => { + if (!appToastDismissTimer) { + return + } -for (const button of editorToolsButtons) { - button.addEventListener('click', () => { - const panelName = button.dataset.editorToolsToggle - if (!panelName || !Object.hasOwn(panelToolsState, panelName)) { - return - } - - panelToolsState[panelName] = !panelToolsState[panelName] - applyEditorToolsVisibility() - }) -} - -for (const button of panelCollapseButtons) { - button.addEventListener('click', () => { - const panelName = button.dataset.panelCollapse - if (!panelName) { - return - } - - togglePanelCollapse(panelName) - }) -} - -const handleCompactViewportChange = () => { - applyPanelCollapseState() - setCompactAiControlsOpen(false) -} - -const handleStackedRailViewportChange = () => { - setStackedRailViewControlsOpen(false) -} - -if (typeof compactViewportMediaQuery.addEventListener === 'function') { - compactViewportMediaQuery.addEventListener('change', handleCompactViewportChange) -} else { - compactViewportMediaQuery.onchange = handleCompactViewportChange -} - -if (typeof stackedRailMediaQuery.addEventListener === 'function') { - stackedRailMediaQuery.addEventListener('change', handleStackedRailViewportChange) -} else { - stackedRailMediaQuery.onchange = handleStackedRailViewportChange -} - -window.addEventListener('beforeunload', () => { - if (appToastDismissTimer) { - clearTimeout(appToastDismissTimer) - appToastDismissTimer = null - } - clearComponentLintRecheckTimer() - clearStylesLintRecheckTimer() - lintDiagnostics.dispose() - chatDrawerController.dispose() - prDrawerController.dispose() + clearTimeout(appToastDismissTimer) + appToastDismissTimer = null + }, + diagnosticsFlowController, + chatDrawerController, + prDrawerController, + }, + startup: { + renderRuntime, + typeDiagnostics, + clipboardSupported, + previewBackground, + initializeCodeEditors, + }, }) - -applyAppGridLayout(getInitialAppGridLayout(), { persist: false }) -applyTheme(getInitialTheme(), { persist: false }) -applyEditorToolsVisibility() -applyPanelCollapseState() -setStackedRailViewControlsOpen(false) -setCompactAiControlsOpen(false) -setGitHubTokenInfoOpen(false) -syncAiChatTokenVisibility(githubAiContextState.token) - -updateRenderButtonVisibility() -renderDiagnosticsScope('component') -renderDiagnosticsScope('styles') -updateDiagnosticsToggleLabel() -updateUiIssueIndicators() -setDiagnosticsDrawerOpen(false) -setTypeDiagnosticsDetails({ headline: '' }) -renderRuntime.setStyleCompiling(false) -setCdnLoading(true) -initializePreviewBackgroundPicker() -void initializeCodeEditors() -renderPreview() diff --git a/src/bootstrap.js b/src/bootstrap.js index 0f7e3ae..221e644 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -13,6 +13,7 @@ const preloadImportKeys = [ 'jsxReact', 'react', 'reactDomClient', + 'idb', ] const isImportMapPrimary = diff --git a/src/index.html b/src/index.html index e4cd022..3f95a62 100644 --- a/src/index.html +++ b/src/index.html @@ -31,8 +31,8 @@

    Idle
    -
    -
    +
    +
    + + - -
    - - -
    - - -
    -
    + +
    + + + +
    +
    +
    +
    +

    + Component + + +

    +
    + - - +
    +
    +
    + + + + + +
    -
    -
    - -
    - +
    + +
    + -
    -
    -

    - Styles - -

    -
    - - - - -
    -
    -
    +

    +
    - + + +
    +
    +
    + + +
    -
    -
    - -
    - +
    + +
    + +
    Preview Background - - -
    -
    -
    +
    + +