Skip to content

Commit 979c9cf

Browse files
feat: enhanced auto render and pr publish. (#38)
1 parent 9017e66 commit 979c9cf

12 files changed

Lines changed: 954 additions & 29 deletions

docs/next-steps.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,27 @@ Focused follow-up work for `@knighted/develop`.
3737
- Do not add dependencies without explicit approval.
3838
- Remaining Phase 3 mini-spec (agent implementation prompt):
3939
- "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."
40+
41+
5. **Phase 2 UX/UI continuation: fixed editor tabs first pass (Component, Styles, App)**
42+
- Continue the tabs/editor UX work with a constrained first implementation that supports exactly three editor tabs: Component, Styles, and App.
43+
- Do not introduce arbitrary/custom tab names in this pass; treat custom naming as future scope after baseline tab behavior is stable.
44+
- 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.
45+
- Ensure assistant/editor integration remains compatible with this model (edits should target one of the fixed tabs) without expanding to dynamic tab metadata yet.
46+
- Suggested implementation prompt:
47+
- "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."
48+
49+
6. **Document implicit App strict-flow behavior (auto render)**
50+
- Add a short behavior matrix in docs that explains when implicit App wrapping is allowed versus when users must define `App` explicitly.
51+
- Include concrete Component editor examples for each case so reviewer/user expectations are clear.
52+
- Suggested example cases to document:
53+
- Allowed implicit wrap (standalone top-level JSX, no imports/declarations), for example:
54+
- `(<button type="button">Standalone</button>) as any`
55+
- Requires explicit `App` (top-level JSX with declarations/imports), for example:
56+
- `const label = 'Hello'`
57+
- `const Button = () => <button>{label}</button>`
58+
- `(<Button />) as any`
59+
- Recommended explicit pattern, for example:
60+
- `const Button = () => <button>Hello</button>`
61+
- `const App = () => <Button />`
62+
- Suggested implementation prompt:
63+
- "Document the current implicit App behavior in @knighted/develop for auto-render mode using a compact behavior matrix and concrete component-editor snippets. Clearly distinguish supported implicit wrapping from cases that intentionally require an explicit App (such as top-level JSX mixed with imports/declarations). Keep docs concise, aligned with current runtime behavior, and include at least one positive and one explicit-error example."

playwright/diagnostics.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ test('clear component diagnostics resets rendered lint-issue status pill', async
346346
await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/)
347347
})
348348

