From cc7bf834bdb663cd245e4d0e7255d52a487ed1df Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 27 Mar 2026 21:20:38 -0500 Subject: [PATCH 1/2] test: refactor to modules and accessible locators. --- AGENTS.md | 4 + playwright/app.spec.ts | 1957 ------------------------ playwright/diagnostics.spec.ts | 442 ++++++ playwright/github-byot-ai.spec.ts | 400 +++++ playwright/github-pr-drawer.spec.ts | 453 ++++++ playwright/helpers/app-test-helpers.ts | 246 +++ playwright/layout-panels.spec.ts | 240 +++ playwright/rendering-modes.spec.ts | 248 +++ src/app.js | 8 + src/index.html | 55 +- src/modules/editor-codemirror.js | 4 + src/modules/render-runtime.js | 2 + 12 files changed, 2091 insertions(+), 1968 deletions(-) delete mode 100644 playwright/app.spec.ts create mode 100644 playwright/diagnostics.spec.ts create mode 100644 playwright/github-byot-ai.spec.ts create mode 100644 playwright/github-pr-drawer.spec.ts create mode 100644 playwright/helpers/app-test-helpers.ts create mode 100644 playwright/layout-panels.spec.ts create mode 100644 playwright/rendering-modes.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 65ec35d..95dbc99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,10 @@ Repository structure: - Do not introduce bundler-only assumptions into src/ runtime code. - Prefer async/await over promise chains. - 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). +- 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/playwright/app.spec.ts b/playwright/app.spec.ts deleted file mode 100644 index 7b4dd1c..0000000 --- a/playwright/app.spec.ts +++ /dev/null @@ -1,1957 +0,0 @@ -import { expect, test } from '@playwright/test' -import type { Page } from '@playwright/test' -import { defaultGitHubChatModel } from '../src/modules/github-api.js' - -const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' -const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html' - -type ChatRequestMessage = { - role?: string - content?: string -} - -type ChatRequestBody = { - metadata?: unknown - messages?: ChatRequestMessage[] - model?: string - stream?: boolean -} - -type CreateRefRequestBody = { - ref?: string - sha?: string -} - -type PullRequestCreateBody = { - head?: string - base?: string -} - -type BranchesByRepo = Record - -const waitForAppReady = async (page: Page, path = appEntryPath) => { - await page.goto(path) - await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() - await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '') - await expect.poll(() => page.locator('#status').textContent()).not.toBe('Idle') -} - -const waitForInitialRender = async (page: Page) => { - await waitForAppReady(page) - await expect(page.locator('#status')).toHaveText('Rendered') -} - -const expectPreviewHasRenderedContent = async (page: Page) => { - const previewHost = page.locator('#preview-host') - await expect(previewHost.locator('pre')).toHaveCount(0) - await expect - .poll(() => previewHost.evaluate(node => node.childElementCount)) - .toBeGreaterThan(0) -} - -const setComponentEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.component-panel .cm-content').first() - await editorContent.fill(source) -} - -const setStylesEditorSource = async (page: Page, source: string) => { - const editorContent = page.locator('.styles-panel .cm-content').first() - await editorContent.fill(source) -} - -const getActiveComponentEditorLineNumber = async (page: Page) => { - return page - .locator('#component-panel .cm-activeLineGutter') - .first() - .innerText() - .then(text => text.trim()) -} - -const runTypecheck = async (page: Page) => { - await ensurePanelToolsVisible(page, 'component') - await page.locator('#typecheck-button').click() -} - -const runComponentLint = async (page: Page) => { - await ensurePanelToolsVisible(page, 'component') - await page.locator('#lint-component-button').click() -} - -const runStylesLint = async (page: Page) => { - await ensurePanelToolsVisible(page, 'styles') - await page.locator('#lint-styles-button').click() -} - -const getActiveStylesEditorLineNumber = async (page: Page) => { - return page - .locator('#styles-panel .cm-activeLineGutter') - .first() - .innerText() - .then(text => text.trim()) -} - -const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') => - page.locator(`#collapse-${panelName}`) - -const getToolsButton = (page: Page, panelName: 'component' | 'styles') => - page.locator(`#tools-${panelName}`) - -const ensurePanelToolsVisible = async (page: Page, panelName: 'component' | 'styles') => { - const button = getToolsButton(page, panelName) - const isPressed = await button.getAttribute('aria-pressed') - if (isPressed !== 'true') { - await button.click() - } -} - -const ensureDiagnosticsDrawerOpen = async (page: Page) => { - const toggle = page.locator('#diagnostics-toggle') - const isExpanded = await toggle.getAttribute('aria-expanded') - - if (isExpanded !== 'true') { - await toggle.click() - } - - await expect(page.locator('#diagnostics-drawer')).toBeVisible() -} - -const ensureDiagnosticsDrawerClosed = async (page: Page) => { - const toggle = page.locator('#diagnostics-toggle') - const isExpanded = await toggle.getAttribute('aria-expanded') - - if (isExpanded === 'true') { - await page.locator('#diagnostics-close').click() - } - - await expect(page.locator('#diagnostics-drawer')).toBeHidden() -} - -const ensureAiChatDrawerOpen = async (page: Page) => { - const toggle = page.locator('#ai-chat-toggle') - const isExpanded = await toggle.getAttribute('aria-expanded') - - if (isExpanded !== 'true') { - await toggle.click() - } - - await expect(page.locator('#ai-chat-drawer')).toBeVisible() -} - -const ensureOpenPrDrawerOpen = async (page: Page) => { - const toggle = page.locator('#github-pr-toggle') - await expect(toggle).toBeEnabled({ timeout: 60_000 }) - const isExpanded = await toggle.getAttribute('aria-expanded') - - if (isExpanded !== 'true') { - await toggle.click() - } - - await expect(page.locator('#github-pr-drawer')).toBeVisible() -} - -const mockRepositoryBranches = async ( - page: Page, - branchesByRepo: BranchesByRepo = {}, -) => { - await page.route('https://api.github.com/repos/**/branches**', async route => { - const url = new URL(route.request().url()) - const match = url.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/branches$/) - const repositoryKey = match ? `${match[1]}/${match[2]}` : '' - - const branchNames = - branchesByRepo[repositoryKey] && branchesByRepo[repositoryKey].length > 0 - ? branchesByRepo[repositoryKey] - : ['main'] - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(branchNames.map(name => ({ name }))), - }) - }) -} - -const connectByotWithSingleRepo = async (page: 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'], - }) - - await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890') - await page.locator('#github-token-add').click() - await expect(page.locator('#status')).toHaveText('Loaded 1 writable repositories') - await expect(page.locator('#github-pr-toggle')).toBeVisible() -} - -const expectCollapseButtonState = async ( - page: Page, - panelName: 'component' | 'styles' | 'preview', - { - axis, - direction, - collapsed, - disabled, - }: { - axis: 'vertical' | 'horizontal' - direction: 'left' | 'right' | 'none' - collapsed: boolean - disabled?: boolean - }, -) => { - const button = getCollapseButton(page, panelName) - - await expect(button).toHaveAttribute('data-collapse-axis', axis) - await expect(button).toHaveAttribute('data-collapse-direction', direction) - await expect(button).toHaveAttribute('data-collapsed', collapsed ? 'true' : 'false') - - if (disabled !== undefined) { - if (disabled) { - await expect(button).toBeDisabled() - } else { - await expect(button).toBeEnabled() - } - } -} - -test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => { - await waitForAppReady(page) - - const byotControls = page.locator('#github-ai-controls') - await expect(byotControls).toHaveAttribute('hidden', '') - await expect(byotControls).toBeHidden() - await expect(page.locator('#ai-chat-toggle')).toBeHidden() - await expect(page.locator('#ai-chat-drawer')).toBeHidden() - await expect(page.locator('#github-pr-toggle')).toBeHidden() - await expect(page.locator('#github-pr-drawer')).toBeHidden() -}) - -test('BYOT controls render when feature flag is enabled by query param', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - - const byotControls = page.locator('#github-ai-controls') - await expect(byotControls).toBeVisible() - await expect(page.locator('#github-token-input')).toBeVisible() - await expect(page.locator('#github-token-add')).toBeVisible() - await expect(page.locator('#github-ai-controls #ai-chat-toggle')).toBeHidden() - await expect(page.locator('#github-ai-controls #github-pr-toggle')).toBeHidden() -}) - -test('GitHub token info panel reflects missing and present token states', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - - const infoButtonMissing = page.getByRole('button', { - name: 'About GitHub token features and privacy', - }) - const infoButtonPresent = page.getByRole('button', { - name: 'About GitHub token privacy', - }) - const missingMessage = page.getByText('Provide a GitHub PAT', { exact: false }) - const presentMessage = page.getByText( - 'This token is stored only in your browser and is sent only to GitHub APIs you invoke. Use the trash icon to remove it from storage.', - ) - - await expect(infoButtonMissing).toHaveAttribute('data-token-state', 'missing') - await expect(infoButtonMissing).toHaveAttribute( - 'aria-label', - 'About GitHub token features and privacy', - ) - await expect(presentMessage).toBeHidden() - - await infoButtonMissing.click() - await expect(missingMessage).toBeVisible() - await expect(missingMessage).toContainText('Provide a GitHub PAT') - await expect(page.getByRole('link', { name: 'docs' })).toHaveAttribute( - 'href', - 'https://github.com/knightedcodemonkey/develop/blob/main/docs/byot.md', - ) - await expect(presentMessage).toBeHidden() - - await connectByotWithSingleRepo(page) - await expect(infoButtonPresent).toHaveAttribute('data-token-state', 'present') - await expect(infoButtonPresent).toHaveAttribute( - 'aria-label', - 'About GitHub token privacy', - ) - - await infoButtonPresent.click() - await expect(presentMessage).toBeVisible() - await expect(presentMessage).toContainText( - 'Use the trash icon to remove it from storage.', - ) - await expect(missingMessage).toBeHidden() -}) - -test('deleting saved GitHub token requires confirmation modal', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - - const dialog = page.locator('#clear-confirm-dialog') - const tokenDelete = page.locator('#github-token-delete') - const tokenAdd = page.locator('#github-token-add') - const tokenInput = page.locator('#github-token-input') - - await expect(tokenDelete).toBeVisible() - - await tokenDelete.click() - await expect(dialog).toHaveAttribute('open', '') - await expect(page.locator('#clear-confirm-title')).toHaveText( - 'Remove saved GitHub token?', - ) - await expect(page.locator('#clear-confirm-copy')).toHaveText( - 'This action removes the token from browser storage. You can add another token at any time.', - ) - const removeButton = dialog.getByRole('button', { name: 'Remove' }) - await expect(removeButton).toBeVisible() - await expect(removeButton).not.toHaveAttribute('aria-label') - - await dialog.getByRole('button', { name: 'Cancel' }).click() - await expect(dialog).not.toHaveAttribute('open', '') - await expect(tokenDelete).toBeVisible() - await expect(tokenAdd).toBeHidden() - - await tokenDelete.click() - await expect(dialog).toHaveAttribute('open', '') - await removeButton.click() - await expect(dialog).not.toHaveAttribute('open', '') - - await expect(page.locator('#status')).toHaveText('GitHub token removed') - await expect(tokenAdd).toBeVisible() - await expect(tokenDelete).toBeHidden() - await expect(tokenInput).toHaveValue('') -}) - -test('AI chat drawer opens and closes when feature flag is enabled', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - - const chatToggle = page.locator('#ai-chat-toggle') - const chatDrawer = page.locator('#ai-chat-drawer') - - await expect(chatToggle).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') - - await chatToggle.click() - await expect(chatDrawer).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') - - await page.locator('#ai-chat-close').click() - await expect(chatDrawer).toBeHidden() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') -}) - -test('AI chat prefers streaming responses when available', 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":"Streaming "}}]}', - '', - 'data: {"choices":[{"delta":{"content":"response ready"}}]}', - '', - 'data: [DONE]', - '', - ].join('\n'), - }) - }) - - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) - - await page.locator('#ai-chat-prompt').fill('Summarize this repository.') - await page.locator('#ai-chat-send').click() - - await expect(page.locator('#ai-chat-status')).toHaveText( - 'Response streamed from GitHub.', - ) - await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable') - await expect(page.locator('#ai-chat-messages')).toContainText( - 'Summarize this repository.', - ) - await expect(page.locator('#ai-chat-messages')).toContainText( - 'Streaming response ready', - ) - - expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.model).toBe(defaultGitHubChatModel) - const systemMessage = streamRequestBody?.messages?.find( - (message: ChatRequestMessage) => message.role === 'system', - ) - const systemMessages = streamRequestBody?.messages?.filter( - (message: ChatRequestMessage) => message.role === 'system', - ) - expect(systemMessage?.content).toContain('Selected repository context') - expect(systemMessage?.content).toContain('Repository: knightedcodemonkey/develop') - expect(systemMessage?.content).toContain( - 'Repository URL: https://github.com/knightedcodemonkey/develop', - ) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Editor context:'), - ), - ).toBe(true) -}) - -test('AI chat can disable editor context payload via checkbox', 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}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) - - const includeEditorsToggle = page.locator('#ai-chat-include-editors') - await expect(includeEditorsToggle).toBeChecked() - await includeEditorsToggle.uncheck() - - await page.locator('#ai-chat-prompt').fill('No editor source this time.') - await page.locator('#ai-chat-send').click() - await expect(page.locator('#ai-chat-status')).toHaveText( - 'Response streamed from GitHub.', - ) - await expect(page.locator('#ai-chat-rate')).toHaveText('Rate limit info unavailable') - - expect(streamRequestBody?.metadata).toBeUndefined() - const systemMessages = streamRequestBody?.messages?.filter( - (message: ChatRequestMessage) => message.role === 'system', - ) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Selected repository context'), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes( - 'Repository URL: https://github.com/knightedcodemonkey/develop', - ), - ), - ).toBe(true) - expect( - systemMessages?.some((message: ChatRequestMessage) => - message.content?.includes('Editor context:'), - ), - ).toBe(false) -}) - -test('AI chat falls back to non-streaming response when streaming fails', async ({ - page, -}) => { - let streamAttemptCount = 0 - let fallbackAttemptCount = 0 - const attemptedModels: string[] = [] - - await page.route('https://models.github.ai/inference/chat/completions', async route => { - const body = route.request().postDataJSON() as ChatRequestBody | null - if (typeof body?.model === 'string') { - attemptedModels.push(body.model) - } - - if (body?.stream) { - streamAttemptCount += 1 - await route.fulfill({ - status: 502, - contentType: 'application/json', - body: JSON.stringify({ message: 'stream failed' }), - }) - return - } - - fallbackAttemptCount += 1 - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - rate_limit: { - remaining: 17, - reset: 1704067200, - }, - choices: [ - { - message: { - role: 'assistant', - content: 'Fallback response from JSON path.', - }, - }, - ], - }), - }) - }) - - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) - - const selectedModel = 'openai/gpt-5-mini' - await page.locator('#ai-chat-model').selectOption(selectedModel) - await expect(page.locator('#ai-chat-model')).toHaveValue(selectedModel) - - await page.locator('#ai-chat-prompt').fill('Use fallback path.') - await page.locator('#ai-chat-send').click() - - await expect(page.locator('#ai-chat-status')).toHaveText('Fallback response loaded.') - await expect(page.locator('#ai-chat-rate')).toHaveText('Remaining 17, resets 00:00 UTC') - await expect(page.locator('#ai-chat-messages')).toContainText( - 'Fallback response from JSON path.', - ) - expect(streamAttemptCount).toBeGreaterThan(0) - expect(fallbackAttemptCount).toBeGreaterThan(0) - expect(attemptedModels.length).toBeGreaterThan(0) - expect(attemptedModels.every(model => model === selectedModel)).toBe(true) -}) - -test('BYOT remembers selected repository across reloads', async ({ page }) => { - test.setTimeout(90_000) - - 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: 'main', - permissions: { push: true }, - }, - ]), - }) - }) - - await mockRepositoryBranches(page, { - 'knightedcodemonkey/develop': ['main', 'release'], - 'knightedcodemonkey/css': ['main', 'release/1.x'], - }) - - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - - await page.locator('#github-token-input').fill('github_pat_fake_1234567890') - await page.locator('#github-token-add').click() - - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.locator('#github-pr-repo-select') - await expect(repoSelect).toBeEnabled() - await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories') - - await repoSelect.selectOption('knightedcodemonkey/develop') - await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') - - await page.reload() - await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() - await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories', { - timeout: 60_000, - }) - await expect(page.locator('#github-token-add')).toBeHidden() - await expect(page.locator('#github-token-delete')).toBeVisible() - await ensureOpenPrDrawerOpen(page) - await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') -}) - -test('Open PR drawer confirms and submits component/styles filepaths', async ({ - page, -}) => { - 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}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.locator('#github-pr-head-branch').fill('Develop/Open-Pr-Test') - await page.locator('#github-pr-component-path').fill('examples/component/App.tsx') - await page.locator('#github-pr-styles-path').fill('examples/styles/app.css') - await page.locator('#github-pr-title').fill('Apply editor updates from develop') - await page - .locator('#github-pr-body') - .fill('Generated from editor content in @knighted/develop.') - - await page.locator('#github-pr-submit').click() - - const dialog = page.locator('#clear-confirm-dialog') - await expect(dialog).toHaveAttribute('open', '') - await expect(page.locator('#clear-confirm-title')).toHaveText( - 'Open pull request with editor content?', - ) - await expect(page.locator('#clear-confirm-copy')).toContainText( - 'Component file path: examples/component/App.tsx', - ) - await expect(page.locator('#clear-confirm-copy')).toContainText( - 'Styles file path: examples/styles/app.css', - ) - - await dialog.getByRole('button', { name: 'Open PR' }).click() - - await expect(page.locator('#github-pr-status')).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(pullRequestPayload?.head).toBe('Develop/Open-Pr-Test') - expect(pullRequestPayload?.base).toBe('main') - - await ensureOpenPrDrawerOpen(page) - await expect(page.locator('#github-pr-component-path')).toHaveValue( - 'examples/component/App.tsx', - ) - await expect(page.locator('#github-pr-styles-path')).toHaveValue( - 'examples/styles/app.css', - ) - await expect(page.locator('#github-pr-base-branch')).toHaveValue('main') - - await expect(page.locator('#github-pr-head-branch')).toHaveValue( - /^develop\/develop\/editor-sync-/, - ) - await expect(page.locator('#github-pr-head-branch')).not.toHaveValue( - 'Develop/Open-Pr-Test', - ) - await expect(page.locator('#github-pr-title')).toHaveValue( - 'Apply component and styles edits to knightedcodemonkey/develop', - ) - await expect(page.locator('#github-pr-body')).toHaveValue( - [ - 'This PR was created from @knighted/develop editor content.', - '', - '- Component source -> examples/component/App.tsx', - '- Styles source -> examples/styles/app.css', - ].join('\n'), - ) -}) - -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}?feature-ai=true`) - - await page.locator('#github-token-input').fill('github_pat_fake_1234567890') - await page.locator('#github-token-add').click() - await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories') - - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.locator('#github-pr-repo-select') - const baseSelect = page.locator('#github-pr-base-branch') - - await repoSelect.selectOption('knightedcodemonkey/develop') - await expect(baseSelect).toHaveValue('main') - await expect(baseSelect.locator('option')).toHaveText(['main', 'develop-next']) - - await repoSelect.selectOption('knightedcodemonkey/css') - await expect(baseSelect).toHaveValue('stable') - await expect(baseSelect.locator('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}?feature-ai=true`) - - await page.locator('#github-token-input').fill('github_pat_fake_1234567890') - await page.locator('#github-token-add').click() - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.locator('#github-pr-repo-select') - const componentPath = page.locator('#github-pr-component-path') - - 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}?feature-ai=true`) - - await page.locator('#github-token-input').fill('github_pat_fake_1234567890') - await page.locator('#github-token-add').click() - await ensureOpenPrDrawerOpen(page) - - const repoSelect = page.locator('#github-pr-repo-select') - const componentPath = page.locator('#github-pr-component-path') - - 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('Open PR drawer validates unsafe filepaths', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.locator('#github-pr-component-path').fill('../outside/App.tsx') - await page.locator('#github-pr-submit').click() - - await expect(page.locator('#github-pr-status')).toContainText( - 'Component path: File path cannot include parent directory traversal.', - ) - await expect(page.locator('#clear-confirm-dialog')).not.toHaveAttribute('open', '') -}) - -test('Open PR drawer allows dotted file segments that are not traversal', async ({ - page, -}) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.locator('#github-pr-component-path').fill('docs/v1.0..v1.1/App.tsx') - await page.locator('#github-pr-styles-path').fill('styles/foo..bar.css') - await page.locator('#github-pr-submit').click() - - await expect(page.locator('#clear-confirm-dialog')).toHaveAttribute('open', '') - await expect(page.locator('#github-pr-status')).not.toContainText( - 'File path cannot include parent directory traversal.', - ) -}) - -test('Open PR drawer rejects trailing slash file paths', async ({ page }) => { - await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) - await connectByotWithSingleRepo(page) - await ensureOpenPrDrawerOpen(page) - - await page.locator('#github-pr-component-path').fill('src/components/') - await page.locator('#github-pr-submit').click() - - await expect(page.locator('#github-pr-status')).toContainText( - 'Component path: File path must include a filename (no trailing slash).', - ) - await expect(page.locator('#clear-confirm-dialog')).not.toHaveAttribute('open', '') -}) - -test('renders default playground preview', async ({ page }) => { - await waitForInitialRender(page) - - await page.getByLabel('ShadowRoot').uncheck() - await expect(page.locator('#status')).toHaveText('Rendered') - await expectPreviewHasRenderedContent(page) -}) - -test('supports layout and theme toggles', async ({ page }) => { - await waitForInitialRender(page) - - await page.getByLabel('Use side preview layout').click() - await expect(page.locator('.app-grid')).toHaveClass(/app-grid--preview-right/) - - await page.getByLabel('Use light theme').click() - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') - - const colorInput = page.locator('#preview-bg-color') - await colorInput.fill('#2456a8') - await expect(page.locator('#preview-host')).toHaveCSS( - 'background-color', - 'rgb(36, 86, 168)', - ) -}) - -test('side 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.locator('.app-grid')).toHaveClass(/app-grid--preview-right/) - - const metrics = await page.evaluate(() => { - const stack = document.querySelector('.panels-stack--editors') - const previewPanel = document.getElementById('preview-panel') - const stackHeight = stack?.getBoundingClientRect().height ?? 0 - const previewHeight = previewPanel?.getBoundingClientRect().height ?? 0 - const previewOverflowY = previewPanel ? getComputedStyle(previewPanel).overflowY : '' - return { stackHeight, previewHeight, previewOverflowY } - }) - - expect(metrics.stackHeight).toBeGreaterThan(0) - expect(metrics.previewHeight).toBeGreaterThan(0) - expect(metrics.previewHeight).toBeLessThanOrEqual(metrics.stackHeight + 2) - expect(metrics.previewOverflowY).toBe('hidden') -}) - -test('side layout config 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') - if (!previewPanel || !previewHost) { - return null - } - - const panelStyles = getComputedStyle(previewPanel) - const styles = getComputedStyle(previewHost) - return { - panelOverflowY: panelStyles.overflowY, - panelOverflowX: panelStyles.overflowX, - overflowY: styles.overflowY, - minHeight: styles.minHeight, - } - }) - - expect(scrollConfig).not.toBeNull() - expect(scrollConfig?.panelOverflowY).toBe('hidden') - expect(scrollConfig?.panelOverflowX).toBe('hidden') - expect(['auto', 'scroll']).toContain(scrollConfig?.overflowY) - expect(scrollConfig?.minHeight).toBe('0px') -}) - -test('expanded component and styles can shrink consistently in side layouts', 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) - } -}) - -test('panel collapse axis and direction adapt to active layout', async ({ page }) => { - await waitForInitialRender(page) - await expect(page.locator('.app-grid')).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 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 }) => { - await waitForInitialRender(page) - - await getCollapseButton(page, 'component').click() - await getCollapseButton(page, 'styles').click() - - await expect(page.locator('#component-panel')).toHaveClass( - /panel--collapsed-horizontal/, - ) - await expect(page.locator('#styles-panel')).toHaveClass(/panel--collapsed-horizontal/) - - await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', - collapsed: false, - disabled: true, - }) - await expect(getCollapseButton(page, 'preview')).toHaveAttribute( - 'title', - 'At least one panel must remain expanded.', - ) - - await getCollapseButton(page, 'component').click() - await expectCollapseButtonState(page, 'preview', { - axis: 'vertical', - direction: 'none', - collapsed: false, - disabled: false, - }) -}) - -test('does not persist panel collapse state across reload', async ({ page }) => { - await waitForInitialRender(page) - - await getCollapseButton(page, 'component').click() - await expect(page.locator('#component-panel')).toHaveClass( - /panel--collapsed-horizontal/, - ) - await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', - collapsed: true, - }) - - await page.reload() - await waitForInitialRender(page) - - await expect(page.locator('#component-panel')).not.toHaveClass( - /panel--collapsed-horizontal|panel--collapsed-vertical/, - ) - await expectCollapseButtonState(page, 'component', { - axis: 'horizontal', - direction: 'left', - collapsed: false, - }) -}) - -test('gear tools toggles default inactive and switch active/inactive per panel', async ({ - page, -}) => { - await waitForInitialRender(page) - - const componentPanel = page.locator('#component-panel') - const stylesPanel = page.locator('#styles-panel') - const componentTools = getToolsButton(page, 'component') - const stylesTools = getToolsButton(page, 'styles') - - await expect(componentPanel).toHaveClass(/panel--tools-hidden/) - await expect(stylesPanel).toHaveClass(/panel--tools-hidden/) - await expect(componentTools).toHaveAttribute('aria-pressed', 'false') - await expect(stylesTools).toHaveAttribute('aria-pressed', 'false') - - await componentTools.click() - await expect(componentPanel).not.toHaveClass(/panel--tools-hidden/) - await expect(componentTools).toHaveAttribute('aria-pressed', 'true') - await expect(componentTools).toHaveAttribute('title', 'Hide component tools') - - await componentTools.click() - await expect(componentPanel).toHaveClass(/panel--tools-hidden/) - await expect(componentTools).toHaveAttribute('aria-pressed', 'false') - await expect(componentTools).toHaveAttribute('title', 'Show component tools') - - 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('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.locator('#render-mode').selectOption('react') - await page.locator('#style-mode').selectOption('module') - await expect(page.locator('#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.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#preview-host 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.locator('#clear-confirm-dialog') - await page.getByLabel('Clear component source').click() - await expect(dialog).toHaveAttribute('open', '') - await dialog.getByRole('button', { name: 'Clear' }).click() - - await expect(page.locator('#preview-host button')).toHaveCount(0) - await expect(page.locator('#preview-host pre')).toHaveCount(0) - await expect(page.locator('#status')).toHaveText('Component cleared') - await expect(page.locator('#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.locator('#style-mode') - - await autoRenderToggle.uncheck() - await expect(renderButton).toBeVisible() - - await styleMode.selectOption('module') - - await renderButton.click() - await expect(page.locator('#status')).toHaveText('Rendered') - 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.locator('.app-grid')).toHaveClass(/app-grid--preview-right/) - await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') - - await page.reload() - await waitForInitialRender(page) - - await expect(page.locator('.app-grid')).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.locator('#style-mode').selectOption('less') - await expect(page.locator('#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.locator('#style-mode').selectOption('sass') - await expect(page.locator('#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.locator('#style-mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - await expect(page.locator('#status')).toHaveText('Error') - await expect(page.locator('#diagnostics-toggle')).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') -}) - -test('clear component action opens confirm dialog and can be canceled', async ({ - page, -}) => { - await waitForInitialRender(page) - - const dialog = page.locator('#clear-confirm-dialog') - const jsxEditor = page.locator('#jsx-editor') - - const beforeValue = await jsxEditor.inputValue() - await page.getByLabel('Clear component source').click() - - await expect(dialog).toHaveAttribute('open', '') - await expect(page.locator('#clear-confirm-title')).toHaveText('Clear Component source?') - - await dialog.getByRole('button', { name: 'Cancel' }).click() - await expect(dialog).not.toHaveAttribute('open', '') - await expect(jsxEditor).toHaveValue(beforeValue) -}) - -test('clear styles action opens confirm dialog and clears on confirm', async ({ - page, -}) => { - await waitForInitialRender(page) - - const dialog = page.locator('#clear-confirm-dialog') - const cssEditor = page.locator('#css-editor') - - await page.getByLabel('Clear styles source').click() - - await expect(dialog).toHaveAttribute('open', '') - await expect(page.locator('#clear-confirm-title')).toHaveText('Clear Styles source?') - - await dialog.getByRole('button', { name: 'Clear' }).click() - await expect(dialog).not.toHaveAttribute('open', '') - await expect(cssEditor).toHaveValue('') - await expect(page.locator('#status')).toHaveText('Styles cleared') -}) - -test('clearing styles keeps diagnostics error state but resets status styling', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - await setComponentEditorSource( - page, - ["const count: number = 'oops'", 'const App = () => '].join( - '\n', - ), - ) - - await page.getByRole('button', { name: 'Typecheck' }).click() - - await expect(page.locator('#status')).toHaveText(/Rendered \(Type errors: [1-9]\d*\)/) - await expect(page.locator('#status')).toHaveClass(/status--error/) - await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - - const dialog = page.locator('#clear-confirm-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.locator('#status')).toHaveText('Styles cleared') - await expect(page.locator('#status')).toHaveClass(/status--neutral/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) -}) - -test('clear component diagnostics removes type errors and restores rendered status', async ({ - page, -}) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'component') - - await setComponentEditorSource( - page, - ["const count: number = 'oops'", 'const App = () => '].join( - '\n', - ), - ) - - await page.getByRole('button', { name: 'Typecheck' }).click() - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#status')).toHaveText(/Rendered \(Type errors: [1-9]\d*\)/) - - await ensureDiagnosticsDrawerOpen(page) - await page.locator('#diagnostics-clear-component').click() - - await expect(page.locator('#diagnostics-component')).toContainText( - 'No diagnostics yet.', - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--neutral/, - ) - await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#status')).toHaveClass(/status--neutral/) -}) - -test('clear all diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.locator('#style-mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-styles')).toContainText( - 'Style compilation failed.', - ) - - await page.locator('#diagnostics-clear-all').click() - await expect(page.locator('#diagnostics-component')).toContainText( - 'No diagnostics yet.', - ) - await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--neutral/, - ) -}) - -test('clear styles diagnostics removes style compile diagnostics', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - - await page.locator('#style-mode').selectOption('sass') - await setStylesEditorSource(page, '.card { color: $missing; }') - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-styles')).toContainText( - 'Style compilation failed.', - ) - - await page.locator('#diagnostics-clear-styles').click() - await expect(page.locator('#diagnostics-component')).toContainText( - 'No diagnostics yet.', - ) - await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--neutral/, - ) -}) - -test('typecheck success reports ok diagnostics state in button and drawer', async ({ - page, -}) => { - await waitForInitialRender(page) - - await runTypecheck(page) - - await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#diagnostics-toggle')).toHaveClass(/diagnostics-toggle--ok/) - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-component')).toContainText( - 'No TypeScript errors found.', - ) -}) - -test('typecheck error reports diagnostics count in button and details in drawer', async ({ - page, -}) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - ["const broken: number = 'oops'", 'const App = () => '].join( - '\n', - ), - ) - - await runTypecheck(page) - - await expect(page.locator('#status')).toHaveText(/Rendered \(Type errors: [1-9]\d*\)/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) - - await expect(page.locator('#diagnostics-drawer')).toBeVisible() - await expect(page.locator('#diagnostics-component')).toContainText('TypeScript found') - await expect(page.locator('#diagnostics-component')).toContainText('TS') -}) - -test('component diagnostics rows navigate editor to reported line', async ({ page }) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - [ - "const brokenCount: number = 'oops'", - 'const App = () => ', - ].join('\n'), - ) - - await runTypecheck(page) - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await ensureDiagnosticsDrawerOpen(page) - - const targetDiagnostic = page - .locator('#diagnostics-component .diagnostic-line-button[data-diagnostic-line="2"]') - .first() - await expect(targetDiagnostic).toBeVisible() - - await targetDiagnostic.click() - await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/) - await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2') -}) - -test('component diagnostics support arrow navigation and enter jump', async ({ - page, -}) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - [ - "const broken: number = 'oops'", - 'const App = () => ', - ].join('\n'), - ) - - await runTypecheck(page) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - - await ensureDiagnosticsDrawerOpen(page) - - const firstDiagnostic = page - .locator('#diagnostics-component .diagnostic-line-button') - .first() - const secondDiagnostic = page - .locator('#diagnostics-component .diagnostic-line-button') - .nth(1) - - await expect(firstDiagnostic).toBeVisible() - await expect(secondDiagnostic).toBeVisible() - - await firstDiagnostic.focus() - await firstDiagnostic.press('ArrowDown') - await expect(secondDiagnostic).toBeFocused() - - await secondDiagnostic.press('Enter') - await expect(secondDiagnostic).toHaveClass(/diagnostic-line-button--active/) - await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2') -}) - -test('component lint error reports diagnostics count and details', async ({ page }) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - ['const unusedValue = 1', 'const App = () => '].join('\n'), - ) - - await runComponentLint(page) - - await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) - - await expect(page.locator('#diagnostics-drawer')).toBeVisible() - await expect(page.locator('#diagnostics-component')).toContainText( - 'Biome reported issues.', - ) -}) - -test('styles diagnostics rows navigate editor to reported line', async ({ page }) => { - await waitForInitialRender(page) - - await ensurePanelToolsVisible(page, 'styles') - await setStylesEditorSource( - page, - ['.card {', ' color: red', ' color: blue;', '}'].join('\n'), - ) - - await runStylesLint(page) - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await ensureDiagnosticsDrawerOpen(page) - - const targetDiagnostic = page - .locator('#diagnostics-styles .diagnostic-line-button[data-diagnostic-line="3"]') - .first() - await expect(targetDiagnostic).toBeVisible() - - await targetDiagnostic.click() - await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/) - await expect.poll(() => getActiveStylesEditorLineNumber(page)).toBe('3') -}) - -test('clear component diagnostics resets rendered lint-issue status pill', async ({ - page, -}) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - [ - 'const unusedValue = 1', - 'const App = () => ', - ].join('\n'), - ) - - await runComponentLint(page) - - await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/) - await expect(page.locator('#status')).toHaveClass(/status--error/) - - await ensureDiagnosticsDrawerOpen(page) - await page.locator('#diagnostics-clear-component').click() - - await expect(page.locator('#diagnostics-component')).toContainText( - 'No diagnostics yet.', - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--neutral/, - ) - await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#status')).toHaveClass(/status--neutral/) -}) - -test('component lint ignores unused App View and render bindings', async ({ page }) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - [ - 'function App() { return }', - 'function View() { return
View
}', - 'function render() { return null }', - ].join('\n'), - ) - - await runComponentLint(page) - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-component')).toContainText( - 'No Biome issues found.', - ) - - await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#status')).toHaveClass(/status--neutral/) - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - await expect(page.locator('#diagnostics-toggle')).toHaveClass(/diagnostics-toggle--ok/) - - const diagnosticsText = await page.locator('#diagnostics-component').innerText() - expect(diagnosticsText).not.toContain('This variable App is unused') - expect(diagnosticsText).not.toContain('This variable View is unused') - expect(diagnosticsText).not.toContain('This variable render is unused') - expect(diagnosticsText).not.toContain('This function App is unused') - expect(diagnosticsText).not.toContain('This function View is unused') - expect(diagnosticsText).not.toContain('This function render is unused') -}) - -test('component lint with unresolved issues enters pending diagnostics state while typing', async ({ - page, -}) => { - await waitForInitialRender(page) - - await setComponentEditorSource( - page, - ['const unusedValue = 1', 'const App = () => '].join('\n'), - ) - - await runComponentLint(page) - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - - await setComponentEditorSource( - page, - ['const unusedValue = 1', 'const App = () => '].join( - '\n', - ), - ) - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--pending/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveAttribute('aria-busy', 'true') - - await expect(page.locator('#status')).toHaveText(/Rendered \(Lint issues: [1-9]\d*\)/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveAttribute('aria-busy', 'false') -}) - -test('changing css dialect resets diagnostics after lint and typecheck runs', async ({ - page, -}) => { - await waitForInitialRender(page) - await ensurePanelToolsVisible(page, 'styles') - - await setComponentEditorSource( - page, - [ - "const broken: number = 'oops'", - 'const unusedValue = 1', - 'const App = () => ', - ].join('\n'), - ) - - await runTypecheck(page) - await runComponentLint(page) - - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--error/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText(/Diagnostics \([1-9]\d*\)/) - - await page.locator('#style-mode').selectOption('less') - - await expect(page.locator('#status')).toHaveText('Rendered') - await expect(page.locator('#status')).toHaveClass(/status--neutral/) - await expect(page.locator('#diagnostics-toggle')).toHaveClass( - /diagnostics-toggle--neutral/, - ) - await expect(page.locator('#diagnostics-toggle')).toHaveText('Diagnostics') - - await ensureDiagnosticsDrawerOpen(page) - await expect(page.locator('#diagnostics-component')).toContainText( - 'No diagnostics yet.', - ) - await expect(page.locator('#diagnostics-styles')).toContainText('No diagnostics yet.') -}) diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts new file mode 100644 index 0000000..004051e --- /dev/null +++ b/playwright/diagnostics.spec.ts @@ -0,0 +1,442 @@ +import { expect, test } from '@playwright/test' +import { + ensurePanelToolsVisible, + ensureDiagnosticsDrawerClosed, + ensureDiagnosticsDrawerOpen, + getActiveComponentEditorLineNumber, + getActiveStylesEditorLineNumber, + runComponentLint, + runStylesLint, + runTypecheck, + setComponentEditorSource, + setStylesEditorSource, + waitForInitialRender, +} from './helpers/app-test-helpers.js' + +test('clear component action opens confirm dialog and can be canceled', async ({ + page, +}) => { + await waitForInitialRender(page) + + const dialog = page.getByRole('dialog') + const jsxEditor = page.getByRole('textbox', { + name: 'Component source editor fallback', + includeHidden: true, + }) + + const beforeValue = await jsxEditor.inputValue() + await page.getByLabel('Clear component source').click() + + await expect(dialog).toHaveAttribute('open', '') + await expect(page.getByRole('heading', { level: 3 })).toHaveText( + 'Clear Component source?', + ) + + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await expect(jsxEditor).toHaveValue(beforeValue) +}) + +test('clear styles action opens confirm dialog and clears on confirm', async ({ + page, +}) => { + await waitForInitialRender(page) + + const dialog = page.getByRole('dialog') + const cssEditor = page.getByRole('textbox', { + name: 'Styles source editor fallback', + includeHidden: true, + }) + + await page.getByLabel('Clear styles source').click() + + await expect(dialog).toHaveAttribute('open', '') + await expect(page.getByRole('heading', { level: 3 })).toHaveText('Clear Styles source?') + + await dialog.getByRole('button', { name: 'Clear' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await expect(cssEditor).toHaveValue('') + await expect(page.getByText('Styles cleared', { exact: true })).toBeVisible() +}) + +test('clearing styles keeps diagnostics error state but resets status styling', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + ["const count: number = 'oops'", '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, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + await setComponentEditorSource( + page, + ["const count: number = 'oops'", 'const App = () => '].join( + '\n', + ), + ) + + await page.getByRole('button', { name: 'Typecheck' }).click() + 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 expect(page.getByText('No diagnostics yet.')).toHaveCount(2) + await expect(diagnosticsToggle).toHaveText('Diagnostics') + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) + 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, +}) => { + await waitForInitialRender(page) + + await runTypecheck(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(page.getByText('Rendered', { exact: true })).toBeVisible() + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--ok/) + await expect(diagnosticsToggle).toHaveText('Diagnostics') + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.getByText('No TypeScript errors found.')).toBeVisible() +}) + +test('typecheck error reports diagnostics count in button and details in drawer', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ["const broken: number = 'oops'", 'const App = () => '].join( + '\n', + ), + ) + + await runTypecheck(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(page.getByText(/Rendered \(Type errors: [1-9]\d*\)/)).toBeVisible() + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) + + await expect( + page.getByRole('button', { name: 'Close diagnostics drawer' }), + ).toBeVisible() + await expect(page.getByText('TypeScript found')).toBeVisible() + await expect(page.getByText(/TS\d+/)).toBeVisible() +}) + +test('component diagnostics rows navigate editor to reported line', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + [ + "const brokenCount: number = 'oops'", + 'const App = () => ', + ].join('\n'), + ) + + await runTypecheck(page) + + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( + /diagnostics-toggle--error/, + ) + await ensureDiagnosticsDrawerOpen(page) + + const targetDiagnostic = page.getByRole('button', { name: /^L2(:\d+)?\s/ }).first() + await expect(targetDiagnostic).toBeVisible() + + await targetDiagnostic.click() + await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/) + await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2') +}) + +test('component diagnostics support arrow navigation and enter jump', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + [ + "const broken: number = 'oops'", + 'const App = () => ', + ].join('\n'), + ) + + await runTypecheck(page) + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( + /diagnostics-toggle--error/, + ) + + await ensureDiagnosticsDrawerOpen(page) + + const diagnosticButtons = page.getByRole('button', { name: /^L\d+(:\d+)?\s/ }) + const firstDiagnostic = diagnosticButtons.first() + const secondDiagnostic = diagnosticButtons.nth(1) + + await expect(firstDiagnostic).toBeVisible() + await expect(secondDiagnostic).toBeVisible() + + await firstDiagnostic.focus() + await firstDiagnostic.press('ArrowDown') + await expect(secondDiagnostic).toBeFocused() + + await secondDiagnostic.press('Enter') + await expect(secondDiagnostic).toHaveClass(/diagnostic-line-button--active/) + await expect.poll(() => getActiveComponentEditorLineNumber(page)).toBe('2') +}) + +test('component lint error reports diagnostics count and details', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ['const unusedValue = 1', 'const App = () => '].join('\n'), + ) + + await runComponentLint(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toBeVisible() + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) + + await expect( + page.getByRole('button', { name: 'Close diagnostics drawer' }), + ).toBeVisible() + await expect(page.getByText('Biome reported issues.')).toBeVisible() +}) + +test('styles diagnostics rows navigate editor to reported line', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'styles') + await setStylesEditorSource( + page, + ['.card {', ' color: red', ' color: blue;', '}'].join('\n'), + ) + + await runStylesLint(page) + + await expect(page.getByRole('button', { name: /^Diagnostics/ })).toHaveClass( + /diagnostics-toggle--error/, + ) + await ensureDiagnosticsDrawerOpen(page) + + const targetDiagnostic = page.getByRole('button', { name: /^L3(:\d+)?\s/ }).first() + await expect(targetDiagnostic).toBeVisible() + + await targetDiagnostic.click() + await expect(targetDiagnostic).toHaveClass(/diagnostic-line-button--active/) + await expect.poll(() => getActiveStylesEditorLineNumber(page)).toBe('3') +}) + +test('clear component diagnostics resets rendered lint-issue status pill', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + [ + 'const unusedValue = 1', + 'const App = () => ', + ].join('\n'), + ) + + await runComponentLint(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toHaveClass( + /status--error/, + ) + + await ensureDiagnosticsDrawerOpen(page) + await page.getByRole('button', { name: 'Reset component' }).click() + + await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) + await expect(diagnosticsToggle).toHaveText('Diagnostics') + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) + await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) +}) + +test('component lint ignores unused App View and render bindings', async ({ page }) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + [ + 'function App() { return }', + 'function View() { return
View
}', + 'function render() { return null }', + ].join('\n'), + ) + + await runComponentLint(page) + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.getByText('No Biome issues found.')).toBeVisible() + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) + await expect(diagnosticsToggle).toHaveText('Diagnostics') + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--ok/) + + const diagnosticsText = await page.getByRole('complementary').innerText() + expect(diagnosticsText).not.toContain('This variable App is unused') + expect(diagnosticsText).not.toContain('This variable View is unused') + expect(diagnosticsText).not.toContain('This variable render is unused') + expect(diagnosticsText).not.toContain('This function App is unused') + expect(diagnosticsText).not.toContain('This function View is unused') + expect(diagnosticsText).not.toContain('This function render is unused') +}) + +test('component lint with unresolved issues enters pending diagnostics state while typing', async ({ + page, +}) => { + await waitForInitialRender(page) + + await setComponentEditorSource( + page, + ['const unusedValue = 1', 'const App = () => '].join('\n'), + ) + + await runComponentLint(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + + await setComponentEditorSource( + page, + ['const unusedValue = 1', 'const App = () => '].join( + '\n', + ), + ) + + 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).toHaveAttribute('aria-busy', 'false') +}) + +test('changing css dialect resets diagnostics after lint and typecheck runs', async ({ + page, +}) => { + await waitForInitialRender(page) + await ensurePanelToolsVisible(page, 'styles') + + await setComponentEditorSource( + page, + [ + "const broken: number = 'oops'", + 'const unusedValue = 1', + 'const App = () => ', + ].join('\n'), + ) + + await runTypecheck(page) + await runComponentLint(page) + + const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ }) + + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) + await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) + + await page.getByLabel('Style mode').selectOption('less') + + await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--neutral/) + await expect(diagnosticsToggle).toHaveText('Diagnostics') + + await ensureDiagnosticsDrawerOpen(page) + await expect(page.getByText('No diagnostics yet.')).toHaveCount(2) +}) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts new file mode 100644 index 0000000..2495033 --- /dev/null +++ b/playwright/github-byot-ai.spec.ts @@ -0,0 +1,400 @@ +import { expect, test } from '@playwright/test' +import { defaultGitHubChatModel } from '../src/modules/github-api.js' +import type { ChatRequestBody, ChatRequestMessage } from './helpers/app-test-helpers.js' +import { + appEntryPath, + connectByotWithSingleRepo, + ensureAiChatDrawerOpen, + ensureOpenPrDrawerOpen, + mockRepositoryBranches, + waitForAppReady, +} from './helpers/app-test-helpers.js' + +test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => { + await waitForAppReady(page) + + const byotControls = page.getByRole('group', { + name: 'GitHub controls', + includeHidden: true, + }) + await expect(byotControls).toHaveAttribute('hidden', '') + await expect(byotControls).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('heading', { name: 'AI Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Open PR' })).toBeHidden() + await expect(page.getByRole('heading', { name: 'Open Pull Request' })).toBeHidden() +}) + +test('BYOT controls render when feature flag is enabled by query param', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + + const byotControls = page.getByRole('group', { name: 'GitHub controls' }) + 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(page.getByRole('button', { name: 'Open PR' })).toBeHidden() +}) + +test('GitHub token info panel reflects missing and present token states', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + + const infoButtonMissing = page.getByRole('button', { + name: 'About GitHub token features and privacy', + }) + const infoButtonPresent = page.getByRole('button', { + name: 'About GitHub token privacy', + }) + const missingMessage = page.getByText('Provide a GitHub PAT', { exact: false }) + const presentMessage = page.getByText( + 'This token is stored only in your browser and is sent only to GitHub APIs you invoke. Use the trash icon to remove it from storage.', + ) + + await expect(infoButtonMissing).toHaveAttribute('data-token-state', 'missing') + await expect(infoButtonMissing).toHaveAttribute( + 'aria-label', + 'About GitHub token features and privacy', + ) + await expect(presentMessage).toBeHidden() + + await infoButtonMissing.click() + await expect(missingMessage).toBeVisible() + await expect(missingMessage).toContainText('Provide a GitHub PAT') + await expect(page.getByRole('link', { name: 'docs' })).toHaveAttribute( + 'href', + 'https://github.com/knightedcodemonkey/develop/blob/main/docs/byot.md', + ) + await expect(presentMessage).toBeHidden() + + await connectByotWithSingleRepo(page) + await expect(infoButtonPresent).toHaveAttribute('data-token-state', 'present') + await expect(infoButtonPresent).toHaveAttribute( + 'aria-label', + 'About GitHub token privacy', + ) + + await infoButtonPresent.click() + await expect(presentMessage).toBeVisible() + await expect(presentMessage).toContainText( + 'Use the trash icon to remove it from storage.', + ) + await expect(missingMessage).toBeHidden() +}) + +test('deleting saved GitHub token requires confirmation modal', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + const dialog = page.getByRole('dialog', { + name: 'Remove saved GitHub token?', + includeHidden: true, + }) + const tokenDelete = page.getByRole('button', { name: 'Delete GitHub token' }) + const tokenAdd = page.getByRole('button', { name: 'Add GitHub token' }) + const tokenInput = page.getByRole('textbox', { name: 'GitHub token' }) + + await expect(tokenDelete).toBeVisible() + + await tokenDelete.click() + await expect(dialog).toHaveAttribute('open', '') + await expect(page.getByText('Remove saved GitHub token?', { exact: true })).toHaveText( + 'Remove saved GitHub token?', + ) + await expect( + page.getByText( + 'This action removes the token from browser storage. You can add another token at any time.', + ), + ).toHaveText( + 'This action removes the token from browser storage. You can add another token at any time.', + ) + const removeButton = dialog.getByRole('button', { name: 'Remove' }) + await expect(removeButton).toBeVisible() + await expect(removeButton).not.toHaveAttribute('aria-label') + + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).not.toHaveAttribute('open', '') + await expect(tokenDelete).toBeVisible() + await expect(tokenAdd).toBeHidden() + + await tokenDelete.click() + await expect(dialog).toHaveAttribute('open', '') + await removeButton.click() + await expect(dialog).not.toHaveAttribute('open', '') + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'GitHub token removed', + ) + await expect(tokenAdd).toBeVisible() + await expect(tokenDelete).toBeHidden() + await expect(tokenInput).toHaveValue('') +}) + +test('AI chat drawer opens and closes when feature flag is enabled', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + const chatToggle = page.getByRole('button', { name: 'Chat', exact: true }) + const chatDrawer = page.getByRole('heading', { name: 'AI Chat' }) + + await expect(chatToggle).toBeVisible() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') + + await chatToggle.click() + await expect(chatDrawer).toBeVisible() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') + + await page.getByRole('button', { name: 'Close AI chat drawer' }).click() + await expect(chatDrawer).toBeHidden() + await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') +}) + +test('AI chat prefers streaming responses when available', 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":"Streaming "}}]}', + '', + 'data: {"choices":[{"delta":{"content":"response ready"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Rate limit info unavailable', { exact: true })).toHaveText( + 'Rate limit info unavailable', + ) + await expect(page.getByText('Summarize this repository.')).toBeVisible() + await expect(page.getByText('Streaming response ready')).toBeVisible() + + expect(streamRequestBody?.metadata).toBeUndefined() + expect(streamRequestBody?.model).toBe(defaultGitHubChatModel) + const systemMessage = streamRequestBody?.messages?.find( + (message: ChatRequestMessage) => message.role === 'system', + ) + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect(systemMessage?.content).toContain('Selected repository context') + expect(systemMessage?.content).toContain('Repository: knightedcodemonkey/develop') + expect(systemMessage?.content).toContain( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(true) +}) + +test('AI chat can disable editor context payload via checkbox', 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}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + const includeEditorsToggle = page.getByLabel('Send JSX + CSS editor context') + await expect(includeEditorsToggle).toBeChecked() + await includeEditorsToggle.uncheck() + + await page.getByLabel('Ask AI assistant').fill('No editor source this time.') + await page.getByRole('button', { name: 'Send' }).click() + await expect( + page.getByText('Response streamed from GitHub.', { exact: true }), + ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Rate limit info unavailable', { exact: true })).toHaveText( + 'Rate limit info unavailable', + ) + + expect(streamRequestBody?.metadata).toBeUndefined() + const systemMessages = streamRequestBody?.messages?.filter( + (message: ChatRequestMessage) => message.role === 'system', + ) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Selected repository context'), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes( + 'Repository URL: https://github.com/knightedcodemonkey/develop', + ), + ), + ).toBe(true) + expect( + systemMessages?.some((message: ChatRequestMessage) => + message.content?.includes('Editor context:'), + ), + ).toBe(false) +}) + +test('AI chat falls back to non-streaming response when streaming fails', async ({ + page, +}) => { + let streamAttemptCount = 0 + let fallbackAttemptCount = 0 + const attemptedModels: string[] = [] + + await page.route('https://models.github.ai/inference/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + if (typeof body?.model === 'string') { + attemptedModels.push(body.model) + } + + if (body?.stream) { + streamAttemptCount += 1 + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream failed' }), + }) + return + } + + fallbackAttemptCount += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rate_limit: { + remaining: 17, + reset: 1704067200, + }, + choices: [ + { + message: { + role: 'assistant', + content: 'Fallback response from JSON path.', + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureAiChatDrawerOpen(page) + + const selectedModel = 'openai/gpt-5-mini' + await page.getByLabel('Chat model').selectOption(selectedModel) + await expect(page.getByLabel('Chat model')).toHaveValue(selectedModel) + + await page.getByLabel('Ask AI assistant').fill('Use fallback path.') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( + 'Fallback response loaded.', + ) + await expect( + page.getByText('Remaining 17, resets 00:00 UTC', { exact: true }), + ).toHaveText('Remaining 17, resets 00:00 UTC') + await expect(page.getByText('Fallback response from JSON path.')).toBeVisible() + expect(streamAttemptCount).toBeGreaterThan(0) + expect(fallbackAttemptCount).toBeGreaterThan(0) + expect(attemptedModels.length).toBeGreaterThan(0) + expect(attemptedModels.every(model => model === selectedModel)).toBe(true) +}) + +test('BYOT remembers selected repository across reloads', async ({ page }) => { + test.setTimeout(90_000) + + 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: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + 'knightedcodemonkey/css': ['main', 'release/1.x'], + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=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) + + const repoSelect = page.getByLabel('Pull request repository') + await expect(repoSelect).toBeEnabled() + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'Loaded 2 writable repositories', + ) + + await repoSelect.selectOption('knightedcodemonkey/develop') + 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', + { + timeout: 60_000, + }, + ) + await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Delete GitHub token' })).toBeVisible() + await ensureOpenPrDrawerOpen(page) + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') +}) diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts new file mode 100644 index 0000000..35d125d --- /dev/null +++ b/playwright/github-pr-drawer.spec.ts @@ -0,0 +1,453 @@ +import { expect, test } from '@playwright/test' +import type { + CreateRefRequestBody, + PullRequestCreateBody, +} from './helpers/app-test-helpers.js' +import { + appEntryPath, + connectByotWithSingleRepo, + ensureOpenPrDrawerOpen, + mockRepositoryBranches, + waitForAppReady, +} from './helpers/app-test-helpers.js' + +test('Open PR drawer confirms and submits component/styles filepaths', async ({ + page, +}) => { + 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}?feature-ai=true`) + 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.getByRole('button', { name: 'Open PR' }).last().click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect( + page.getByText('Open pull request with editor content?', { exact: true }), + ).toHaveText('Open pull request with editor content?') + await expect( + page.getByText('Component file path: examples/component/App.tsx'), + ).toBeVisible() + await expect(page.getByText('Styles file path: examples/styles/app.css')).toBeVisible() + + await dialog.getByRole('button', { name: 'Open PR' }).click() + + 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(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\/develop\/editor-sync-/) + await expect(page.getByLabel('Head')).not.toHaveValue('Develop/Open-Pr-Test') + await expect(page.getByLabel('PR title')).toHaveValue( + 'Apply component and styles edits to knightedcodemonkey/develop', + ) + await expect(page.getByLabel('PR description')).toHaveValue( + [ + 'This PR was created from @knighted/develop editor content.', + '', + '- Component source -> examples/component/App.tsx', + '- Styles source -> examples/styles/app.css', + ].join('\n'), + ) +}) + +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}?feature-ai=true`) + + 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}?feature-ai=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) + + 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}?feature-ai=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) + + 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('Open PR drawer validates unsafe filepaths', async ({ page }) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Component filename').fill('../outside/App.tsx') + await page.getByRole('button', { name: 'Open PR' }).last().click() + + 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}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Component filename').fill('docs/v1.0..v1.1/App.tsx') + await page.getByLabel('Styles filename').fill('styles/foo..bar.css') + await page.getByRole('button', { name: 'Open PR' }).last().click() + + await expect(page.getByRole('dialog')).toBeVisible() + 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}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Component filename').fill('src/components/') + await page.getByRole('button', { name: 'Open PR' }).last().click() + + 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() +}) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts new file mode 100644 index 0000000..969b2ee --- /dev/null +++ b/playwright/helpers/app-test-helpers.ts @@ -0,0 +1,246 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' + +export const appEntryPath = + webServerMode === 'preview' ? '/index.html' : '/src/index.html' + +export type ChatRequestMessage = { + role?: string + content?: string +} + +export type ChatRequestBody = { + metadata?: unknown + messages?: ChatRequestMessage[] + model?: string + stream?: boolean +} + +export type CreateRefRequestBody = { + ref?: string + sha?: string +} + +export type PullRequestCreateBody = { + head?: string + base?: string +} + +export type BranchesByRepo = Record + +export const waitForAppReady = async (page: Page, path = appEntryPath) => { + await page.goto(path) + await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() + await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '') + await expect + .poll(() => page.getByRole('status', { name: 'App status' }).textContent()) + .not.toBe('Idle') +} + +export const waitForInitialRender = async (page: Page) => { + await waitForAppReady(page) + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') +} + +export const expectPreviewHasRenderedContent = async (page: Page) => { + const previewHost = page.locator('#preview-host') + await expect(previewHost.locator('pre')).toHaveCount(0) + await expect + .poll(() => previewHost.evaluate(node => node.childElementCount)) + .toBeGreaterThan(0) +} + +export const setComponentEditorSource = async (page: Page, source: string) => { + const editorContent = page.locator('.component-panel .cm-content').first() + await editorContent.fill(source) +} + +export const setStylesEditorSource = async (page: Page, source: string) => { + const editorContent = page.locator('.styles-panel .cm-content').first() + await editorContent.fill(source) +} + +export const getActiveComponentEditorLineNumber = async (page: Page) => { + return page + .locator('#component-panel .cm-activeLineGutter') + .first() + .innerText() + .then(text => text.trim()) +} + +export const runTypecheck = async (page: Page) => { + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('button', { name: 'Typecheck' }).click() +} + +export const runComponentLint = async (page: Page) => { + await ensurePanelToolsVisible(page, 'component') + await page.getByRole('button', { name: 'Lint' }).first().click() +} + +export const runStylesLint = async (page: Page) => { + await ensurePanelToolsVisible(page, 'styles') + await page.locator('#styles-panel').getByRole('button', { name: 'Lint' }).click() +} + +export const getActiveStylesEditorLineNumber = async (page: Page) => { + return page + .locator('#styles-panel .cm-activeLineGutter') + .first() + .innerText() + .then(text => text.trim()) +} + +export const getCollapseButton = ( + page: Page, + panelName: 'component' | 'styles' | 'preview', +) => page.locator(`#collapse-${panelName}`) + +export const getToolsButton = (page: Page, panelName: 'component' | 'styles') => + page.locator(`#tools-${panelName}`) + +export const ensurePanelToolsVisible = async ( + page: Page, + panelName: 'component' | 'styles', +) => { + const button = getToolsButton(page, panelName) + const isPressed = await button.getAttribute('aria-pressed') + if (isPressed !== 'true') { + await button.click() + } +} + +export const ensureDiagnosticsDrawerOpen = async (page: Page) => { + const toggle = page.getByRole('button', { + name: /^Diagnostics(?:\s+\([1-9]\d*\)|\s+✓)?$/, + }) + const isExpanded = await toggle.getAttribute('aria-expanded') + + if (isExpanded !== 'true') { + await toggle.click() + } + + await expect(page.locator('#diagnostics-drawer')).toBeVisible() +} + +export const ensureDiagnosticsDrawerClosed = async (page: Page) => { + const toggle = page.getByRole('button', { + name: /^Diagnostics(?:\s+\([1-9]\d*\)|\s+✓)?$/, + }) + const isExpanded = await toggle.getAttribute('aria-expanded') + + if (isExpanded === 'true') { + await page.getByRole('button', { name: 'Close diagnostics drawer' }).click() + } + + await expect(page.locator('#diagnostics-drawer')).toBeHidden() +} + +export const ensureAiChatDrawerOpen = async (page: Page) => { + const toggle = page.getByRole('button', { name: 'Chat' }) + const isExpanded = await toggle.getAttribute('aria-expanded') + + if (isExpanded !== 'true') { + await toggle.click() + } + + await expect(page.locator('#ai-chat-drawer')).toBeVisible() +} + +export const ensureOpenPrDrawerOpen = async (page: Page) => { + const toggle = page.getByRole('button', { name: 'Open PR' }) + await expect(toggle).toBeEnabled({ timeout: 60_000 }) + const isExpanded = await toggle.getAttribute('aria-expanded') + + if (isExpanded !== 'true') { + await toggle.click() + } + + await expect(page.locator('#github-pr-drawer')).toBeVisible() +} + +export const mockRepositoryBranches = async ( + page: Page, + branchesByRepo: BranchesByRepo = {}, +) => { + await page.route('https://api.github.com/repos/**/branches**', async route => { + const url = new URL(route.request().url()) + const match = url.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/branches$/) + const repositoryKey = match ? `${match[1]}/${match[2]}` : '' + + const branchNames = + branchesByRepo[repositoryKey] && branchesByRepo[repositoryKey].length > 0 + ? branchesByRepo[repositoryKey] + : ['main'] + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(branchNames.map(name => ({ name }))), + }) + }) +} + +export const connectByotWithSingleRepo = async (page: 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'], + }) + + await page + .getByRole('textbox', { name: 'GitHub token' }) + .fill('github_pat_fake_chat_1234567890') + await page.getByRole('button', { name: 'Add GitHub token' }).click() + await expect(page.getByRole('status', { name: 'App status' })).toHaveText( + 'Loaded 1 writable repositories', + ) + await expect(page.getByRole('button', { name: 'Open PR' })).toBeVisible() +} + +export const expectCollapseButtonState = async ( + page: Page, + panelName: 'component' | 'styles' | 'preview', + { + axis, + direction, + collapsed, + disabled, + }: { + axis: 'vertical' | 'horizontal' + direction: 'left' | 'right' | 'none' + collapsed: boolean + disabled?: boolean + }, +) => { + const button = getCollapseButton(page, panelName) + + await expect(button).toHaveAttribute('data-collapse-axis', axis) + await expect(button).toHaveAttribute('data-collapse-direction', direction) + await expect(button).toHaveAttribute('data-collapsed', collapsed ? 'true' : 'false') + + if (disabled !== undefined) { + if (disabled) { + await expect(button).toBeDisabled() + } else { + await expect(button).toBeEnabled() + } + } +} diff --git a/playwright/layout-panels.spec.ts b/playwright/layout-panels.spec.ts new file mode 100644 index 0000000..5030d8d --- /dev/null +++ b/playwright/layout-panels.spec.ts @@ -0,0 +1,240 @@ +import { expect, test } from '@playwright/test' +import { + expectCollapseButtonState, + expectPreviewHasRenderedContent, + getCollapseButton, + getToolsButton, + waitForInitialRender, +} from './helpers/app-test-helpers.js' + +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 }) => { + 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') + + const colorInput = page.getByLabel('Background') + await colorInput.fill('#2456a8') + const previewBackgroundColor = await page.evaluate(() => { + const previewHost = document.getElementById('preview-host') + return previewHost ? getComputedStyle(previewHost).backgroundColor : '' + }) + expect(previewBackgroundColor).toBe('rgb(36, 86, 168)') +}) + +test('side 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') + const stackHeight = stack?.getBoundingClientRect().height ?? 0 + const previewHeight = previewPanel?.getBoundingClientRect().height ?? 0 + const previewOverflowY = previewPanel ? getComputedStyle(previewPanel).overflowY : '' + return { stackHeight, previewHeight, previewOverflowY } + }) + + expect(metrics.stackHeight).toBeGreaterThan(0) + expect(metrics.previewHeight).toBeGreaterThan(0) + expect(metrics.previewHeight).toBeLessThanOrEqual(metrics.stackHeight + 2) + expect(metrics.previewOverflowY).toBe('hidden') +}) + +test('side layout config 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') + if (!previewPanel || !previewHost) { + return null + } + + const panelStyles = getComputedStyle(previewPanel) + const styles = getComputedStyle(previewHost) + return { + panelOverflowY: panelStyles.overflowY, + panelOverflowX: panelStyles.overflowX, + overflowY: styles.overflowY, + minHeight: styles.minHeight, + } + }) + + expect(scrollConfig).not.toBeNull() + expect(scrollConfig?.panelOverflowY).toBe('hidden') + expect(scrollConfig?.panelOverflowX).toBe('hidden') + expect(['auto', 'scroll']).toContain(scrollConfig?.overflowY) + expect(scrollConfig?.minHeight).toBe('0px') +}) + +test('expanded component and styles can shrink consistently in side layouts', 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) + } +}) + +test('panel collapse axis and direction adapt to active 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 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 }) => { + 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() + + await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) + await expect(stylesPanel).toHaveClass(/panel--collapsed-horizontal/) + + await expectCollapseButtonState(page, 'preview', { + axis: 'vertical', + direction: 'none', + collapsed: false, + disabled: true, + }) + await expect(getCollapseButton(page, 'preview')).toHaveAttribute( + 'title', + 'At least one panel must remain expanded.', + ) + + await getCollapseButton(page, 'component').click() + await expectCollapseButtonState(page, 'preview', { + axis: 'vertical', + direction: 'none', + collapsed: false, + disabled: false, + }) +}) + +test('does not persist panel collapse state across reload', async ({ page }) => { + await waitForInitialRender(page) + const componentPanel = page.getByRole('region', { name: 'Component' }) + + await getCollapseButton(page, 'component').click() + await expect(componentPanel).toHaveClass(/panel--collapsed-horizontal/) + await expectCollapseButtonState(page, 'component', { + axis: 'horizontal', + direction: 'left', + collapsed: true, + }) + + await page.reload() + await waitForInitialRender(page) + + await expect(componentPanel).not.toHaveClass( + /panel--collapsed-horizontal|panel--collapsed-vertical/, + ) + await expectCollapseButtonState(page, 'component', { + axis: 'horizontal', + direction: 'left', + collapsed: false, + }) +}) + +test('gear tools toggles default inactive and switch active/inactive per panel', async ({ + page, +}) => { + await waitForInitialRender(page) + + const componentPanel = page.getByRole('region', { name: 'Component' }) + const stylesPanel = page.getByRole('region', { name: 'Styles' }) + const componentTools = getToolsButton(page, 'component') + const stylesTools = getToolsButton(page, 'styles') + + await expect(componentPanel).toHaveClass(/panel--tools-hidden/) + await expect(stylesPanel).toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'false') + await expect(stylesTools).toHaveAttribute('aria-pressed', 'false') + + await componentTools.click() + await expect(componentPanel).not.toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'true') + await expect(componentTools).toHaveAttribute('title', 'Hide component tools') + + await componentTools.click() + await expect(componentPanel).toHaveClass(/panel--tools-hidden/) + await expect(componentTools).toHaveAttribute('aria-pressed', 'false') + await expect(componentTools).toHaveAttribute('title', 'Show component tools') + + 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') +}) diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts new file mode 100644 index 0000000..051b6f9 --- /dev/null +++ b/playwright/rendering-modes.spec.ts @@ -0,0 +1,248 @@ +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.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('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/src/app.js b/src/app.js index fe3facc..1fd0cae 100644 --- a/src/app.js +++ b/src/app.js @@ -710,6 +710,10 @@ const initializeCodeEditors = async () => { parent: jsxHost, value: defaultJsx, language: 'javascript-jsx', + contentAttributes: { + 'aria-label': 'Component source editor', + 'aria-multiline': 'true', + }, onChange: () => { if (suppressEditorChangeSideEffects) { return @@ -723,6 +727,10 @@ const initializeCodeEditors = async () => { parent: cssHost, value: defaultCss, language: getStyleEditorLanguage(styleMode.value), + contentAttributes: { + 'aria-label': 'Styles source editor', + 'aria-multiline': 'true', + }, onChange: () => { if (suppressEditorChangeSideEffects) { return diff --git a/src/index.html b/src/index.html index c1aac8d..73339e8 100644 --- a/src/index.html +++ b/src/index.html @@ -28,7 +28,7 @@

