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
304 changes: 285 additions & 19 deletions playwright/github-pr-drawer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
connectByotWithSingleRepo,
ensureOpenPrDrawerOpen,
mockRepositoryBranches,
resetWorkbenchStorage,
setComponentEditorSource,
setStylesEditorSource,
waitForAppReady,
Expand Down Expand Up @@ -102,10 +103,45 @@ const removeSavedGitHubToken = async (page: Page) => {
await expect(dialog).not.toHaveAttribute('open', '')
}

const openStoredWorkspaceContextById = async (page: Page, workspaceId: string) => {
const select = page.getByLabel('Stored local editor contexts')
const openButton = page.locator('#workspaces-open')

if (!(await select.isVisible())) {
await page.getByRole('button', { name: 'Workspaces' }).click()
}

await expect(select).toBeVisible()

await expect
.poll(async () => {
return select.evaluate(
(element, id) =>
element instanceof HTMLSelectElement &&
Array.from(element.options).some(option => option.value === id),
workspaceId,
)
})
.toBe(true)

await expect
.poll(async () => {
await select.selectOption(workspaceId)
const selectedValue = await select.inputValue()
return selectedValue === workspaceId && (await openButton.isEnabled())
})
.toBe(true)

await openButton.click()
}

const openMostRecentStoredWorkspaceContext = async (page: Page) => {
await page.getByRole('button', { name: 'Workspaces' }).click()
const select = page.getByLabel('Stored local editor contexts')

if (!(await select.isVisible())) {
await page.getByRole('button', { name: 'Workspaces' }).click()
}

const select = page.locator('#workspaces-select')
await expect(select).toBeVisible()

const firstContextId = await select.evaluate(element => {
Expand All @@ -118,20 +154,7 @@ const openMostRecentStoredWorkspaceContext = async (page: Page) => {
})

expect(firstContextId).not.toBe('')
await select.selectOption(firstContextId)
await page.locator('#workspaces-open').click()
}

const openStoredWorkspaceContextById = async (page: Page, workspaceId: string) => {
const select = page.locator('#workspaces-select')

if (!(await select.isVisible())) {
await page.locator('#workspaces-toggle').click()
}

await expect(select).toBeVisible()
await select.selectOption(workspaceId)
await page.locator('#workspaces-open').click()
await openStoredWorkspaceContextById(page, firstContextId)
}

const seedLocalWorkspaceContexts = async (
Expand Down Expand Up @@ -846,9 +869,229 @@ test('Open PR drawer can filter stored local contexts by search', async ({ page
expect(labels).toEqual(['Select a stored local context', 'local:Beta local context'])
})

test('Blank-slate startup persists inactive local workspace before PAT', async ({
page,
}) => {
await resetWorkbenchStorage(page)

await waitForAppReady(page, `${appEntryPath}`)

await expect
.poll(async () => {
const records = await getAllWorkspaceRecords(page)
if (!Array.isArray(records) || records.length === 0) {
return false
}

const latest = records.slice().sort((a, b) => {
const aLastModified =
typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified)
? a.lastModified
: 0
const bLastModified =
typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified)
? b.lastModified
: 0
return bLastModified - aLastModified
})[0]

return (
latest?.prContextState === 'inactive' &&
latest?.prNumber === null &&
typeof latest?.repo === 'string'
)
})
.toBe(true)
})

test('Fresh PAT bootstrap persists drawer head metadata to IDB', async ({ page }) => {
const repositoryFullName = 'knightedcodemonkey/contract-case'

await resetWorkbenchStorage(page)

await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 12,
owner: { login: 'knightedcodemonkey' },
name: 'contract-case',
full_name: repositoryFullName,
default_branch: 'main',
permissions: { push: true },
},
]),
})
})

await mockRepositoryBranches(page, {
[repositoryFullName]: ['main', 'release'],
})

await waitForAppReady(page, `${appEntryPath}`)