349-
test('component lint ignores unused App View and render bindings', async ({ page }) => {
349+
test('component lint ignores only unused App binding', async ({ page }) => {
350350
await waitForInitialRender(page)
351351

352352
await setComponentEditorSource(
@@ -361,20 +361,20 @@ test('component lint ignores unused App View and render bindings', async ({ page
361361
await runComponentLint(page)
362362

363363
await ensureDiagnosticsDrawerOpen(page)
364-
await expect(page.getByText('No Biome issues found.')).toBeVisible()
365364

366365
const diagnosticsToggle = page.getByRole('button', { name: /^Diagnostics/ })
367-
await expect(page.getByText('Rendered', { exact: true })).toHaveClass(/status--neutral/)
368-
await expect(diagnosticsToggle).toHaveText('Diagnostics')
369-
await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--ok/)
366+
await expect(page.getByText(/Rendered \(Lint issues: [1-9]\d*\)/)).toHaveClass(
367+
/status--error/,
368+
)
369+
await expect(diagnosticsToggle).toHaveText(/Diagnostics \([1-9]\d*\)/)
370+
await expect(diagnosticsToggle).toHaveClass(/diagnostics-toggle--error/)
370371

371372
const diagnosticsText = await page.getByRole('complementary').innerText()
373+
expect(diagnosticsText).toContain('Biome reported issues.')
372374
expect(diagnosticsText).not.toContain('This variable App is unused')
373-
expect(diagnosticsText).not.toContain('This variable View is unused')
374-
expect(diagnosticsText).not.toContain('This variable render is unused')
375375
expect(diagnosticsText).not.toContain('This function App is unused')
376-
expect(diagnosticsText).not.toContain('This function View is unused')
377-
expect(diagnosticsText).not.toContain('This function render is unused')
376+
expect(diagnosticsText).toContain('This function View is unused')
377+
expect(diagnosticsText).toContain('This function render is unused')
378378
})
379379

380380
test('component lint with unresolved issues enters pending diagnostics state while typing', async ({

playwright/github-pr-drawer.spec.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import {
88
connectByotWithSingleRepo,
99
ensureOpenPrDrawerOpen,
1010
mockRepositoryBranches,
11+
setComponentEditorSource,
1112
waitForAppReady,
1213
} from './helpers/app-test-helpers.js'
1314

15+
const decodeGitHubFileBodyContent = (body: Record<string, unknown>) => {
16+
const encoded = typeof body.content === 'string' ? body.content : ''
17+
return Buffer.from(encoded, 'base64').toString('utf8')
18+
}
19+
1420
test('Open PR drawer confirms and submits component/styles filepaths', async ({
1521
page,
1622
}) => {
@@ -451,3 +457,279 @@ test('Open PR drawer rejects trailing slash file paths', async ({ page }) => {
451457
)
452458
await expect(page.getByRole('dialog')).toBeHidden()
453459
})
460+
461+
test('Open PR drawer include App wrapper checkbox defaults off and resets on reopen', async ({
462+
page,
463+
}) => {
464+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
465+
await connectByotWithSingleRepo(page)
466+
await ensureOpenPrDrawerOpen(page)
467+
468+
const includeWrapperToggle = page.getByLabel(
469+
'Include App wrapper in committed component source',
470+
)
471+
await expect(includeWrapperToggle).not.toBeChecked()
472+
473+
await includeWrapperToggle.check()
474+
await expect(includeWrapperToggle).toBeChecked()
475+
476+
await page.getByRole('button', { name: 'Close open pull request drawer' }).click()
477+
await ensureOpenPrDrawerOpen(page)
478+
479+
await expect(includeWrapperToggle).not.toBeChecked()
480+
})
481+
482+
test('Open PR drawer strips App wrapper from committed component source by default', async ({
483+
page,
484+
}) => {
485+
const upsertRequests: Array<{ path: string; body: Record<string, unknown> }> = []
486+
487+
await page.route('https://api.github.com/user/repos**', async route => {
488+
await route.fulfill({
489+
status: 200,
490+
contentType: 'application/json',
491+
body: JSON.stringify([
492+
{
493+
id: 11,
494+
owner: { login: 'knightedcodemonkey' },
495+
name: 'develop',
496+
full_name: 'knightedcodemonkey/develop',
497+
default_branch: 'main',
498+
permissions: { push: true },
499+
},
500+
]),
501+
})
502+
})
503+
504+
await mockRepositoryBranches(page, {
505+
'knightedcodemonkey/develop': ['main', 'release'],
506+
})
507+
508+
await page.route(
509+
'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
510+
async route => {
511+
await route.fulfill({
512+
status: 200,
513+
contentType: 'application/json',
514+
body: JSON.stringify({
515+
ref: 'refs/heads/main',
516+
object: { type: 'commit', sha: 'abc123mainsha' },
517+
}),
518+
})
519+
},
520+
)
521+
522+
await page.route(
523+
'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
524+
async route => {
525+
await route.fulfill({
526+
status: 201,
527+
contentType: 'application/json',
528+
body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }),
529+
})
530+
},
531+
)
532+
533+
await page.route(
534+
'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
535+
async route => {
536+
const request = route.request()
537+
const method = request.method()
538+
const path =
539+
new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path'
540+
541+
if (method === 'GET') {
542+
await route.fulfill({
543+
status: 404,
544+
contentType: 'application/json',
545+
body: JSON.stringify({ message: 'Not Found' }),
546+
})
547+
return
548+
}
549+
550+
const body = request.postDataJSON() as Record<string, unknown>
551+
upsertRequests.push({ path: decodeURIComponent(path), body })
552+
await route.fulfill({
553+
status: 201,
554+
contentType: 'application/json',
555+
body: JSON.stringify({ commit: { sha: 'commit-sha' } }),
556+
})
557+
},
558+
)
559+
560+
await page.route(
561+
'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
562+
async route => {
563+
await route.fulfill({
564+
status: 201,
565+
contentType: 'application/json',
566+
body: JSON.stringify({
567+
number: 101,
568+
html_url: 'https://github.com/knightedcodemonkey/develop/pull/101',
569+
}),
570+
})
571+
},
572+
)
573+
574+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
575+
await connectByotWithSingleRepo(page)
576+
577+
const componentSource = [
578+
'const CounterButton = () => <button type="button">Counter</button>',
579+
'const App = () => <CounterButton />',
580+
].join('\n')
581+
582+
await setComponentEditorSource(page, componentSource)
583+
await ensureOpenPrDrawerOpen(page)
584+
585+
await page.getByLabel('Head').fill('develop/repo/editor-sync-without-app')
586+
await page.getByRole('button', { name: 'Open PR' }).last().click()
587+
await page.getByRole('dialog').getByRole('button', { name: 'Open PR' }).click()
588+
589+
await expect(
590+
page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
591+
).toContainText(
592+
'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101',
593+
)
594+
595+
const componentUpserts = upsertRequests.filter(request =>
596+
request.path.endsWith('/App.jsx'),
597+
)
598+
599+
expect(componentUpserts).toHaveLength(1)
600+
601+
const strippedComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body)
602+
603+
expect(strippedComponentSource).toContain('const CounterButton = () =>')
604+
expect(strippedComponentSource).not.toContain('const App = () =>')
605+
})
606+
607+
test('Open PR drawer includes App wrapper in committed source when toggled on', async ({
608+
page,
609+
}) => {
610+
const upsertRequests: Array<{ path: string; body: Record<string, unknown> }> = []
611+
612+
await page.route('https://api.github.com/user/repos**', async route => {
613+
await route.fulfill({
614+
status: 200,
615+
contentType: 'application/json',
616+
body: JSON.stringify([
617+
{
618+
id: 11,
619+
owner: { login: 'knightedcodemonkey' },
620+
name: 'develop',
621+
full_name: 'knightedcodemonkey/develop',
622+
default_branch: 'main',
623+
permissions: { push: true },
624+
},
625+
]),
626+
})
627+
})
628+
629+
await mockRepositoryBranches(page, {
630+
'knightedcodemonkey/develop': ['main', 'release'],
631+
})
632+
633+
await page.route(
634+
'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
635+
async route => {
636+
await route.fulfill({
637+
status: 200,
638+
contentType: 'application/json',
639+
body: JSON.stringify({
640+
ref: 'refs/heads/main',
641+
object: { type: 'commit', sha: 'abc123mainsha' },
642+
}),
643+
})
644+
},
645+
)
646+
647+
await page.route(
648+
'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
649+
async route => {
650+
await route.fulfill({
651+
status: 201,
652+
contentType: 'application/json',
653+
body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-app-wrapper' }),
654+
})
655+
},
656+
)
657+
658+
await page.route(
659+
'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
660+
async route => {
661+
const request = route.request()
662+
const method = request.method()
663+
const path =
664+
new URL(request.url()).pathname.split('/contents/')[1] ?? 'unknown-file-path'
665+
666+
if (method === 'GET') {
667+
await route.fulfill({
668+
status: 404,
669+
contentType: 'application/json',
670+
body: JSON.stringify({ message: 'Not Found' }),
671+
})
672+
return
673+
}
674+
675+
const body = request.postDataJSON() as Record<string, unknown>
676+
upsertRequests.push({ path: decodeURIComponent(path), body })
677+
await route.fulfill({
678+
status: 201,
679+
contentType: 'application/json',
680+
body: JSON.stringify({ commit: { sha: 'commit-sha' } }),
681+
})
682+
},
683+
)
684+
685+
await page.route(
686+
'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
687+
async route => {
688+
await route.fulfill({
689+
status: 201,
690+
contentType: 'application/json',
691+
body: JSON.stringify({
692+
number: 101,
693+
html_url: 'https://github.com/knightedcodemonkey/develop/pull/101',
694+
}),
695+
})
696+
},
697+
)
698+
699+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
700+
await connectByotWithSingleRepo(page)
701+
702+
await setComponentEditorSource(
703+
page,
704+
[
705+
'const CounterButton = () => <button type="button">Counter</button>',
706+
'const App = () => <CounterButton />',
707+
].join('\n'),
708+
)
709+
await ensureOpenPrDrawerOpen(page)
710+
711+
const includeWrapperToggle = page.getByLabel(
712+
'Include App wrapper in committed component source',
713+
)
714+
await includeWrapperToggle.check()
715+
716+
await page.getByLabel('Head').fill('develop/repo/editor-sync-with-app')
717+
await page.getByRole('button', { name: 'Open PR' }).last().click()
718+
await page.getByRole('dialog').getByRole('button', { name: 'Open PR' }).click()
719+
720+
await expect(
721+
page.getByRole('status', { name: 'Open pull request status', includeHidden: true }),
722+
).toContainText(
723+
'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/101',
724+
)
725+
726+
const componentUpserts = upsertRequests.filter(request =>
727+
request.path.endsWith('/App.jsx'),
728+
)
729+
730+
expect(componentUpserts).toHaveLength(1)
731+
732+
const fullComponentSource = decodeGitHubFileBodyContent(componentUpserts[0].body)
733+
expect(fullComponentSource).toContain('const CounterButton = () =>')
734+
expect(fullComponentSource).toContain('const App = () =>')
735+
})

0 commit comments

Comments
 (0)