Browser IDE with compiler-as-a-service.

-
Idle
+
Idle
@@ -317,9 +317,13 @@

-
+
-

Component

+

Component

- +
-
+
-

Styles

+

Styles

- +
-
+
-

Preview

+

Preview

-
+
@@ -652,7 +679,13 @@

Open Pull Request

-

+

Configure repository, file paths, and branch details.

diff --git a/src/modules/editor-codemirror.js b/src/modules/editor-codemirror.js index 5322f42..465bf3d 100644 --- a/src/modules/editor-codemirror.js +++ b/src/modules/editor-codemirror.js @@ -114,6 +114,7 @@ export const createCodeMirrorEditor = async ({ parent, value, language, + contentAttributes, onChange, onFocus, }) => { @@ -318,6 +319,9 @@ export const createCodeMirrorEditor = async ({ ...runtime.defaultKeymap, ...runtime.historyKeymap, ]), + ...(contentAttributes + ? [runtime.EditorView.contentAttributes.of(contentAttributes)] + : []), languageCompartment.of(resolveLanguageExtension(runtime, language)), editorTheme, updateListener, diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index d666ed4..83fdf67 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/render-runtime.js @@ -84,6 +84,8 @@ export const createRenderRuntimeController = ({ const nextHost = document.createElement('div') nextHost.id = 'preview-host' nextHost.className = previewHost.className + nextHost.setAttribute('role', 'region') + nextHost.setAttribute('aria-label', 'Preview output') previewHost.replaceWith(nextHost) setPreviewHost(nextHost) From 996e4034c9fe34074b0bf0288c1d1c0fc2e0eef4 Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 27 Mar 2026 21:49:00 -0500 Subject: [PATCH 2/2] refactor: address comments. --- .oxlintrc.json | 1 + playwright/helpers/app-test-helpers.ts | 14 +++++---- src/index.html | 41 +++++++++++++++++++++----- src/modules/editor-codemirror.js | 15 +++++++++- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 2c89af7..a667199 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,6 +9,7 @@ "nursery": "off" }, "rules": { + "eqeqeq": "error", "no-console": "error", "no-unused-vars": "error", "no-shadow": "error", diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 969b2ee..cb57059 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -77,12 +77,12 @@ export const runTypecheck = async (page: Page) => { export const runComponentLint = async (page: Page) => { await ensurePanelToolsVisible(page, 'component') - await page.getByRole('button', { name: 'Lint' }).first().click() + await page.getByRole('button', { name: 'Component lint' }).click() } export const runStylesLint = async (page: Page) => { await ensurePanelToolsVisible(page, 'styles') - await page.locator('#styles-panel').getByRole('button', { name: 'Lint' }).click() + await page.getByRole('button', { name: 'Styles lint' }).click() } export const getActiveStylesEditorLineNumber = async (page: Page) => { @@ -122,7 +122,7 @@ export const ensureDiagnosticsDrawerOpen = async (page: Page) => { await toggle.click() } - await expect(page.locator('#diagnostics-drawer')).toBeVisible() + await expect(page.getByRole('complementary', { name: 'Diagnostics' })).toBeVisible() } export const ensureDiagnosticsDrawerClosed = async (page: Page) => { @@ -135,7 +135,7 @@ export const ensureDiagnosticsDrawerClosed = async (page: Page) => { await page.getByRole('button', { name: 'Close diagnostics drawer' }).click() } - await expect(page.locator('#diagnostics-drawer')).toBeHidden() + await expect(page.getByRole('complementary', { name: 'Diagnostics' })).toBeHidden() } export const ensureAiChatDrawerOpen = async (page: Page) => { @@ -146,7 +146,7 @@ export const ensureAiChatDrawerOpen = async (page: Page) => { await toggle.click() } - await expect(page.locator('#ai-chat-drawer')).toBeVisible() + await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() } export const ensureOpenPrDrawerOpen = async (page: Page) => { @@ -158,7 +158,9 @@ export const ensureOpenPrDrawerOpen = async (page: Page) => { await toggle.click() } - await expect(page.locator('#github-pr-drawer')).toBeVisible() + await expect( + page.getByRole('complementary', { name: 'Open Pull Request' }), + ).toBeVisible() } export const mockRepositoryBranches = async ( diff --git a/src/index.html b/src/index.html index 73339e8..89cd3ba 100644 --- a/src/index.html +++ b/src/index.html @@ -399,7 +399,12 @@

Component

-
-
-