Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,21 @@ Focused follow-up work for `@knighted/develop`.
- Suggested implementation prompt:
- "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run."

4. **Issue #18 continuation (resume from Phase 3)**
4. **Issue #18 continuation (finish remaining Phase 3 scope)**
- Current rollout status:
- Phase 0 complete: feature flag + scaffolding.
- Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering.
- Phase 2 complete: separate AI chat drawer UX, streaming-first responses with non-stream fallback, selected repository context plumbing, and README fine-grained PAT setup links.
- Implement the next slice first (Phase 3):
- Phase 3 partially complete: PR-prep filename/path groundwork landed via the Open PR drawer with repository-scoped persistence and stricter path validation.
- Phase 4 complete: open PR flow from editor content (branch creation, file upserts, PR creation), confirmation UX, loading/success states, and toast feedback.
- Post-implementation hardening complete: traversal/path validation edge cases, trailing-slash rejection, writable-repo select reset behavior during loading/error states, and a JS-driven Playwright readiness check.
- Implement the next slice (remaining Phase 3 assistant features):
- Add mode-aware recommendation behavior so the assistant strongly adapts suggestions to current render mode and style mode.
- Add an editor update workflow where the assistant can propose structured edits and the user can apply to Component and Styles editors with explicit confirmation.
- Add filename groundwork for upcoming PR flows by allowing user-defined Component and Styles file names, persisted per selected repository.
- Keep behavior and constraints aligned with current implementation:
- Keep everything behind the existing browser-only AI feature flag.
- Preserve BYOT token semantics (localStorage persistence until user deletes).
- Keep CDN-first runtime behavior and existing fallback model.
- Do not add dependencies without explicit approval.
- Phase 3 mini-spec (agent implementation prompt):
- "Continue Issue #18 in @knighted/develop from the current Phase 2 baseline. Implement Phase 3 with three 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. (3) Add PR-prep filename metadata: introduce user-editable fields for Component filename and Styles filename in AI controls, validate simple safe filename format, and persist/reload values scoped to selected repository so Phase 4 PR write flow can reuse them. 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, apply/undo editor actions, and repository-scoped filename persistence."
- 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."
250 changes: 246 additions & 4 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,21 @@ type ChatRequestBody = {
stream?: boolean
}

type CreateRefRequestBody = {
ref?: string
sha?: string
}

type PullRequestCreateBody = {
head?: string
base?: string
}

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) => {
Expand Down Expand Up @@ -124,6 +135,18 @@ const ensureAiChatDrawerOpen = async (page: Page) => {
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 connectByotWithSingleRepo = async (page: Page) => {
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
Expand All @@ -144,9 +167,8 @@ const connectByotWithSingleRepo = async (page: Page) => {

await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890')
await page.locator('#github-token-add').click()
await expect(page.locator('#github-repo-select')).toHaveValue(
'knightedcodemonkey/develop',
)
await expect(page.locator('#status')).toHaveText('Loaded 1 writable repositories')
await expect(page.locator('#github-pr-toggle')).toBeVisible()
}

const expectCollapseButtonState = async (
Expand Down Expand Up @@ -187,6 +209,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page })
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 ({
Expand All @@ -199,6 +223,7 @@ test('BYOT controls render when feature flag is enabled by query param', async (
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 ({
Expand Down Expand Up @@ -476,6 +501,8 @@ test('AI chat falls back to non-streaming response when streaming fails', async
})

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,
Expand Down Expand Up @@ -506,7 +533,9 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
await page.locator('#github-token-add').click()

const repoSelect = page.locator('#github-repo-select')
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')

Expand All @@ -515,11 +544,224 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {

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<string, unknown> }> = []
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 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<string, unknown>
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 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)

Expand Down
Loading