await page
.getByRole('textbox', { name: 'GitHub token' })
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()

await ensureOpenPrDrawerOpen(page)

await expect
.poll(async () => {
const selectedRepository = await page
.getByLabel('Pull request repository')
.inputValue()
const drawerHead = await page.getByLabel('Head').inputValue()
const records = await getAllWorkspaceRecords(page)

const latestRecord = records
.filter(record => record?.repo === selectedRepository)
.sort((a, b) => {
const aLastModified =
typeof a?.lastModified === 'number' && Number.isFinite(a.lastModified)
? a.lastModified
: 0
const bLastModified =
typeof b?.lastModified === 'number' && Number.isFinite(b.lastModified)
? b.lastModified
: 0
return bLastModified - aLastModified
})[0]

return (
Boolean(selectedRepository) &&
Boolean(drawerHead) &&
Boolean(latestRecord) &&
latestRecord.repo === selectedRepository &&
latestRecord.head === drawerHead
)
})
.toBe(true)
})

for (const prContextState of ['inactive', 'disconnected', 'closed'] as const) {
test(`Head stays fixed across repository changes for ${prContextState} workspace context`, async ({
page,
browserName,
}) => {
// WebKit-only quarantine: keep these specs active on Chromium while CI flake is investigated.
test.fixme(
browserName === 'webkit',
'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.',
)

const sourceRepository = 'knightedcodemonkey/contract-case'
const targetRepository = 'knightedcodemonkey/develop-sandbox'
const workspaceHead = 'feat/component-j101'
const workspaceId = buildWorkspaceRecordId({
repositoryFullName: sourceRepository,
headBranch: workspaceHead,
})

await resetWorkbenchStorage(page)

await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 12,
owner: { login: 'knightedcodemonkey' },
name: 'contract-case',
full_name: sourceRepository,
default_branch: 'main',
permissions: { push: true },
},
{
id: 13,
owner: { login: 'knightedcodemonkey' },
name: 'develop-sandbox',
full_name: targetRepository,
default_branch: 'main',
permissions: { push: true },
},
]),
})
})

await mockRepositoryBranches(page, {
[sourceRepository]: ['main', 'release', workspaceHead],
[targetRepository]: ['main', 'release'],
})

await waitForAppReady(page, `${appEntryPath}`)

await seedLocalWorkspaceContexts(page, [
{
id: workspaceId,
repo: sourceRepository,
base: 'main',
head: workspaceHead,
prTitle: '',
prNumber: null,
prContextState,
renderMode: 'dom',
tabs: [
{
id: 'component',
name: 'App.tsx',
path: 'src/components/App.tsx',
language: 'javascript-jsx',
role: 'entry',
isActive: true,
content: 'export const App = () => <main>Workspace context</main>',
},
{
id: 'styles',
name: 'app.css',
path: 'src/styles/app.css',
language: 'css',
role: 'module',
isActive: false,
content: 'main { color: #111; }',
},
],
activeTabId: 'component',
},
])

await page
.getByRole('textbox', { name: 'GitHub token' })
.fill('github_pat_fake_chat_1234567890')
await page.getByRole('button', { name: 'Add GitHub token' }).click()

await openStoredWorkspaceContextById(page, workspaceId)

await ensureOpenPrDrawerOpen(page)
await expect(page.getByLabel('Pull request repository')).toHaveValue(sourceRepository)
await expect(page.getByLabel('Head')).toHaveValue(workspaceHead)

await page.getByLabel('Pull request repository').selectOption(targetRepository)

await expect(page.getByLabel('Head')).toHaveValue(workspaceHead)
await expect
.poll(async () => {
const record = await getWorkspaceTabsRecord(page, { headBranch: workspaceHead })
return record?.head === workspaceHead
})
.toBe(true)
})
}

