1377 lines
86 KiB
TypeScript
1377 lines
86 KiB
TypeScript
/** Worktree Sessions and the change request panel through the real optional bundle, a Git checkout, and a scripted GitHub CLI. */
|
||
import { execFile } from 'node:child_process'
|
||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||
import { homedir, tmpdir } from 'node:os'
|
||
import { basename, join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { promisify } from 'node:util'
|
||
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
|
||
import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||
import { initialShortcutConfig, type DesktopShortcutInput, type ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||
import { chromium, type Browser, type Page } from 'playwright'
|
||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||
import {
|
||
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole,
|
||
webSnapshotMode, type WebScaffold,
|
||
} from './scaffold.ts'
|
||
import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts'
|
||
|
||
declare global {
|
||
interface Window {
|
||
forgeKeyboard: { readonly revision: ShortcutRevision; deliver(input: DesktopShortcutInput): void }
|
||
}
|
||
}
|
||
|
||
const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url))
|
||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url))
|
||
const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url))
|
||
const MODE = webSnapshotMode()
|
||
const PULL = 'https://github.com/octo/demo/pull/7'
|
||
/** A draft change request that the double lets the panel mark ready and merge. */
|
||
const DRAFT = 'https://github.com/octo/demo/pull/9'
|
||
/** Message sent after the worktree is removed, which waits until the worktree is restored. */
|
||
const FOLLOW_UP = 'Summarize the failed check'
|
||
/** Reply to {@link FOLLOW_UP}, a model call appended after the borrowed scenario's single one. */
|
||
const FOLLOW_UP_REPLY = 'RESTORED'
|
||
const run = promisify(execFile)
|
||
|
||
/**
|
||
* GitHub CLI double: signed in to github.com, with octo/demo allowing every
|
||
* merge method and the viewer's usual one squash. GraphQL serves pull request
|
||
* 7 with one failed and one passed check run, one unresolved thread, and a
|
||
* required review, and pull request 9, an approved draft whose checks passed.
|
||
* `gh pr ready 9` and the REST merge of pull request 9 change what later
|
||
* queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or
|
||
* fails with `pr-N.refused` on standard error when that file exists, and the raw
|
||
* contents API serves `contents.json`, keyed by `path@commit`. Every invocation
|
||
* is appended to `calls.jsonl`.
|
||
*/
|
||
const GH = `#!/usr/bin/env node
|
||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||
import { dirname, join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
const args = process.argv.slice(2)
|
||
const here = dirname(fileURLToPath(import.meta.url))
|
||
appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n')
|
||
const statePath = join(here, 'state.json')
|
||
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null }
|
||
const save = () => { writeFileSync(statePath, JSON.stringify(state)) }
|
||
if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') {
|
||
const fields = args[args.indexOf('--json') + 1]
|
||
process.stdout.write(JSON.stringify(fields === 'url'
|
||
? { url: 'https://github.com/octo/demo/pull/7' }
|
||
: { number: 7, title: 'Add the pull request panel' }))
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') {
|
||
state.ready = true
|
||
save()
|
||
process.stderr.write('Pull request octo/demo#9 is marked as "ready for review"\\n')
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') {
|
||
state.mergedAt = new Date().toISOString()
|
||
save()
|
||
process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'api' && args[3] === 'repos/octo/demo/pulls/7') {
|
||
process.stdout.write(JSON.stringify({ head: { sha: 'a'.repeat(40) }, state: 'open' }))
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'api' && args[4] === 'POST' && ['repos/octo/demo/pulls/7/comments', 'repos/octo/demo/pulls/7/reviews'].includes(args[5])) {
|
||
const body = JSON.parse(readFileSync(0, 'utf8'))
|
||
appendFileSync(join(here, 'reviews.jsonl'), JSON.stringify({ endpoint: args[5], body }) + '\\n')
|
||
process.stdout.write(JSON.stringify({ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-1' }))
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'pr' && args[1] === 'diff') {
|
||
const refused = join(here, 'pr-' + args[2] + '.refused')
|
||
if (existsSync(refused)) {
|
||
process.stderr.write(readFileSync(refused, 'utf8'))
|
||
process.exit(1)
|
||
}
|
||
await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'), resolve))
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') {
|
||
const [resource, query] = args[5].split('?')
|
||
const path = decodeURIComponent(resource.slice('repos/octo/demo/contents/'.length))
|
||
const text = JSON.parse(readFileSync(join(here, 'contents.json'), 'utf8'))[path + '@' + query.slice('ref='.length)]
|
||
if (text === undefined) {
|
||
process.stderr.write('gh: Not Found (HTTP 404)\\n')
|
||
process.exit(1)
|
||
}
|
||
process.stdout.write(text)
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'auth' && args[1] === 'status') {
|
||
process.stdout.write('github.com\\n Logged in to github.com account octo\\n')
|
||
process.exit(0)
|
||
}
|
||
if (args[0] === 'api' && args[1] === 'graphql') {
|
||
const query = args.find(arg => arg.startsWith('query=')).slice('query='.length)
|
||
const check = (name, conclusion, completedAt, databaseId = 101) => ({
|
||
__typename: 'CheckRun', id: 'check-' + databaseId, name, status: 'COMPLETED', conclusion, databaseId,
|
||
detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt,
|
||
checkSuite: { app: { id: 'github-actions' }, workflowRun: { event: 'pull_request', workflow: { id: 'ci', name: 'CI' } } },
|
||
})
|
||
const pulls = {
|
||
7: {
|
||
number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
|
||
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40),
|
||
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED',
|
||
reviewThreads: { nodes: [{ isResolved: false }] },
|
||
commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: {
|
||
pageInfo: { hasNextPage: true, endCursor: 'checks-100' },
|
||
nodes: Array.from({ length: 100 }, (_, i) => check('lint', 'FAILURE', '2026-10-08T00:00:42Z', i + 1)),
|
||
} } } }] },
|
||
},
|
||
9: {
|
||
number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
|
||
isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' },
|
||
headRefName: 'feat/merge', headRefOid: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
|
||
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] },
|
||
commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: {
|
||
pageInfo: { hasNextPage: false, endCursor: null }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')],
|
||
} } } }] },
|
||
},
|
||
}
|
||
if (query.includes('node(id: $id)')) {
|
||
process.stdout.write(JSON.stringify({ data: { node: { contexts: {
|
||
pageInfo: { hasNextPage: false, endCursor: null },
|
||
nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')],
|
||
} } } }))
|
||
process.exit(0)
|
||
}
|
||
const settings = {
|
||
squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH',
|
||
}
|
||
const data = {}
|
||
let repository
|
||
for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) {
|
||
if (match[1] !== undefined) data[repository = match[1]] = { ...settings }
|
||
else data[repository][match[2]] = pulls[Number(match[3])] ?? null
|
||
}
|
||
process.stdout.write(JSON.stringify({ data }))
|
||
process.exit(0)
|
||
}
|
||
process.stderr.write('unexpected gh invocation\\n')
|
||
process.exit(1)
|
||
`
|
||
|
||
/** Head commit of pull request 7 in the GitHub CLI double. */
|
||
const HEAD7 = 'a'.repeat(40)
|
||
|
||
/** What gh 2.100 prints for the diff of pull request 9 when GitHub refuses it for having over 300 files. */
|
||
const PR9_REFUSAL = [
|
||
'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull '
|
||
+ 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/9)',
|
||
'PullRequest.diff too_large',
|
||
'',
|
||
].join('\n')
|
||
|
||
/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */
|
||
function panelLine(n: number, side: 'old' | 'new'): string {
|
||
if (n === 2) return side === 'old' ? 'export const n2 = 2' : 'export const n2 = \'two\''
|
||
if (n === 7) return 'export const n7 = "Long unchanged context stays wrapped while switching between Sessions and reviewing the same file"'
|
||
if (n === 15) return side === 'old' ? 'export const n15 = 15' : 'export const n15 = \'fifteen\''
|
||
return `export const n${n} = ${n}`
|
||
}
|
||
|
||
/** Patch of pull request 7 as `gh pr diff` prints it: two hunks of `src/panel.ts` and the new `docs/panel.md`. */
|
||
const PR7_DIFF = [
|
||
'diff --git a/src/panel.ts b/src/panel.ts',
|
||
'index 1111111..2222222 100644',
|
||
'--- a/src/panel.ts',
|
||
'+++ b/src/panel.ts',
|
||
'@@ -1,5 +1,5 @@',
|
||
` ${panelLine(1, 'new')}`, `-${panelLine(2, 'old')}`, `+${panelLine(2, 'new')}`,
|
||
...[3, 4, 5].map(n => ` ${panelLine(n, 'new')}`),
|
||
'@@ -12,7 +12,7 @@',
|
||
...[12, 13, 14].map(n => ` ${panelLine(n, 'new')}`), `-${panelLine(15, 'old')}`, `+${panelLine(15, 'new')}`,
|
||
...[16, 17, 18].map(n => ` ${panelLine(n, 'new')}`),
|
||
'diff --git a/docs/panel.md b/docs/panel.md',
|
||
'new file mode 100644',
|
||
'index 0000000..3333333',
|
||
'--- /dev/null',
|
||
'+++ b/docs/panel.md',
|
||
'@@ -0,0 +1,2 @@',
|
||
'+# Panel',
|
||
'+Shows the change requests of a Session.',
|
||
'',
|
||
].join('\n')
|
||
|
||
async function git(cwd: string, ...args: string[]): Promise<string> {
|
||
const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } })
|
||
return stdout.trim()
|
||
}
|
||
|
||
/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and the New Session screen of that workspace. */
|
||
interface PanelLaunch {
|
||
readonly scaffold: WebScaffold
|
||
readonly page: Page
|
||
/** Temporary directory of the GitHub CLI double and its call log. */
|
||
readonly root: string
|
||
/** The Session's workspace, a main checkout. */
|
||
readonly project: string
|
||
readonly tripwire: ReturnType<typeof watchConsole>
|
||
}
|
||
|
||
/**
|
||
* Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace.
|
||
* @param failureShot - name of the screenshot saved when the test fails.
|
||
* @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup.
|
||
* @param options - desktop transport, checkout branch, browser input, sandbox runner availability, and diff output limit.
|
||
* @returns the scaffold, page, and checkout.
|
||
*/
|
||
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal',
|
||
options: { desktop?: 'darwin' | 'win32'; branch?: string; hasTouch?: boolean; missingSandbox?: boolean; maxDiffBytes?: number } = {}): Promise<PanelLaunch> {
|
||
const { desktop } = options
|
||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||
const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-'))
|
||
onTestFinished(async () => {
|
||
const failures: unknown[] = []
|
||
await resources.browser?.close().catch((error: unknown) => failures.push(error))
|
||
await resources.scaffold?.close().catch((error: unknown) => failures.push(error))
|
||
await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||
if (failures.length > 0) throw new AggregateError(failures, 'Forge panel teardown failed')
|
||
})
|
||
const gh = join(root, 'gh')
|
||
await writeFile(gh, GH)
|
||
await chmod(gh, 0o755)
|
||
await writeFile(join(root, 'pr-7.diff'), PR7_DIFF)
|
||
await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL)
|
||
const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n`
|
||
await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText }))
|
||
const overlay = join(root, 'forge.patch.yml')
|
||
await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`
|
||
+ (options.maxDiffBytes === undefined ? '' : `- id: forge-panel\n config: ${JSON.stringify({ maxDiffBytes: options.maxDiffBytes })}\n`)
|
||
+ (options.missingSandbox ? `- id: sandbox\n config: ${JSON.stringify({ runnerCommand: [join(root, 'missing-sandbox')], runnerFailureSignatures: ['sandbox startup failed'] })}\n` : ''))
|
||
const override = join(root, 'replay.override.json')
|
||
// The borrowed scenario records one model call, so index 1 appends the reply to the follow-up.
|
||
if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] }))
|
||
if (scenario === 'reference') {
|
||
const command = `PATH=${JSON.stringify(`.fixture-bin:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200`
|
||
const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) }
|
||
await writeFile(override, JSON.stringify({ patches: [
|
||
{ at: 0, entry: { kind: 'chunks', chunks: [
|
||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||
{ type: 'block-end', index: 0, block: call },
|
||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||
] } },
|
||
{ at: 1, entry: { kind: 'chunks', chunks: textReply('Viewed PR 7.') } },
|
||
] }))
|
||
}
|
||
const scaffold = await launchWebScaffold({
|
||
profile: { packages: [{ dir: BUNDLE, enabled: true }] },
|
||
extraOverlayPath: overlay,
|
||
replayFixture: FIXTURE,
|
||
...scenario !== 'normal' ? { replayOverride: override } : {},
|
||
// The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation.
|
||
compareReplaySession: false,
|
||
})
|
||
resources.scaffold = scaffold
|
||
|
||
const project = join(scaffold.workspaceCwd, 'workspace')
|
||
await mkdir(project, { recursive: true })
|
||
await git(project, 'init', '--quiet', '--initial-branch=main')
|
||
await git(project, 'config', 'user.name', 'Forge Panel')
|
||
await git(project, 'config', 'user.email', '[email protected]')
|
||
await git(project, 'config', 'commit.gpgsign', 'false')
|
||
await writeFile(join(project, 'README.md'), 'demo\n')
|
||
await git(project, 'add', 'README.md')
|
||
await git(project, 'commit', '--quiet', '-m', 'init')
|
||
await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git')
|
||
await git(project, 'branch', 'release')
|
||
await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only')
|
||
if (options.branch !== undefined) await git(project, 'checkout', '--quiet', '-b', options.branch)
|
||
if (scenario === 'reference') {
|
||
// The model's confined command must own the CLI double's writable call log.
|
||
const modelBin = join(project, '.fixture-bin')
|
||
await mkdir(modelBin)
|
||
await writeFile(join(modelBin, 'gh'), GH)
|
||
await chmod(join(modelBin, 'gh'), 0o755)
|
||
}
|
||
|
||
const browser = await chromium.launch()
|
||
resources.browser = browser
|
||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
|
||
hasTouch: options.hasTouch ?? false })
|
||
if (desktop !== undefined) {
|
||
// Only Electron's keyboard transport is substituted; the product registry and menus remain real.
|
||
await page.addInitScript(({ device, config }) => {
|
||
const mark = () => { document.documentElement.dataset.platform = device }
|
||
if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark)
|
||
else mark()
|
||
const listeners = new Set<(input: DesktopShortcutInput) => void>()
|
||
Object.assign(window, {
|
||
forgeKeyboard: { revision: config.revision,
|
||
deliver: (input: DesktopShortcutInput) => { for (const listener of listeners) listener(input) } },
|
||
dshDesktop: { protocolVersion: 1,
|
||
shortcuts: { get: async () => config, subscribe: () => () => {}, recording: async () => {}, edit: async () => { throw new Error('Unused preference edit') } },
|
||
keyboard: {
|
||
subscribe: (listener: (input: DesktopShortcutInput) => void) => {
|
||
listeners.add(listener)
|
||
return () => { listeners.delete(listener) }
|
||
},
|
||
closeWindow: async () => {},
|
||
},
|
||
},
|
||
})
|
||
}, { device: desktop, config: { ...initialShortcutConfig(), status: 'ready' as const } })
|
||
}
|
||
const tripwire = watchConsole(page)
|
||
onTestFailed(async () => { await saveFailureShot(page, failureShot) })
|
||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||
return { scaffold, page, root, project, tripwire }
|
||
}
|
||
|
||
/** Stream chunks of a model reply that is one text block. */
|
||
function textReply(text: string): unknown[] {
|
||
return [
|
||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||
{ type: 'text-delta', index: 0, text },
|
||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||
{ type: 'usage', usage: { inputTokens: 50, outputTokens: 2, cacheReadTokens: 0, reasoningTokens: 0 } },
|
||
{ type: 'finish', reason: { kind: 'stop' } },
|
||
]
|
||
}
|
||
|
||
/**
|
||
* Launch the panel and choose the worktree option of the fresh Session from `release`.
|
||
* @param failureShot - name of the screenshot saved when the test fails.
|
||
* @param followUp - whether the replay answers a second model call.
|
||
* @returns the scaffold, page, and checkout.
|
||
*/
|
||
async function requestReleaseWorktree(failureShot: string, followUp = false): Promise<PanelLaunch> {
|
||
const launched = await launchPanel(failureShot, followUp ? 'follow-up' : 'normal')
|
||
const { page } = launched
|
||
const option = page.locator('[data-forge-worktree]')
|
||
await option.waitFor({ timeout: 20_000 })
|
||
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check()
|
||
await option.getByRole('button', { name: '基于 main', exact: true }).click()
|
||
await page.getByRole('menuitem', { name: 'release', exact: true }).click()
|
||
await option.getByRole('button', { name: '基于 release', exact: true }).waitFor()
|
||
return launched
|
||
}
|
||
|
||
/**
|
||
* Send the first prompt of the borrowed scenario.
|
||
* @returns the prompt and the turn's settlement, which resolves to the Session id.
|
||
*/
|
||
async function sendPrompt(page: Page, scaffold: WebScaffold): Promise<{ readonly prompt: string; readonly settled: Promise<SessionId> }> {
|
||
const [prompt] = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
|
||
if (prompt === undefined) throw new Error('The borrowed scenario requires a user prompt')
|
||
const settled = scaffold.whenTurnSettled()
|
||
const composer = page.locator('[data-composer-input]').first()
|
||
await writeComposerDraft(page, composer, prompt)
|
||
await composer.press('Enter')
|
||
return { prompt, settled }
|
||
}
|
||
|
||
function worktreeRecords(events: readonly SessionEvent[]): unknown[] {
|
||
return events.flatMap((event) => {
|
||
const record = pluginRecordOf(event)
|
||
return record?.type === 'plugin:client-ui-forge/worktree' ? [record.data] : []
|
||
})
|
||
}
|
||
|
||
it.skipIf(MODE === 'record')('reads local Git with full access when the sandbox runner cannot start', async () => {
|
||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-permissions', 'normal', { missingSandbox: true })
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
await writeFile(join(project, 'README.md'), 'demo\nchanged\n')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
await page.locator('[data-forge-header]').click()
|
||
const panel = page.locator('[data-forge-panel]')
|
||
await panel.getByText('无法读取 Git 信息', { exact: true }).waitFor({ timeout: 20_000 })
|
||
|
||
const permission = page.locator('button[aria-label^="访问模式"]').first()
|
||
await permission.click()
|
||
await page.getByRole('menuitem', { name: '完全权限', exact: true }).click()
|
||
const confirmation = page.getByRole('dialog', { name: '确认启用完全权限?' })
|
||
await confirmation.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
|
||
await confirmation.getByRole('button', { name: '启用完全权限' }).click()
|
||
const local = page.locator('[data-forge-local]')
|
||
await local.getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true }).waitFor({ timeout: 20_000 })
|
||
await compareOrRefreshGolden(join(EXPECTED, 'full-access.expected.md'), await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE)
|
||
await local.getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true }).click()
|
||
await page.locator('[data-forge-diff-file="README.md"]').getByText('changed', { exact: true }).waitFor()
|
||
|
||
await permission.click()
|
||
await page.getByRole('menuitem', { name: '工作区内修改', exact: true }).click()
|
||
if (!await panel.isVisible()) await page.locator('[data-forge-header]').click()
|
||
await panel.getByText('无法读取 Git 信息', { exact: true }).waitFor({ timeout: 20_000 })
|
||
await expect.poll(async () => (await readPersistedEvents(scaffold, sessionId))
|
||
.filter(event => event.type === 'sandbox/mode').map(event => event.data.mode).slice(-2))
|
||
.toEqual(['danger-full-access', 'workspace-write'])
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('reports incomplete filter configuration and retries after it is corrected', async () => {
|
||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-filter-config', 'normal', { maxDiffBytes: 512 })
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
await writeFile(join(project, 'README.md'), 'demo\nchanged\n')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
await page.locator('[data-forge-header]').click()
|
||
const openChanges = page.locator('[data-forge-local]').getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true })
|
||
await openChanges.waitFor({ timeout: 20_000 })
|
||
const key = `filter.${'long'.repeat(200)}.clean`
|
||
await git(project, 'config', key, 'cat')
|
||
await openChanges.click()
|
||
const changes = page.locator('[data-forge-changes="local"]')
|
||
await changes.getByText('无法完整读取 Git 过滤器配置,尚未执行比较。请检查 Git 配置和读取上限后重试', { exact: true }).waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'filter-config-failed.expected.md'),
|
||
await captureStableAria(page, '[data-forge-changes="local"]', scaffold.workspaceCwd), MODE)
|
||
|
||
await git(project, 'config', '--unset', key)
|
||
await changes.getByRole('button', { name: '重试', exact: true }).click()
|
||
await changes.locator('[data-forge-diff-file="README.md"]').getByText('changed', { exact: true }).waitFor()
|
||
expect(await changes.getByRole('status').count()).toBe(0)
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => {
|
||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
const events = await readPersistedEvents(scaffold, sessionId)
|
||
const toolOutput = events.flatMap(event => event.type === 'tool/result'
|
||
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n')
|
||
expect(toolOutput).toContain('Add the pull request panel')
|
||
const header = page.locator('[data-forge-header]')
|
||
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
|
||
await header.click()
|
||
const change = page.locator(`[data-forge-change="${PULL}"]`)
|
||
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
|
||
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'),
|
||
await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE)
|
||
expect(events.flatMap((event) => {
|
||
const record = pluginRecordOf(event)
|
||
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
|
||
})).toEqual([{ action: 'reference', url: PULL }])
|
||
const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url'])
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its change request, and restores the removed worktree', async () => {
|
||
const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel', true)
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
await page.locator('[data-forge-creation]').waitFor({ state: 'detached' })
|
||
await page.locator('[data-forge-worktree]').waitFor({ state: 'detached' })
|
||
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('The worktree Session must stay loaded')
|
||
const directory = scaffold.ctx.workingDirectory.get(session)
|
||
const branch = basename(directory)
|
||
expect(directory).toBe(join(project, '.agents', 'worktrees', branch))
|
||
expect(branch).toMatch(new RegExp(`^dsh-${sessionId.slice('session-'.length, 'session-'.length + 8)}-[0-9a-f]{4}$`))
|
||
expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch)
|
||
const baseCommit = await git(project, 'rev-parse', 'release')
|
||
expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit)
|
||
const events = await readPersistedEvents(scaffold, sessionId)
|
||
expect(worktreeRecords(events)).toEqual([
|
||
{ action: 'request', from: 'main' },
|
||
{ action: 'request', from: 'release' },
|
||
{ action: 'created', worktree: { path: directory, branch, from: 'release', baseCommit, repositoryRoot: project } },
|
||
])
|
||
// The worktree existed before the turn, so the first model request carried its directory notice.
|
||
const created = events.findIndex((event) => {
|
||
const record = pluginRecordOf(event)
|
||
const data = record?.type === 'plugin:client-ui-forge/worktree' ? record.data : null
|
||
return typeof data === 'object' && data !== null && 'action' in data && data.action === 'created'
|
||
})
|
||
const turn = events.findIndex(event => event.type === 'turn/start')
|
||
const notice = events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')
|
||
expect(created).toBeLessThan(turn)
|
||
expect(notice).toBeGreaterThan(turn)
|
||
expect(notice).toBeLessThan(events.findIndex(event => event.type === 'assistant/message'))
|
||
|
||
// A change request bound the way a `gh pr create` result binds one.
|
||
scaffold.ctx.forgeSession.track(session, PULL)
|
||
const header = page.locator('[data-forge-header]')
|
||
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
|
||
expect(await header.getAttribute('aria-pressed')).toBe('false')
|
||
|
||
// The Session row marks the worktree and its change request's state, and its hover card names both.
|
||
const row = page.locator(`[data-row-key="session:${sessionId}"]`)
|
||
await row.getByRole('img', { name: '在自己的工作树中', exact: true }).waitFor({ timeout: 20_000 })
|
||
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
|
||
// A fresh page cannot retain the previous browser source's status values.
|
||
await page.goto('about:blank')
|
||
await expect.poll(() => scaffold.ctx.storageDomain.get('forge_panel_change_requests')).toBeUndefined()
|
||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
|
||
await row.hover()
|
||
const rowHover = page.locator('[data-forge-session-hover]')
|
||
await rowHover.getByText('#7 Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 })
|
||
await compareOrRefreshGolden(join(EXPECTED, 'session-row-hover.expected.md'),
|
||
await captureStableAria(page, '[data-forge-session-hover]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
|
||
await page.mouse.move(0, 0)
|
||
await rowHover.waitFor({ state: 'detached' })
|
||
await header.click()
|
||
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true')
|
||
const panel = page.locator('[data-forge-panel]')
|
||
const checkout = panel.locator('[data-forge-checkout]')
|
||
const checkoutName = checkout.getByText(branch, { exact: true })
|
||
await checkoutName.waitFor({ timeout: 20_000 })
|
||
await checkout.getByText('工作树 · 基于 release', { exact: true }).waitFor()
|
||
await checkoutName.hover()
|
||
await page.getByRole('tooltip').filter({ hasText: `工作树(基于 release):${abbreviateHomePath(directory, homedir())}` }).waitFor()
|
||
await page.mouse.move(0, 0)
|
||
const change = panel.locator(`[data-forge-change="${PULL}"]`)
|
||
const title = change.getByRole('link', { name: 'Add the pull request panel', exact: true })
|
||
await title.waitFor({ timeout: 20_000 })
|
||
// The only open change request starts expanded.
|
||
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
|
||
// The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations.
|
||
await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'),
|
||
await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
|
||
const beforeChecks = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(beforeChecks.some(args => args.includes('id=rollup-7'))).toBe(false)
|
||
const checksRow = change.getByRole('button', { name: /^检查/ })
|
||
await checksRow.click()
|
||
const checks = page.getByRole('menu')
|
||
await expect.poll(() => checks.getByRole('menuitem').allTextContents()).toEqual([expect.stringMatching(/^test/), expect.stringMatching(/^lint/)])
|
||
const afterChecks = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(afterChecks.filter(args => args.includes('id=rollup-7'))).toHaveLength(1)
|
||
await page.keyboard.press('Escape')
|
||
await checks.waitFor({ state: 'detached' })
|
||
expect(await checksRow.getAttribute('aria-expanded')).toBe('false')
|
||
|
||
const bindings = async () => (await readPersistedEvents(scaffold, sessionId)).flatMap((event) => {
|
||
const record = pluginRecordOf(event)
|
||
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
|
||
})
|
||
await change.getByRole('button', { name: '从本会话移除 #7', exact: true }).click()
|
||
await change.waitFor({ state: 'detached' })
|
||
await panel.getByText('本会话还没有变更请求', { exact: true }).waitFor()
|
||
await page.getByRole('alert').filter({ hasText: '已从本会话移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click()
|
||
await title.waitFor({ timeout: 20_000 })
|
||
await expect.poll(bindings).toEqual([{ action: 'track', url: PULL }, { action: 'dismiss', url: PULL }, { action: 'track', url: PULL }])
|
||
|
||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(calls).toContainEqual(['auth', 'status', '--hostname', 'github.com'])
|
||
expect(calls.some(args => args[0] === 'api' && args[1] === 'graphql' && args.includes('--hostname'))).toBe(true)
|
||
await header.click()
|
||
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('false')
|
||
|
||
// Removing the worktree outside the Session holds the next message in the inbox until the worktree is restored.
|
||
await git(project, 'worktree', 'remove', '--force', directory)
|
||
const composer = page.locator('[data-composer-input]').first()
|
||
await writeComposerDraft(page, composer, FOLLOW_UP)
|
||
const accepted = page.waitForResponse('**/api/session/prompt')
|
||
await composer.press('Enter')
|
||
const envelope = await (await accepted).json() as { readonly result: unknown }
|
||
expect(envelope.result).toMatchObject({ ok: true })
|
||
const missing = page.locator('[data-forge-missing]')
|
||
await missing.getByText(`可以在原位置恢复分支 ${branch} 的工作树。删除前未提交的修改无法恢复`, { exact: true }).waitFor()
|
||
await missing.getByText('恢复工作树或在项目目录继续后,消息会接着发送', { exact: true }).waitFor()
|
||
await expect.poll(() => checkout.getAttribute('data-missing')).toBe('')
|
||
const turns = async (): Promise<number> => (await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start').length
|
||
expect(await turns()).toBe(1)
|
||
await missing.getByRole('button', { name: '恢复工作树', exact: true }).click()
|
||
await missing.waitFor({ state: 'detached', timeout: 20_000 })
|
||
await page.getByRole('alert').filter({ hasText: '工作树已恢复' }).waitFor()
|
||
expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch)
|
||
expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit)
|
||
await expect.poll(() => checkout.getAttribute('data-missing')).toBeNull()
|
||
// The waiting message goes on in the restored worktree.
|
||
await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor({ timeout: 20_000 })
|
||
expect(await turns()).toBe(2)
|
||
expect(scaffold.ctx.workingDirectory.get(session)).toBe(directory)
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('keeps the first message waiting when the worktree cannot be created, tries again, and sends it without the worktree', async () => {
|
||
const { scaffold, page, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel-failure')
|
||
await git(project, 'branch', '--quiet', '-D', 'release')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const card = page.locator('[data-forge-creation="failed"]')
|
||
const failure = card.getByRole('region', { name: '创建工作树失败', exact: true })
|
||
await failure.getByText('原因', { exact: true }).waitFor({ timeout: 20_000 })
|
||
const attempted = await failure.getByRole('definition').first().textContent()
|
||
expect(attempted).toMatch(/^dsh-[0-9a-f]{8}-[0-9a-f]{4}$/)
|
||
await failure.getByText('消息会在工作树建好后发送', { exact: true }).waitFor()
|
||
|
||
// Retry makes another attempt with a new branch name while the message keeps waiting.
|
||
await card.getByRole('button', { name: '重试', exact: true }).click()
|
||
await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted)
|
||
|
||
await card.getByRole('button', { name: '不使用工作树', exact: true }).click()
|
||
const sessionId = await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
await card.waitFor({ state: 'detached' })
|
||
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('The Session must stay loaded')
|
||
expect(scaffold.ctx.workingDirectory.get(session)).toBe(project)
|
||
const events = await readPersistedEvents(scaffold, sessionId)
|
||
expect(worktreeRecords(events)).toEqual([{ action: 'request', from: 'main' }, { action: 'request', from: 'release' }, { action: 'cancel' }])
|
||
expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||
expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')).toBe(false)
|
||
await page.locator('[data-forge-header]').click({ timeout: 20_000 })
|
||
const checkout = page.locator('[data-forge-panel] [data-forge-checkout]')
|
||
const checkoutName = checkout.getByText('main', { exact: true })
|
||
await checkoutName.waitFor({ timeout: 20_000 })
|
||
await checkout.getByText('项目目录', { exact: true }).waitFor()
|
||
await checkoutName.hover()
|
||
await page.getByRole('tooltip').filter({ hasText: `项目目录:${abbreviateHomePath(project, homedir())}` }).waitFor()
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('marks a draft change request ready for review and merges it with the usual method', async () => {
|
||
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-panel-merge')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('The Session must stay loaded')
|
||
|
||
scaffold.ctx.forgeSession.track(session, DRAFT)
|
||
const header = page.locator('[data-forge-header]')
|
||
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求')
|
||
await header.click()
|
||
const change = page.locator(`[data-forge-change="${DRAFT}"]`)
|
||
await change.getByRole('link', { name: 'Add the merge button', exact: true }).waitFor({ timeout: 20_000 })
|
||
|
||
// The only open change request starts expanded, so its action is in view.
|
||
await change.getByRole('button', { name: '标记为可评审', exact: true }).click()
|
||
await page.getByRole('alert').filter({ hasText: '已将 #9 标记为可评审' }).waitFor()
|
||
const merge = change.getByRole('button', { name: '合并', exact: true })
|
||
await merge.waitFor({ timeout: 20_000 })
|
||
await merge.click()
|
||
const menu = page.getByRole('menu')
|
||
await menu.getByText('将 #9 合并到 main', { exact: true }).waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'merge-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||
await writeFile(join(root, 'state.json'), JSON.stringify({ ready: true, mergedAt: null, head: 'c'.repeat(40) }))
|
||
scaffold.ctx.forge.refresh({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 })
|
||
const stale = menu.getByRole('menuitem', { name: '提交已更新,请重新检查后再打开合并菜单', exact: true })
|
||
await stale.waitFor()
|
||
expect(await stale.isDisabled()).toBe(true)
|
||
await compareOrRefreshGolden(join(EXPECTED, 'merge-stale.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||
await merge.click()
|
||
await merge.click()
|
||
await menu.getByRole('menuitem', { name: '确认合并', exact: true }).click()
|
||
await page.getByRole('alert').filter({ hasText: '已合并 #9' }).waitFor()
|
||
// Merged, the change request is no longer open, so its card collapses to the merge line.
|
||
await change.getByText(/^已合并到 main · /).waitFor({ timeout: 20_000 })
|
||
|
||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(calls).toContainEqual(['pr', 'ready', '9', '--repo', 'github.com/octo/demo'])
|
||
expect(calls).toContainEqual([
|
||
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/9/merge', '-f', 'merge_method=squash', '-f', `sha=${'c'.repeat(40)}`,
|
||
])
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => {
|
||
const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes', 'follow-up')
|
||
// origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file.
|
||
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join(''))
|
||
await git(project, 'commit', '--quiet', '-am', 'readme')
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
await mkdir(join(project, 'src'))
|
||
await writeFile(join(project, 'src', 'app.ts'), 'export const app = 1\n')
|
||
await git(project, 'add', 'src/app.ts')
|
||
await git(project, 'commit', '--quiet', '-m', 'app')
|
||
await writeFile(join(project, 'src', 'extra.ts'), 'export const extra = 1\n')
|
||
await git(project, 'add', 'src/extra.ts')
|
||
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => index === 9 ? 'line ten\n' : `line ${index + 1}\n`).join(''))
|
||
await writeFile(join(project, 'notes.md'), 'note\n')
|
||
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('The Session must stay loaded')
|
||
const header = page.locator('[data-forge-header]')
|
||
await page.getByRole('button', { name: '打开右侧边栏', exact: true }).click()
|
||
const guide = page.locator('[data-sidebar-right-guide]')
|
||
await guide.getByRole('button', { name: /^代码审查和变更/ }).waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-guide.expected.md'),
|
||
await captureStableAria(page, '[data-sidebar-right-guide-entry="forge-panel"]', scaffold.workspaceCwd), MODE)
|
||
await guide.getByRole('button', { name: /^代码审查和变更/ }).click()
|
||
// The New Session screen inspected the checkout before these changes, which came from outside the Session.
|
||
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
|
||
const card = page.locator('[data-forge-local]')
|
||
await card.getByText('HEAD → origin/main', { exact: true }).waitFor({ timeout: 20_000 })
|
||
await card.getByRole('button', { name: '查看本会话的变更:4 个文件,新增 4 行,删除 1 行', exact: true }).click()
|
||
|
||
const local = page.locator('[data-forge-changes="local"]')
|
||
await local.getByText('与 origin/main 比较,包含未提交的修改', { exact: true }).waitFor({ timeout: 20_000 })
|
||
const files = local.locator('[data-forge-diff-file]')
|
||
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
|
||
.toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md'])
|
||
await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor()
|
||
const readme = local.locator('[data-forge-diff-file="README.md"]')
|
||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||
await page.evaluate(() => navigator.clipboard.writeText('unchanged'))
|
||
await readme.locator('[data-forge-file-label]').click()
|
||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged')
|
||
await readme.locator('[data-forge-path-spacer]').click()
|
||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged')
|
||
await readme.getByRole('button', { name: '6 行未变更', exact: true }).click()
|
||
await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 })
|
||
await local.getByRole('button', { name: '全部 4 个文件', exact: true }).click()
|
||
const tree = page.getByRole('menu')
|
||
await tree.getByRole('textbox', { name: '筛选文件…' }).fill('extra')
|
||
await tree.getByRole('menuitem', { name: 'src/extra.ts', exact: true }).click()
|
||
for (const [path, comment] of [['src/app.ts', '请解释这行代码'], ['src/extra.ts', '请检查这项修改']] as const) {
|
||
const section = local.locator(`[data-forge-diff-file="${path}"]`)
|
||
const lineNumber = section.getByRole('button', { name: '选择第 R1 行', exact: true })
|
||
await lineNumber.hover()
|
||
expect(await lineNumber.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
|
||
const content = await section.locator('[data-review-code]').first().boundingBox()
|
||
const fileBox = await section.boundingBox()
|
||
if (content === null || fileBox === null) throw new Error('Added file must be visible')
|
||
expect(content.x - fileBox.x).toBeLessThan(70)
|
||
await lineNumber.click()
|
||
expect(await section.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0)
|
||
await section.getByRole('button', { name: '添加评论', exact: true }).click()
|
||
await section.getByRole('textbox', { name: '留下评论…' }).fill(comment)
|
||
await section.locator('[data-forge-comment]').getByRole('button', { name: '添加到输入框', exact: true }).click()
|
||
await section.locator('[data-forge-review-thread="agent"]').getByText(comment, { exact: true }).waitFor()
|
||
}
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-quotes.expected.md'),
|
||
await captureStableAria(page, '[data-composer-input]', scaffold.workspaceCwd), MODE)
|
||
expect((await readPersistedEvents(scaffold, sessionId)).some(event => event.type === 'user/message'
|
||
&& event.data.content.some(block => block.type === 'text' && block.text.includes('## User Comment')))).toBe(false)
|
||
const reviewed = scaffold.whenTurnSettled()
|
||
await page.locator('[data-composer-input]').first().press('Enter')
|
||
await reviewed
|
||
await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor()
|
||
const comments = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message'
|
||
? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## User Comment') ? [block.text] : []) : [])
|
||
expect(comments).toHaveLength(1)
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-message.expected.md'), comments.join('').trimEnd(), MODE)
|
||
|
||
await local.getByRole('button', { name: '分支', exact: true }).click()
|
||
await page.getByRole('menuitem', { name: '已暂存', exact: true }).click()
|
||
await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor()
|
||
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
|
||
.toEqual(['src/extra.ts'])
|
||
|
||
// A change request bound the way a `gh pr create` result binds one.
|
||
scaffold.ctx.forgeSession.track(session, PULL)
|
||
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
|
||
await header.click()
|
||
const change = page.locator(`[data-forge-change="${PULL}"]`)
|
||
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
|
||
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
|
||
const view = page.locator('[data-forge-changes="change"]')
|
||
const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]')
|
||
const title = await view.getByText('Add the pull request panel', { exact: true }).boundingBox()
|
||
const submit = await view.getByRole('button', { name: '提交评审', exact: true }).boundingBox()
|
||
if (title === null || submit === null) throw new Error('PR title and submit action must be visible')
|
||
expect(Math.abs(title.y + title.height / 2 - submit.y - submit.height / 2)).toBeLessThan(1)
|
||
|
||
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 })
|
||
await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'),
|
||
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
|
||
await page.getByRole('button', { name: '全屏', exact: true }).click()
|
||
await view.getByRole('navigation').getByRole('button', { name: 'src/panel.ts', exact: true }).waitFor()
|
||
const fileTree = view.getByRole('navigation')
|
||
const treeIsLeft = async () => {
|
||
const tree = await fileTree.boundingBox()
|
||
const diff = await view.locator('[data-forge-changes-scroll]').boundingBox()
|
||
if (tree === null || diff === null) throw new Error('Tree and diff must be visible')
|
||
return tree.x < diff.x
|
||
}
|
||
expect(await treeIsLeft()).toBe(true)
|
||
const directoryName = await fileTree.getByText('src', { exact: true }).boundingBox()
|
||
const fileName = await fileTree.getByText('panel.ts', { exact: true }).boundingBox()
|
||
if (directoryName === null || fileName === null) throw new Error('Tree entries must be visible')
|
||
expect(fileName.x).toBeGreaterThan(directoryName.x)
|
||
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src')
|
||
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('')
|
||
await fileTree.getByRole('button', { name: 'src', exact: true }).click()
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await treeIsLeft()).toBe(false)
|
||
expect(await fileTree.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('false')
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
expect(await fileTree.isVisible()).toBe(false)
|
||
expect(await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).isVisible()).toBe(false)
|
||
await view.getByRole('button', { name: '全部 2 个文件', exact: true }).click()
|
||
await page.getByRole('menu').getByRole('textbox', { name: '筛选文件…' }).waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||
expect(await treeIsLeft()).toBe(false)
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await fileTree.isVisible()).toBe(false)
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await treeIsLeft()).toBe(true)
|
||
expect(await fileTree.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('false')
|
||
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src')
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await fileTree.getByRole('textbox', { name: '筛选文件…' }).inputValue()).toBe('src')
|
||
await compareOrRefreshGolden(join(EXPECTED, 'file-tree-toggle.expected.md'),
|
||
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
|
||
await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('')
|
||
await fileTree.getByRole('button', { name: 'src', exact: true }).click()
|
||
const resizeTree = view.getByRole('separator', { name: '调整文件树宽度', exact: true })
|
||
const originalWidth = Number(await resizeTree.getAttribute('aria-valuenow'))
|
||
const handle = await resizeTree.boundingBox()
|
||
if (handle === null) throw new Error('Tree resize handle must be visible')
|
||
await page.mouse.move(handle.x + handle.width / 2, handle.y + 100)
|
||
await page.mouse.down()
|
||
await page.mouse.move(handle.x + 80, handle.y + 100, { steps: 5 })
|
||
await page.mouse.up()
|
||
expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBeGreaterThan(originalWidth)
|
||
await resizeTree.press('Home')
|
||
expect(await fileTree.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||
const searchBounds = await fileTree.getByRole('textbox').boundingBox()
|
||
const treeBounds = await fileTree.boundingBox()
|
||
if (searchBounds === null || treeBounds === null) throw new Error('Tree search must be visible')
|
||
expect(searchBounds.x + searchBounds.width).toBeLessThanOrEqual(treeBounds.x + treeBounds.width)
|
||
await resizeTree.press('ArrowRight')
|
||
const resizedWidth = await resizeTree.getAttribute('aria-valuenow')
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await treeIsLeft()).toBe(false)
|
||
expect(await resizeTree.getAttribute('aria-valuenow')).toBe(resizedWidth)
|
||
await resizeTree.press('ArrowLeft')
|
||
expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBe(Number(resizedWidth) + 20)
|
||
await resizeTree.press('ArrowRight')
|
||
const rightHandle = await resizeTree.boundingBox()
|
||
if (rightHandle === null) throw new Error('Right tree resize handle must be visible')
|
||
await page.mouse.move(rightHandle.x + rightHandle.width / 2, rightHandle.y + 100)
|
||
await page.mouse.down()
|
||
await page.mouse.move(rightHandle.x - 80, rightHandle.y + 100, { steps: 5 })
|
||
await page.mouse.up()
|
||
expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBeGreaterThan(Number(resizedWidth))
|
||
const rightWidth = await resizeTree.getAttribute('aria-valuenow')
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click()
|
||
expect(await resizeTree.getAttribute('aria-valuenow')).toBe(rightWidth)
|
||
for (const side of ['old', 'new'] as const) {
|
||
const firstLine = await panelFile.locator(`[data-review-code][data-${side}-line="2"]`).boundingBox()
|
||
const lastLine = await panelFile.locator(`[data-review-code][data-${side}-line="4"]`).boundingBox()
|
||
if (firstLine === null || lastLine === null) throw new Error('Review text must be visible')
|
||
await page.mouse.move(firstLine.x + 7, firstLine.y + firstLine.height / 2)
|
||
await page.mouse.down()
|
||
await page.mouse.move(lastLine.x + 120, lastLine.y + lastLine.height / 2, { steps: 5 })
|
||
await page.mouse.up()
|
||
await panelFile.getByRole('button', { name: '添加评论', exact: true }).waitFor()
|
||
expect(await panelFile.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0)
|
||
const opposite = panelFile.locator(`[data-diff-side="${side === 'old' ? 'R' : 'L'}"]`).first()
|
||
expect(await opposite.evaluate(element => getComputedStyle(element).userSelect)).toBe('none')
|
||
await page.keyboard.press('ControlOrMeta+c')
|
||
const copied = await page.evaluate(() => navigator.clipboard.readText())
|
||
expect(copied).toContain(side === 'old' ? 'n2 = 2' : "n2 = 'two'")
|
||
expect(copied).not.toContain(side === 'old' ? "n2 = 'two'" : 'n2 = 2')
|
||
expect(copied.match(/n3 = 3/g)).toHaveLength(1)
|
||
}
|
||
await panelFile.getByRole('button', { name: '添加评论', exact: true }).click()
|
||
await panelFile.getByRole('region', { name: '评论 R2–4', exact: true }).waitFor()
|
||
await panelFile.getByRole('textbox', { name: '留下评论…' }).fill('请核对边界条件')
|
||
await panelFile.getByRole('button', { name: 'GitHub 评论选项', exact: true }).click()
|
||
await page.getByRole('menuitem', { name: '发布单条评论', exact: true }).waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-composer.expected.md'),
|
||
await captureStableAria(page, '[data-forge-comment]', scaffold.workspaceCwd), MODE)
|
||
await page.getByRole('button', { name: '退出全屏', exact: true }).click()
|
||
const commentForm = panelFile.locator('[data-forge-comment]')
|
||
expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||
await page.getByRole('button', { name: '全屏', exact: true }).click()
|
||
await page.setViewportSize({ width: 390, height: 780 })
|
||
await commentForm.getByRole('textbox').focus()
|
||
expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||
const narrowComment = await commentForm.boundingBox()
|
||
if (narrowComment === null) throw new Error('Comment form must remain visible')
|
||
expect(narrowComment.x + narrowComment.width).toBeLessThanOrEqual(390)
|
||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||
await panelFile.getByRole('button', { name: '开始评审', exact: true }).click()
|
||
await panelFile.locator('[data-forge-review-thread="pending"]').getByText('请核对边界条件', { exact: true }).waitFor()
|
||
await page.setViewportSize({ width: 1680, height: 500 })
|
||
const changesScroll = view.locator('[data-forge-changes-scroll]')
|
||
await changesScroll.evaluate((element) => { element.scrollTop = element.scrollHeight })
|
||
const reviewScroll = await changesScroll.evaluate(element => element.scrollTop)
|
||
expect(reviewScroll).toBeGreaterThan(0)
|
||
await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click()
|
||
const reviewDialog = page.getByRole('dialog', { name: '提交评审', exact: true })
|
||
await reviewDialog.waitFor()
|
||
expect(await reviewDialog.getByRole('textbox').count()).toBe(1)
|
||
expect(await reviewDialog.getByText('请核对边界条件', { exact: true }).count()).toBe(0)
|
||
expect(await changesScroll.evaluate(element => element.scrollTop)).toBe(reviewScroll)
|
||
await page.setViewportSize({ width: 390, height: 780 })
|
||
expect(await reviewDialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||
const narrowDialog = await reviewDialog.boundingBox()
|
||
if (narrowDialog === null) throw new Error('Review dialog must remain visible')
|
||
expect(narrowDialog.x + narrowDialog.width).toBeLessThanOrEqual(390)
|
||
await page.setViewportSize({ width: 1680, height: 500 })
|
||
await page.keyboard.press('Escape')
|
||
expect(await reviewDialog.count()).toBe(0)
|
||
await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click()
|
||
await reviewDialog.getByRole('radio', { name: '请求更改', exact: true }).check()
|
||
await reviewDialog.getByText('请求更改时,请填写评审总结', { exact: true }).waitFor()
|
||
expect(await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).isDisabled()).toBe(true)
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-summary-required.expected.md'),
|
||
await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE)
|
||
await reviewDialog.getByRole('textbox', { name: '评审总结…', exact: true }).fill('需要补充边界检查')
|
||
await compareOrRefreshGolden(join(EXPECTED, 'submit-review.expected.md'),
|
||
await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE)
|
||
await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).click()
|
||
await page.getByRole('alert').filter({ hasText: '已提交评审' }).waitFor()
|
||
await panelFile.locator('[data-forge-review-thread="published"]').getByText('请核对边界条件', { exact: true }).waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'review-record.expected.md'), await captureStableAria(page, '[data-forge-review-thread="published"]', scaffold.workspaceCwd), MODE)
|
||
const published: unknown = JSON.parse((await readFile(join(root, 'reviews.jsonl'), 'utf8')).trim())
|
||
expect(published).toEqual({ endpoint: 'repos/octo/demo/pulls/7/reviews', body: { commit_id: HEAD7, event: 'REQUEST_CHANGES', body: '需要补充边界检查',
|
||
comments: [{ path: 'src/panel.ts', side: 'RIGHT', line: 4, start_line: 2, start_side: 'RIGHT', body: '请核对边界条件' }] } })
|
||
await page.getByRole('button', { name: '退出全屏', exact: true }).click()
|
||
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click()
|
||
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
|
||
await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor()
|
||
await panelFile.getByRole('button', { name: 'panel.ts 的更多操作', exact: true }).click()
|
||
await page.getByRole('menuitem', { name: '复制路径', exact: true }).click()
|
||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('src/panel.ts')
|
||
const docsFile = view.locator('[data-forge-diff-file="docs/panel.md"]')
|
||
await docsFile.getByRole('button', { name: '收起 panel.md', exact: true }).click()
|
||
await page.setViewportSize({ width: 1280, height: 500 })
|
||
const scrollport = view.locator('[data-forge-changes-scroll]')
|
||
await scrollport.evaluate((element) => { element.scrollTop = 100; element.dispatchEvent(new Event('scroll')) })
|
||
await panelFile.locator('[data-forge-file-label]').click()
|
||
const position = await scrollport.evaluate(element => element.scrollTop)
|
||
expect(position).toBeGreaterThan(0)
|
||
const originalFile = await panelFile.elementHandle()
|
||
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
|
||
await expect.poll(() => view.isVisible()).toBe(false)
|
||
await page.locator(`[data-row-key="session:${sessionId}"]`).click()
|
||
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
|
||
expect(await panelFile.evaluate((element, original) => element === original, originalFile)).toBe(true)
|
||
await expect.poll(() => docsFile.getAttribute('data-expanded')).toBe(null)
|
||
await expect.poll(() => scrollport.evaluate(element => element.scrollTop)).toBe(position)
|
||
expect(await panelFile.locator('[data-forge-review-thread="published"]').count()).toBe(1)
|
||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||
await page.locator('[data-dockkit-tab]').filter({ hasText: '#7 的变更' }).locator('[data-dockkit-tab-close]').click()
|
||
await expect.poll(() => view.count()).toBe(0)
|
||
if (await header.getAttribute('aria-pressed') !== 'true') await header.click()
|
||
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
|
||
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor()
|
||
expect(await panelFile.locator('[data-forge-review-thread]').count()).toBe(0)
|
||
await view.getByRole('button', { name: '提交评审', exact: true }).click()
|
||
await reviewDialog.waitFor()
|
||
expect(await reviewDialog.getByRole('textbox').inputValue()).toBe('')
|
||
expect(await reviewDialog.getByRole('radio', { name: '评论', exact: true }).isChecked()).toBe(true)
|
||
await page.keyboard.press('Escape')
|
||
|
||
// GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry.
|
||
scaffold.ctx.forgeSession.track(session, DRAFT)
|
||
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
|
||
await header.click()
|
||
const draft = page.locator(`[data-forge-change="${DRAFT}"]`)
|
||
await draft.getByRole('button', { name: '展开 #9', exact: true }).click()
|
||
await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 })
|
||
const refused = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' })
|
||
await refused.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 })
|
||
await refused.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor()
|
||
expect(await refused.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
|
||
|
||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||
expect(calls).toContainEqual(['pr', 'diff', '7', '--repo', 'github.com/octo/demo', '--color', 'never'])
|
||
expect(calls).toContainEqual(['pr', 'diff', '9', '--repo', 'github.com/octo/demo', '--color', 'never'])
|
||
expect(calls).toContainEqual([
|
||
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json', `repos/octo/demo/contents/src/panel.ts?ref=${HEAD7}`,
|
||
])
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
|
||
it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across tab and Session switches', async () => {
|
||
const { page, scaffold, root, tripwire } = await launchPanel('web-e2e-forge-reading-position')
|
||
const paths = ['src/before-0.ts', 'src/before-1.ts', 'src/before-2.ts', 'src/server.ts', 'src/after.ts']
|
||
const patch = paths.map(path => [
|
||
`diff --git a/${path} b/${path}`, 'index 1111111..2222222 100644', `--- a/${path}`, `+++ b/${path}`,
|
||
'@@ -1,650 +1,650 @@', '-const oldValue = true;', '+const newValue = true;',
|
||
...Array.from({ length: 649 }, (_, index) => ` const value_${index + 2} = "${'x'.repeat(24 + (index + 2) % 70)}";`), '',
|
||
].join('\n')).join('\n')
|
||
await writeFile(join(root, 'pr-7.diff'), patch)
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('Session must stay loaded')
|
||
scaffold.ctx.forgeSession.track(session, PULL)
|
||
// Keep the file tree and the Session sidebar visible together on every platform.
|
||
await page.setViewportSize({ width: 2400, height: 700 })
|
||
await page.locator('[data-forge-header]').click()
|
||
await page.locator(`[data-forge-change="${PULL}"]`).getByRole('button', { name: /查看 #7 的变更/ }).click()
|
||
const view = page.locator('[data-forge-changes="change"]')
|
||
const scroll = view.locator('[data-forge-changes-scroll]')
|
||
const file = view.locator('[data-forge-diff-file="src/server.ts"]')
|
||
const code = file.locator('[data-review-code][data-new-line="300"]')
|
||
const separator = view.getByRole('separator', { name: '调整文件树宽度', exact: true })
|
||
const beforeFile = view.locator('[data-forge-diff-file="src/before-0.ts"]')
|
||
// ResizeObserver runs between rendering frames, including when an ancestor becomes hidden.
|
||
const layout = () => page.evaluate(() => new Promise<void>((resolve) => {
|
||
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
||
}))
|
||
const reading = () => file.evaluate((element) => {
|
||
const viewport = element.closest('[data-forge-changes-scroll]')!
|
||
const top = Math.max(viewport.getBoundingClientRect().top, element.firstElementChild!.getBoundingClientRect().bottom)
|
||
const line = [...element.querySelectorAll<HTMLElement>('[data-review-code][data-new-line]')]
|
||
.find(row => row.getBoundingClientRect().bottom > top + 1)!
|
||
return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine,
|
||
offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop }
|
||
})
|
||
await code.waitFor()
|
||
for (const width of [240, 320]) {
|
||
if (width === 320) {
|
||
for (let step = 0; step < 4; step += 1) await separator.press('ArrowRight')
|
||
await beforeFile.getByRole('button', { name: '收起 before-0.ts', exact: true }).click()
|
||
}
|
||
await layout()
|
||
await code.evaluate((element) => {
|
||
const viewport = element.closest('[data-forge-changes-scroll]')!
|
||
const header = element.closest('[data-forge-diff-file]')!.firstElementChild!
|
||
const top = viewport.getBoundingClientRect().top + header.getBoundingClientRect().height
|
||
viewport.scrollTop += element.getBoundingClientRect().top - top + 7
|
||
})
|
||
await layout()
|
||
const position = await reading()
|
||
expect(position.line).toBe('300')
|
||
expect(position.offset).toBe(-7)
|
||
for (const destination of ['tab', 'session', 'tab', 'session']) {
|
||
if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '变更请求' }).click()
|
||
else await page.getByRole('button', { name: '新建会话', exact: true }).last().click()
|
||
await expect.poll(() => view.isVisible()).toBe(false)
|
||
await layout()
|
||
expect(await scroll.evaluate(element => element.clientWidth)).toBe(0)
|
||
if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '#7 的变更' }).click()
|
||
else await page.locator(`[data-row-key="session:${sessionId}"]`).click()
|
||
await view.waitFor()
|
||
await layout()
|
||
expect(await separator.getAttribute('aria-valuenow')).toBe(String(width))
|
||
expect(await reading()).toEqual(position)
|
||
expect(await beforeFile.getAttribute('data-expanded')).toBe(width === 240 ? '' : null)
|
||
}
|
||
}
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while preserving vertical scrolling and comment editing', async () => {
|
||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-file-keys')
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
for (const path of ['a.txt', 'b.txt', 'c.txt']) {
|
||
await writeFile(join(project, path), Array.from({ length: path === 'c.txt' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join(''))
|
||
}
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
await settled
|
||
await page.locator('[data-forge-header]').click()
|
||
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
|
||
await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:3 个文件/ }).click()
|
||
const view = page.locator('[data-forge-changes="local"]')
|
||
const scroll = view.getByRole('region', { name: '全部 3 个文件', exact: true })
|
||
await scroll.waitFor()
|
||
await page.getByRole('button', { name: '全屏', exact: true }).click()
|
||
await page.setViewportSize({ width: 1280, height: 500 })
|
||
const tree = view.getByRole('navigation')
|
||
const a = view.locator('[data-forge-diff-file="a.txt"]')
|
||
const b = view.locator('[data-forge-diff-file="b.txt"]')
|
||
const c = view.locator('[data-forge-diff-file="c.txt"]')
|
||
await b.getByRole('button', { name: '收起 b.txt', exact: true }).click()
|
||
await scroll.evaluate((element) => { element.scrollTop = 0 })
|
||
await scroll.focus()
|
||
await page.keyboard.press('ArrowLeft')
|
||
expect(await scroll.evaluate(element => element.scrollTop)).toBe(0)
|
||
expect(await scroll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
|
||
await page.keyboard.press('ArrowDown')
|
||
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0)
|
||
await scroll.evaluate((element) => { element.scrollTop = 0 })
|
||
await page.keyboard.press('ArrowRight')
|
||
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
expect(await b.getAttribute('data-expanded')).toBe('')
|
||
expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true)
|
||
await page.keyboard.press('ArrowRight')
|
||
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
const lastPosition = await scroll.evaluate(element => element.scrollTop)
|
||
const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox()
|
||
if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered')
|
||
expect(lastBox.y).toBeGreaterThan(viewport.y)
|
||
expect(lastBox.y).toBeLessThan(viewport.y + viewport.height)
|
||
await page.keyboard.press('ArrowRight')
|
||
expect(await scroll.evaluate(element => element.scrollTop)).toBe(lastPosition)
|
||
await page.keyboard.press('ArrowLeft')
|
||
await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
await page.keyboard.press('ArrowLeft')
|
||
await expect.poll(() => tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
await b.evaluate((element) => {
|
||
const viewport = element.closest('[data-forge-changes-scroll]')!
|
||
viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 100
|
||
})
|
||
await page.keyboard.press('ArrowRight')
|
||
await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
await tree.getByRole('button', { name: 'a.txt', exact: true }).click()
|
||
await a.getByRole('button', { name: '选择第 R1 行', exact: true }).click()
|
||
await a.getByRole('button', { name: '添加评论', exact: true }).click()
|
||
const input = a.getByRole('textbox', { name: '留下评论…', exact: true })
|
||
await input.fill('keep editing')
|
||
await input.press('ArrowLeft')
|
||
expect(await input.evaluate(element => (element as HTMLTextAreaElement).selectionStart)).toBe(11)
|
||
await input.press('ArrowRight')
|
||
expect(await input.inputValue()).toBe('keep editing')
|
||
expect(await tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true')
|
||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => {
|
||
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout')
|
||
await page.locator('[data-forge-current-branch]').waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'worktree-option.expected.md'),
|
||
await captureStableAria(page, '[data-forge-worktree]', scaffold.workspaceCwd), MODE)
|
||
await page.getByRole('button', { name: '收起侧边栏', exact: true }).click()
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
await expect.poll(() => page.locator('[data-forge-worktree]')
|
||
.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(390)
|
||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
await mkdir(join(project, 'src'))
|
||
await Promise.all(Array.from({ length: 60 }, (_, index) => writeFile(join(project, 'src', `file-${index}.ts`), `export const value = ${index}\n`)))
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const session = scaffold.ctx.sessions.get(await settled)
|
||
if (session === undefined) throw new Error('The Session must stay loaded')
|
||
scaffold.ctx.forgeSession.track(session, PULL)
|
||
await page.locator('[data-forge-header]').click()
|
||
const panel = page.locator('[data-forge-panel]')
|
||
await panel.getByRole('button', { name: '刷新状态', exact: true }).click()
|
||
const local = panel.locator('[data-forge-local]')
|
||
await local.locator('button[aria-expanded]').click()
|
||
await expect.poll(() => local.locator('li').count(), { timeout: 20_000 }).toBe(60)
|
||
for (const width of [1280, 390]) {
|
||
await page.setViewportSize({ width, height: 700 })
|
||
const outerScrollers = await panel.evaluate((element) => {
|
||
const scrollers: string[] = []
|
||
for (let ancestor = element.parentElement; ancestor !== null; ancestor = ancestor.parentElement) {
|
||
if (/auto|scroll/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight + 1) {
|
||
scrollers.push(ancestor.className)
|
||
}
|
||
}
|
||
return scrollers
|
||
})
|
||
expect(outerScrollers).toEqual([])
|
||
expect(await panel.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
|
||
await local.locator('li').last().scrollIntoViewIfNeeded()
|
||
expect(await local.locator('li').last().isVisible()).toBe(true)
|
||
}
|
||
await local.locator('button[aria-expanded]').click()
|
||
const change = panel.locator(`[data-forge-change="${PULL}"]`)
|
||
for (const [locale, labels] of [
|
||
['zh', ['检查', '评审', '合并', '变更']],
|
||
['en', ['Checks', 'Review', 'Merge', 'Changes']],
|
||
['de', ['Prüfungen', 'Review', 'Zusammenführen', 'Änderungen']],
|
||
] as const) {
|
||
await scaffold.ctx.settings.update('locale', { preference: locale })
|
||
await change.getByText(labels[0], { exact: true }).waitFor()
|
||
if (locale === 'en') {
|
||
await compareOrRefreshGolden(join(EXPECTED, 'local-card.expected.md'),
|
||
await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE)
|
||
}
|
||
for (const width of [1280, 390]) {
|
||
await page.setViewportSize({ width, height: 700 })
|
||
await expect.poll(() => change.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(width)
|
||
await change.getByText(labels[3], { exact: true }).hover()
|
||
for (const label of labels) {
|
||
const geometry = await change.getByText(label, { exact: true }).evaluate((element) => {
|
||
const value = element.nextElementSibling
|
||
if (value === null) throw new Error('Status label must have a value')
|
||
const range = document.createRange()
|
||
range.selectNodeContents(element)
|
||
const name = range.getBoundingClientRect()
|
||
const content = value.getBoundingClientRect()
|
||
const row = element.parentElement!
|
||
const icon = element.previousElementSibling!
|
||
const rowRect = row.getBoundingClientRect()
|
||
return {
|
||
gap: content.left - name.right,
|
||
iconInset: icon.getBoundingClientRect().left - rowRect.left,
|
||
rowInset: rowRect.left - row.closest('[data-forge-change]')!.getBoundingClientRect().left,
|
||
labelWidth: element.getBoundingClientRect().width,
|
||
valueOffset: content.left - rowRect.left,
|
||
right: content.right,
|
||
width: document.documentElement.clientWidth,
|
||
}
|
||
})
|
||
expect(geometry.gap).toBeGreaterThanOrEqual(8)
|
||
expect(geometry.iconInset).toBe(8)
|
||
expect(geometry.rowInset).toBeGreaterThanOrEqual(6)
|
||
expect(geometry.rowInset).toBeLessThanOrEqual(7)
|
||
if (locale === 'zh') {
|
||
expect(geometry.labelWidth).toBe(32)
|
||
expect(geometry.valueOffset).toBe(76)
|
||
}
|
||
expect(geometry.right).toBeLessThanOrEqual(geometry.width)
|
||
}
|
||
}
|
||
}
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record')('keeps long branch details accessible by touch and keyboard across the hero row breakpoints', async () => {
|
||
const branch = 'feature/a-long-current-branch-name-for-mobile-review'
|
||
const { page, scaffold, tripwire } = await launchPanel('web-e2e-forge-branch-details', 'normal', { branch, hasTouch: true })
|
||
const option = page.locator('[data-forge-worktree]')
|
||
const chip = option.locator('[data-forge-current-branch]')
|
||
await chip.waitFor()
|
||
await page.getByRole('button', { name: '收起侧边栏', exact: true }).click()
|
||
for (const [locale, label] of [['zh', `当前分支:${branch}`], ['en', `Current branch: ${branch}`], ['de', `Aktueller Branch: ${branch}`]] as const) {
|
||
await scaffold.ctx.settings.update('locale', { preference: locale })
|
||
await option.getByRole('button', { name: label, exact: true }).waitFor()
|
||
for (const width of [390, 600, 601, 700, 1280]) {
|
||
await page.setViewportSize({ width, height: 844 })
|
||
await chip.scrollIntoViewIfNeeded()
|
||
await expect.poll(() => option.evaluate((element) => {
|
||
const row = element.closest('[class*="heroWorkspaceRow"]')!
|
||
const ancestors: Element[] = []
|
||
for (let node: Element | null = row; node !== null; node = node.parentElement) ancestors.push(node)
|
||
return {
|
||
fits: element.getBoundingClientRect().right <= innerWidth,
|
||
overflow: ancestors.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.className),
|
||
}
|
||
})).toEqual({ fits: true, overflow: [] })
|
||
}
|
||
}
|
||
await scaffold.ctx.settings.update('locale', { preference: 'en' })
|
||
await option.getByRole('button', { name: `Current branch: ${branch}`, exact: true }).waitFor()
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
expect(await chip.locator('span').evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
|
||
const tooltip = page.getByRole('tooltip', { exact: true, name: `Current branch: ${branch}` })
|
||
await chip.tap()
|
||
await tooltip.waitFor()
|
||
await compareOrRefreshGolden(join(EXPECTED, 'branch-details.expected.md'), await tooltip.ariaSnapshot(), MODE)
|
||
await chip.tap()
|
||
await tooltip.waitFor({ state: 'hidden' })
|
||
await chip.tap()
|
||
await tooltip.waitFor()
|
||
await page.locator('[class*="heroWorkspaceRow"]').tap({ position: { x: 1, y: 1 } })
|
||
await tooltip.waitFor({ state: 'hidden' })
|
||
await chip.focus()
|
||
await page.keyboard.press('Tab')
|
||
await page.keyboard.press('Shift+Tab')
|
||
await tooltip.waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await tooltip.waitFor({ state: 'hidden' })
|
||
await page.keyboard.press('Enter')
|
||
await tooltip.waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await tooltip.waitFor({ state: 'hidden' })
|
||
expect(await option.getByRole('checkbox').isChecked()).toBe(false)
|
||
expect(await page.getByRole('menu').count()).toBe(0)
|
||
await scaffold.ctx.settings.update('locale', { preference: 'zh' })
|
||
await option.getByRole('checkbox', { name: '新建工作树', exact: true }).waitFor()
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
await settled
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff menus when the %s desktop shortcut deactivates a retained tab', async (platform) => {
|
||
const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', { desktop: platform })
|
||
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
|
||
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
||
await writeFile(join(project, 'README.md'), 'review this change\n')
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
await page.locator('[data-forge-header]').click()
|
||
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
|
||
await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:1 个文件/ }).click()
|
||
const view = page.locator('[data-forge-changes="local"]')
|
||
await view.locator('[data-forge-diff-file="README.md"]').waitFor()
|
||
for (const name of ['分支', '全部 1 个文件', 'README.md 的更多操作']) {
|
||
await view.getByRole('button', { name, exact: true }).click()
|
||
await page.getByRole('menu').waitFor()
|
||
await page.evaluate((mac) => { window.forgeKeyboard.deliver({
|
||
kind: 'keyboard', frameName: '', revision: window.forgeKeyboard.revision, code: 'KeyN',
|
||
meta: mac, control: !mac, alt: false, shift: false, repeat: false,
|
||
}) }, platform === 'darwin')
|
||
await expect.poll(() => view.isVisible()).toBe(false)
|
||
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
|
||
await page.locator(`[data-row-key="session:${sessionId}"]`).click()
|
||
await view.waitFor()
|
||
await expect.poll(() => page.getByRole('menu').count()).toBe(0)
|
||
}
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it.skipIf(MODE === 'record').each([120, 4000])('types a review comment beside %i changed lines', async (count) => {
|
||
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-typing')
|
||
const patch = ['diff --git a/src/typing.ts b/src/typing.ts', 'index 1111111..2222222 100644',
|
||
'--- a/src/typing.ts', '+++ b/src/typing.ts', `@@ -1,${count} +1,${count} @@`,
|
||
...Array.from({ length: count }, (_, index) => {
|
||
const context = index % 80 === 0 ? 'wrapped context '.repeat(600) : index % 5 === 0 ? 'context '.repeat(30) : ''
|
||
return `-export const value${index} = [${index}, '${context}']\n+export const value${index} = [${index + 1}, '${context}']`
|
||
}), ''].join('\n')
|
||
await writeFile(join(root, 'pr-7.diff'), patch)
|
||
const { settled } = await sendPrompt(page, scaffold)
|
||
const sessionId = await settled
|
||
const session = scaffold.ctx.sessions.get(sessionId)
|
||
if (session === undefined) throw new Error('Session must stay loaded')
|
||
scaffold.ctx.forgeSession.track(session, PULL)
|
||
await page.locator('[data-forge-header]').click()
|
||
const change = page.locator(`[data-forge-change="${PULL}"]`)
|
||
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
|
||
const file = page.locator('[data-forge-diff-file="src/typing.ts"]')
|
||
const collapsed = file.getByRole('button', { name: /^文件较大,/ })
|
||
await file.waitFor()
|
||
if (count > 120) await collapsed.click()
|
||
await file.getByRole('button', { name: '选择第 R5 行', exact: true }).click()
|
||
await file.getByRole('button', { name: '添加评论', exact: true }).click()
|
||
const input = file.getByRole('textbox', { name: '留下评论…', exact: true })
|
||
await input.waitFor()
|
||
const field = await input.boundingBox()
|
||
if (field === null) throw new Error('Comment field must be visible')
|
||
await page.mouse.move(field.x + field.width - 3, field.y + field.height - 3)
|
||
await page.mouse.down()
|
||
await page.mouse.move(field.x + field.width - 3, field.y + field.height + 77, { steps: 5 })
|
||
await page.mouse.up()
|
||
const resized = await input.boundingBox()
|
||
if (resized === null) throw new Error('Resized comment field must remain visible')
|
||
expect(resized.height).toBeGreaterThan(field.height + 50)
|
||
await input.fill('Review a wrapped line. '.repeat(80))
|
||
expect(await input.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
|
||
expect(await input.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||
await input.fill('')
|
||
await page.keyboard.type('Check the boundary')
|
||
await page.keyboard.press('Enter')
|
||
const cdp = await page.context().newCDPSession(page)
|
||
try {
|
||
await cdp.send('Input.imeSetComposition', { text: '检查', selectionStart: 2, selectionEnd: 2 })
|
||
expect(await input.inputValue()).toBe('Check the boundary\n检查')
|
||
expect(await file.locator('[data-forge-review-thread]').count()).toBe(0)
|
||
await cdp.send('Input.insertText', { text: '检查边界条件' })
|
||
} finally { await cdp.detach() }
|
||
expect(await input.inputValue()).toBe('Check the boundary\n检查边界条件')
|
||
await input.fill(' \n')
|
||
expect(await file.getByRole('button', { name: '开始评审', exact: true }).isDisabled()).toBe(true)
|
||
await input.fill('检查边界条件')
|
||
await file.getByRole('button', { name: '开始评审', exact: true }).click()
|
||
await file.locator('[data-forge-review-thread="pending"]').getByText('检查边界条件', { exact: true }).waitFor()
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
})
|