From 01cee1aacd49ae2a88a3d50234427072dff2201b Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 28 Mar 2026 12:16:11 -0500 Subject: [PATCH 1/4] feat: enhanced auto render and pr publish. --- docs/next-steps.md | 8 + playwright/diagnostics.spec.ts | 18 +- playwright/github-pr-drawer.spec.ts | 282 ++++++++++++++++++++++ playwright/rendering-modes.spec.ts | 98 ++++++++ src/app.js | 19 ++ src/index.html | 8 + src/modules/github-pr-drawer.js | 97 +++++++- src/modules/jsx-top-level-declarations.js | 80 ++++++ src/modules/jsx-transform-runtime.js | 33 +++ src/modules/lint-diagnostics.js | 2 +- src/modules/render-runtime.js | 80 ++++-- src/styles/ai-controls.css | 16 ++ 12 files changed, 712 insertions(+), 29 deletions(-) create mode 100644 src/modules/jsx-top-level-declarations.js create mode 100644 src/modules/jsx-transform-runtime.js diff --git a/docs/next-steps.md b/docs/next-steps.md index 19d5106..cd49f04 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -37,3 +37,11 @@ Focused follow-up work for `@knighted/develop`. - Do not add dependencies without explicit approval. - Remaining Phase 3 mini-spec (agent implementation prompt): - "Continue Issue #18 in @knighted/develop from the current baseline where PR filename/path groundwork and Open PR flow are already shipped. Implement the two remaining Phase 3 assistant deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. Keep all AI/BYOT behavior behind the existing browser-only AI feature flag and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering mode-aware recommendation constraints and apply/undo editor actions." + +5. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)** + - Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App. + - Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable. + - Preserve existing runtime behavior and editor content semantics while adding tab switching, active tab indication, and predictable persistence/reset behavior consistent with current app patterns. + - Ensure assistant/editor integration remains compatible with this model (edits should target one of the fixed tabs) without expanding to dynamic tab metadata yet. + - Suggested implementation prompt: + - "Implement Phase 2 UX/UI tab support in @knighted/develop with a fixed first-pass tab model: Component, Styles, and App only (no arbitrary tab names yet). Add a clear tab UI for switching editor panes, preserve existing editor behavior/content wiring, and keep render/lint/typecheck/diagnostics flows working with the selected tab context where relevant. Keep AI/BYOT feature-flag behavior unchanged, maintain CDN-first runtime constraints, and do not add dependencies. Add targeted Playwright coverage for tab switching, default/active tab behavior, and interactions with existing render/style-mode flows. Validate with npm run lint and targeted Playwright tests." diff --git a/playwright/diagnostics.spec.ts b/playwright/diagnostics.spec.ts index 004051e..bafdf2b 100644 --- a/playwright/diagnostics.spec.ts +++ b/playwright/diagnostics.spec.ts @@ -346,7 +346,7 @@ test('clear component diagnostics resets rendered lint-issue status pill', async await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/) }) -test('component lint ignores unused App View and render bindings', async ({ page }) => { +test('component lint ignores only unused App binding', async ({ page }) => { await waitForInitialRender(page) await setComponentEditorSource( @@ -361,20 +361,20 @@ test('component lint ignores unused App View and render bindings', async ({ page 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/) + await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toHaveClass( + /status--error/, + ) + await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/) + await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/) const diagnosticsText = await page.getByRole('complementary').innerText() + expect(diagnosticsText).toContain('Biome reported issues.') 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') + expect(diagnosticsText).toContain('This function View is unused') + expect(diagnosticsText).toContain('This function render is unused') }) test('component lint with unresolved issues enters pending diagnostics state while typing', async ({ diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index 35d125d..4421b9b 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -8,9 +8,15 @@ import { connectByotWithSingleRepo, ensureOpenPrDrawerOpen, mockRepositoryBranches, + setComponentEditorSource, waitForAppReady, } from './helpers/app-test-helpers.js' +const decodeGitHubFileBodyContent = (body: Record) => { + const encoded = typeof body.content === 'string' ? body.content : '' + return Buffer.from(encoded, 'base64').toString('utf8') +} + test('Open PR drawer confirms and submits component/styles filepaths', async ({ page, }) => { @@ -451,3 +457,279 @@ test('Open PR drawer rejects trailing slash file paths', async ({ page }) => { ) await expect(page.getByRole('dialog')).toBeHidden() }) + +test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + await ensureOpenPrDrawerOpen(page) + + const includeWrapperToggle = page.getByLabel( + 'Include App wrapper in committed component source', + ) + await expect(includeWrapperToggle).not.toBeChecked() + + await includeWrapperToggle.check() + await expect(includeWrapperToggle).toBeChecked() + + await page.getByRole('button', { name: 'Close open pull request drawer' }).click() + await ensureOpenPrDrawerOpen(page) + + await expect(includeWrapperToggle).not.toBeChecked() +}) + +test('Open PR drawer strips App wrapper from committed component source by default', async ({ + page, +}) => { + const upsertRequests: Array<{ path: string; body: Record }> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const path = + new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' + + if (method === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + const body = request.postDataJSON() as Record + upsertRequests.push({ path: decodeURIComponent(path), body }) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ commit: { sha: 'commit-sha' } }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 101, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + const componentSource = [ + 'const CounterButton = () => ', + 'const App = () => ', + ].join('\n') + + await setComponentEditorSource(page, componentSource) + await ensureOpenPrDrawerOpen(page) + + await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app') + await page.getByRole('button', { name: 'Open PR' }).last().click() + await page.getByRole('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/101', + ) + + const componentUpserts = upsertRequests.filter(request => + request.path.endsWith('/App.jsx'), + ) + + expect(componentUpserts).toHaveLength(1) + + const strippedComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + + expect(strippedComponentSource).toContain('const CounterButton = () =>') + expect(strippedComponentSource).not.toContain('const App = () =>') +}) + +test('Open PR drawer includes App wrapper in committed source when toggled on', async ({ + page, +}) => { + const upsertRequests: Array<{ path: string; body: Record }> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const path = + new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path' + + if (method === 'GET') { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + const body = request.postDataJSON() as Record + upsertRequests.push({ path: decodeURIComponent(path), body }) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ commit: { sha: 'commit-sha' } }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 101, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/101', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + await connectByotWithSingleRepo(page) + + await setComponentEditorSource( + page, + [ + 'const CounterButton = () => ', + 'const App = () => ', + ].join('\n'), + ) + await ensureOpenPrDrawerOpen(page) + + const includeWrapperToggle = page.getByLabel( + 'Include App wrapper in committed component source', + ) + await includeWrapperToggle.check() + + await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app') + await page.getByRole('button', { name: 'Open PR' }).last().click() + await page.getByRole('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/101', + ) + + const componentUpserts = upsertRequests.filter(request => + request.path.endsWith('/App.jsx'), + ) + + expect(componentUpserts).toHaveLength(1) + + const fullComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body) + expect(fullComponentSource).toContain('const CounterButton = () =>') + expect(fullComponentSource).toContain('const App = () =>') +}) diff --git a/playwright/rendering-modes.spec.ts b/playwright/rendering-modes.spec.ts index 051b6f9..27428d2 100644 --- a/playwright/rendering-modes.spec.ts +++ b/playwright/rendering-modes.spec.ts @@ -190,6 +190,104 @@ test('requires render button when auto render is disabled', async ({ page }) => await expect(page.locator('#preview-host pre')).toHaveCount(0) }) +test('clears preview when auto render is toggled', async ({ page }) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(1) + + await autoRenderToggle.uncheck() + + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(0) + await expect(page.locator('#preview-host pre')).toHaveCount(0) +}) + +test('shows App-only error when auto render is disabled and App is missing', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + + const autoRenderToggle = page.getByLabel('Auto render') + const renderButton = page.getByRole('button', { name: 'Render' }) + + await autoRenderToggle.uncheck() + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await renderButton.click() + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Error') + await expect(page.locator('#preview-host pre')).toContainText( + 'Expected a function or const named App.', + ) +}) + +test('auto render implicitly wraps source with App in dom and react modes', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('implicit app dom') + + await page.getByRole('combobox', { name: 'Render mode' }).selectOption('react') + await setComponentEditorSource( + page, + 'const Button = () => ', + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText('implicit app react') +}) + +test('auto render implicit App includes multiple component declarations', async ({ + page, +}) => { + await waitForInitialRender(page) + + await ensurePanelToolsVisible(page, 'component') + await page.getByLabel('ShadowRoot').uncheck() + + await setComponentEditorSource( + page, + [ + 'const OtherButton = () => ', + 'const Button = () => ', + ].join('\n'), + ) + + await expect(page.getByRole('status', { name: 'App status' })).toHaveText('Rendered') + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toHaveCount(2) + await expect( + page.getByRole('region', { name: 'Preview output' }).getByRole('button'), + ).toContainText(['bar', 'foo']) +}) + test('persists layout and theme across reload', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index 1fd0cae..5414456 100644 --- a/src/app.js +++ b/src/app.js @@ -16,6 +16,8 @@ import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' import { createPreviewBackgroundController } from './modules/preview-background.js' import { createRenderRuntimeController } from './modules/render-runtime.js' import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' +import { collectTopLevelDeclarations } from './modules/jsx-top-level-declarations.js' +import { ensureJsxTransformSource } from './modules/jsx-transform-runtime.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') @@ -48,6 +50,7 @@ const githubPrComponentPath = document.getElementById('github-pr-component-path' const githubPrStylesPath = document.getElementById('github-pr-styles-path') const githubPrTitle = document.getElementById('github-pr-title') const githubPrBody = document.getElementById('github-pr-body') +const githubPrIncludeAppWrapper = document.getElementById('github-pr-include-app-wrapper') const githubPrSubmit = document.getElementById('github-pr-submit') const viewControlsToggle = document.getElementById('view-controls-toggle') const viewControlsDrawer = document.getElementById('view-controls-drawer') @@ -622,6 +625,18 @@ const getCurrentWritableRepositories = () => const setCurrentSelectedRepository = fullName => byotControls.setSelectedRepository(fullName) +const getTopLevelDeclarations = async source => { + if (typeof source !== 'string' || !source.trim()) { + return [] + } + + const transformJsxSource = await ensureJsxTransformSource({ + cdnImports, + importFromCdnWithFallback, + }) + return collectTopLevelDeclarations({ source, transformJsxSource }) +} + chatDrawerController = createGitHubChatDrawer({ featureEnabled: aiAssistantFeatureEnabled, toggleButton: aiChatToggle, @@ -660,6 +675,7 @@ prDrawerController = createGitHubPrDrawer({ stylesPathInput: githubPrStylesPath, prTitleInput: githubPrTitle, prBodyInput: githubPrBody, + includeAppWrapperToggle: githubPrIncludeAppWrapper, submitButton: githubPrSubmit, statusNode: githubPrStatus, getToken: getCurrentGitHubToken, @@ -668,6 +684,7 @@ prDrawerController = createGitHubPrDrawer({ setSelectedRepository: setCurrentSelectedRepository, getComponentSource: () => getJsxSource(), getStylesSource: () => getCssSource(), + getTopLevelDeclarations, getDrawerSide: () => { const layout = getCurrentLayout() return layout === 'preview-left' ? 'left' : 'right' @@ -1030,6 +1047,7 @@ renderRuntime = createRenderRuntimeController({ renderMode, styleMode, shadowToggle, + isAutoRenderEnabled: () => autoRenderToggle.checked, getCssSource: () => getCssSource(), getJsxSource: () => getJsxSource(), getPreviewHost: () => previewHost, @@ -1214,6 +1232,7 @@ styleMode.addEventListener('change', () => { }) shadowToggle.addEventListener('change', maybeRender) autoRenderToggle.addEventListener('change', () => { + renderRuntime.clearPreview() updateRenderButtonVisibility() if (autoRenderToggle.checked) { renderPreview() diff --git a/src/index.html b/src/index.html index 89cd3ba..dc59bf3 100644 --- a/src/index.html +++ b/src/index.html @@ -785,6 +785,14 @@

Open Pull Request

/> + +