test('Open PR keeps inactive workspace record when repository changes', async ({
page,
browserName,
}) => {
// WebKit-only quarantine: keep this spec active on Chromium while CI flake is investigated.
test.fixme(
browserName === 'webkit',
'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.',
)

const oldRepository = 'knightedcodemonkey/contract-case'
const newRepository = 'knightedcodemonkey/develop-sandbox'
const headBranch = 'feat/component-sync'
Expand Down Expand Up @@ -1019,9 +1262,7 @@ test('Open PR keeps inactive workspace record when repository changes', async ({
const repoSelect = page.getByLabel('Pull request repository')
await expect(repoSelect).toHaveValue(oldRepository)

await page.getByRole('button', { name: 'Workspaces' }).click()
await page.locator('#workspaces-select').selectOption(oldWorkspaceId)
await page.locator('#workspaces-open').click()
await openStoredWorkspaceContextById(page, oldWorkspaceId)

await ensureOpenPrDrawerOpen(page)
await repoSelect.selectOption(newRepository)
Expand Down Expand Up @@ -1694,6 +1935,21 @@ test('Active PR context disconnect uses local-only confirmation flow', async ({
).length
})
.toBe(0)
await expect
.poll(async () => {
const records = await getAllWorkspaceRecords(page)
const localRecord = records.find(
record =>
typeof record?.id === 'string' &&
record.id.startsWith('local_') &&
record?.repo === 'knightedcodemonkey/develop' &&
record?.prContextState === 'inactive',
)

const localHead = typeof localRecord?.head === 'string' ? localRecord.head : ''
return /^feat\/component-[a-z0-9]+-[a-z0-9]+(?:-\d+)?$/.test(localHead)
})
.toBe(true)
expect(closePullRequestRequestCount).toBe(0)

await waitForAppReady(page, `${appEntryPath}`)
Expand Down Expand Up @@ -2146,6 +2402,9 @@ test('Active PR context rehydrates after token remove and re-add', async ({ page
await expect(
page.getByRole('button', { name: 'Push commit to active pull request branch' }),
).toBeVisible()
await expect
.poll(async () => page.getByRole('textbox', { name: 'Head' }).inputValue())
.toBe(githubHeadBranch)

await expect
.poll(async () => {
Expand Down Expand Up @@ -3294,7 +3553,14 @@ test('Active PR context push commit uses Git Database API atomic path by default

test('Open PR uses module tab paths when stale target file paths collide', async ({
page,
browserName,
}) => {
// WebKit-only quarantine: keep this spec active on Chromium while CI flake is investigated.
test.fixme(
browserName === 'webkit',
'Temporarily quarantined on WebKit due CI-only Workspaces drawer timing flake.',
)

const treeRequests: Array<Record<string, unknown>> = []
const commitRequests: Array<Record<string, unknown>> = []

Expand Down
12 changes: 12 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,9 @@ const byotControls = createGitHubByotControls({
githubAiContextState.selectedRepository = selectedRepository
chatDrawerController.setSelectedRepository(selectedRepository)
prDrawerController.setSelectedRepository(selectedRepository)
const isBootstrappingTokenSession =
typeof githubAiContextState.token !== 'string' ||
githubAiContextState.token.trim().length === 0

if (!activeWorkspaceRecordId || activeWorkspaceCreatedAt === null) {
void loadPreferredWorkspaceContext()
Expand All @@ -523,6 +526,14 @@ const byotControls = createGitHubByotControls({
.catch(() => {
/* noop */
})
} else if (isBootstrappingTokenSession) {
void loadPreferredWorkspaceContext()
.then(() => {
prDrawerController.syncRepositories()
})
.catch(() => {
/* noop */
})
}
}

Expand Down Expand Up @@ -1426,6 +1437,7 @@ bindAppEventsAndStart({
setCdnLoading,
},
workspaceUi: {
githubPrRepoSelect,
githubPrBaseBranch,
githubPrHeadBranch,
githubPrTitle,
Expand Down
Loading
Loading