mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// A current client's `agentSession.send`, built by the outbox clients send from, against a real
|
||||
// published release. The release's schema must take it and its admission must re-derive the
|
||||
// fingerprint the client declared: a send it rejects, or digests differently and refuses as an
|
||||
// operation conflict, loses the user's message.
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { resolveBaselineReleaseRef } from './release-checkout'
|
||||
import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture'
|
||||
import {
|
||||
resetOperationIds,
|
||||
sendParams,
|
||||
SESSION,
|
||||
WORKSPACE
|
||||
} from './structured-agent-session-surface-manifest'
|
||||
import {
|
||||
loadAgentSessionWireBuild,
|
||||
WORKING_TREE,
|
||||
type AgentSessionWireBuild,
|
||||
type RpcReply,
|
||||
type SentMessage
|
||||
} from './versioned-agent-session-wire'
|
||||
|
||||
// Why: a cold CI run extracts the baseline checkout before the first pairing.
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
|
||||
let current: AgentSessionWireBuild
|
||||
let baseline: AgentSessionWireBuild
|
||||
|
||||
beforeAll(async () => {
|
||||
current = await loadAgentSessionWireBuild(WORKING_TREE)
|
||||
baseline = await loadAgentSessionWireBuild(resolveBaselineReleaseRef())
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
async function send(build: AgentSessionWireBuild, params: unknown): Promise<RpcReply[]> {
|
||||
const replies: RpcReply[] = []
|
||||
await build
|
||||
// Releases before the setting gate was dropped admit structured calls only with it on.
|
||||
.createDispatcher({
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
getClientSettings: () => ({ experimentalStructuredNativeChat: true })
|
||||
})
|
||||
.dispatchStreaming(
|
||||
{ id: 'request-send', authToken: 'cross-version-token', method: 'agentSession.send', params },
|
||||
(raw) => replies.push(JSON.parse(raw)),
|
||||
{ clientKind: 'runtime', clientCapabilities: current.capabilities }
|
||||
)
|
||||
return replies
|
||||
}
|
||||
|
||||
describe('a current client sending a message', () => {
|
||||
it.each([
|
||||
['a plain message', undefined],
|
||||
// `delivery` joins the operation fingerprint, so a builder that drops it is refused by a host
|
||||
// that digests it.
|
||||
['a message held as a draft while a turn runs', 'queue-if-active' as const]
|
||||
])('is accepted for %s, under the fingerprint each build re-derives', async (_case, delivery) => {
|
||||
// Anti-vacuous: the release must have the method, or there is no older host to send to.
|
||||
expect(baseline.methodNames).toContain('agentSession.send')
|
||||
for (const build of [current, baseline]) {
|
||||
resetOperationIds()
|
||||
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
|
||||
await build.installStructuredHost(installableHost(hostCalls))
|
||||
try {
|
||||
const replies = await send(build, sendParams('hi', 1, delivery))
|
||||
expect(replies, `${build.label}: ${JSON.stringify(replies)}`).toMatchObject([{ ok: true }])
|
||||
expect(hostCalls.send, `${build.label}: the send reached the host`).toHaveBeenCalledTimes(1)
|
||||
const sent: SentMessage = hostCalls.send.mock.calls[0]?.[1]
|
||||
expect(sent.delivery, `${build.label}: delivery reached the host`).toBe(delivery)
|
||||
// Past the fingerprint check, admission stops at the journal this harness never opens.
|
||||
expect(await build.admitSend(sent), `${build.label}: send admission`).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'sessionNotAttached' } }
|
||||
})
|
||||
// Negative control: proves the fingerprint check ran before admission reached the journal.
|
||||
const wrong = { ...sent.envelope, payloadFingerprint: '0'.repeat(64) }
|
||||
expect(await build.admitSend({ ...sent, envelope: wrong }), build.label).toMatchObject({
|
||||
refusal: { details: { reason: 'fingerprintMismatch' } }
|
||||
})
|
||||
} finally {
|
||||
await build.installStructuredHost(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -180,7 +180,7 @@ test.each(PRE_QODER_HOSTS)(
|
||||
expect(
|
||||
await client.searchSessions({ query: 'proof', filters: { agents: ['codex'] }, within })
|
||||
).toMatchObject({ hits: [{ agent: 'codex' }] })
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createSessionSearchService } from '../../../src/main/ai-vault-search/session-search-service'
|
||||
import {
|
||||
addSyntheticSession,
|
||||
openSessionSearchHarness
|
||||
} from '../../../src/main/ai-vault-search/session-search-engine-test-fixture'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import {
|
||||
createSessionSearchClient,
|
||||
unavailableSessionSearchStatus
|
||||
} from '../../../src/shared/ai-vault-search-client'
|
||||
import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
searchHit,
|
||||
searchResults,
|
||||
fakeSearchService
|
||||
} from '../../../src/shared/ai-vault-search-test-fixture'
|
||||
import {
|
||||
searchSessionService,
|
||||
sessionSearchServiceStatus,
|
||||
setSessionSearchService
|
||||
} from '../../../src/main/ai-vault-search/session-search-service-registry'
|
||||
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
|
||||
|
||||
const LEGACY_REF = 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c'
|
||||
afterEach(() => setSessionSearchService(null))
|
||||
|
||||
test('the actual pre-Jcode client reads current host pages while current peers retain every provider', async () => {
|
||||
const baseline = await importReleaseCheckoutModule(
|
||||
await materializeReleaseCheckout(LEGACY_REF),
|
||||
'src/shared/ai-vault-search-contract.ts'
|
||||
)
|
||||
const oldResponse = baseline.AiVaultSearchResponseSchema
|
||||
if (
|
||||
!oldResponse ||
|
||||
typeof oldResponse !== 'object' ||
|
||||
!('safeParse' in oldResponse) ||
|
||||
typeof oldResponse.safeParse !== 'function'
|
||||
) {
|
||||
throw new Error('Pinned release has no response parser')
|
||||
}
|
||||
const hits = (['codex', 'qoder', 'jcode'] as const).map((agent) => ({ ...searchHit(), agent }))
|
||||
expect(oldResponse.safeParse({ ...searchResults(), hits: [hits[2]] })).toHaveProperty(
|
||||
'success',
|
||||
false
|
||||
)
|
||||
const service = fakeSearchService()
|
||||
service.search.mockImplementation(async (request) => ({
|
||||
...searchResults(),
|
||||
hits: hits.filter(
|
||||
(hit) => !request.filters?.agents?.length || request.filters.agents.includes(hit.agent)
|
||||
)
|
||||
}))
|
||||
setSessionSearchService(service)
|
||||
const legacy = await searchSessionService({ query: 'proof' }, 'relay')
|
||||
expect(oldResponse.safeParse(legacy)).toHaveProperty('success', true)
|
||||
expect(legacy).toMatchObject({ hits: [{ agent: 'codex' }] })
|
||||
const client = createSessionSearchClient(
|
||||
(method, params) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? sessionSearchServiceStatus(params, 'relay')
|
||||
: searchSessionService(params, 'relay'),
|
||||
'relay'
|
||||
)
|
||||
expect(await client.searchSessions({ query: 'proof' })).toMatchObject({
|
||||
hits: [{ agent: 'codex' }, { agent: 'qoder' }, { agent: 'jcode' }]
|
||||
})
|
||||
expect(await sessionSearchServiceStatus({}, 'relay')).toMatchObject({
|
||||
supportedAgents: [...AI_VAULT_AGENTS]
|
||||
})
|
||||
})
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'a new %s client refuses sole unsupported Jcode without widening the query',
|
||||
async (transport) => {
|
||||
const call = vi.fn(async () => unavailableSessionSearchStatus())
|
||||
expect(
|
||||
await createSessionSearchClient(call, transport).searchSessions({
|
||||
query: 'proof',
|
||||
filters: { agents: ['jcode'] }
|
||||
})
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {})
|
||||
}
|
||||
)
|
||||
|
||||
test('agent-list negotiation takes precedence over the old flag and tolerates future advertised names', async () => {
|
||||
const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' }
|
||||
const call = vi.fn(async (method: string) =>
|
||||
method === 'aiVault.searchStatus'
|
||||
? {
|
||||
...unavailableSessionSearchStatus(),
|
||||
supportsQoderHistory: true,
|
||||
supportedAgents: ['codex', 'jcode', 'future-agent']
|
||||
}
|
||||
: searchResults()
|
||||
)
|
||||
await createSessionSearchClient(call, 'runtime').searchSessions({
|
||||
query: 'proof',
|
||||
cursor: 'page-2',
|
||||
within,
|
||||
filters: { agents: ['codex', 'qoder', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' }
|
||||
})
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'aiVault.searchSessions',
|
||||
expect.objectContaining({
|
||||
cursor: 'page-2',
|
||||
within,
|
||||
filters: { agents: ['codex', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' },
|
||||
supportedAgents: [...AI_VAULT_AGENTS]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('a current host honors a negotiated client subset before index search and keeps scope identity', async () => {
|
||||
const service = fakeSearchService()
|
||||
setSessionSearchService(service)
|
||||
await searchSessionService(
|
||||
{
|
||||
query: 'proof',
|
||||
supportsQoderHistory: true,
|
||||
supportedAgents: ['codex', 'future-agent'],
|
||||
filters: { agents: ['codex', 'qoder', 'jcode'], scopePaths: ['/task-owned'] }
|
||||
},
|
||||
'runtime'
|
||||
)
|
||||
expect(service.search).toHaveBeenLastCalledWith(
|
||||
{ query: 'proof', limit: 20, filters: { agents: ['codex'], scopePaths: ['/task-owned'] } },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])(
|
||||
'a client with no shared advertised agents returns no indexed rows for %j',
|
||||
async ({ supportedAgents }) => {
|
||||
const harness = await openSessionSearchHarness('ss-empty-peer-agents')
|
||||
try {
|
||||
addSyntheticSession(harness.db, { id: 1, agent: 'codex' })
|
||||
addSyntheticSession(harness.db, { id: 2, agent: 'jcode' })
|
||||
const retrieve = vi.spyOn(harness.engine, 'search')
|
||||
const service = createSessionSearchService({
|
||||
engine: harness.engine,
|
||||
indexer: { status: unavailableSessionSearchStatus, reconcile: async () => {} }
|
||||
})
|
||||
setSessionSearchService(service)
|
||||
const reply = await searchSessionService({ query: 'needle', supportedAgents }, 'runtime')
|
||||
expect(reply).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(retrieve).toHaveBeenCalledExactlyOnceWith({
|
||||
query: 'needle',
|
||||
limit: 20,
|
||||
filters: { scopePaths: [''] }
|
||||
})
|
||||
expect(retrieve.mock.results[0]).toMatchObject({
|
||||
type: 'return',
|
||||
value: { hits: [], page: { cursor: null, hasMore: false } }
|
||||
})
|
||||
} finally {
|
||||
setSessionSearchService(null)
|
||||
await harness.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['v1.4.211', LEGACY_REF])(
|
||||
'a missing status method still searches through the actual %s request parser',
|
||||
async (ref) => {
|
||||
const baseline = await importReleaseCheckoutModule(
|
||||
await materializeReleaseCheckout(ref),
|
||||
'src/shared/ai-vault-search-contract.ts'
|
||||
)
|
||||
const parser = baseline.AiVaultSearchRequestSchema
|
||||
if (
|
||||
!parser ||
|
||||
typeof parser !== 'object' ||
|
||||
!('parse' in parser) ||
|
||||
typeof parser.parse !== 'function'
|
||||
) {
|
||||
throw new Error('Pinned release has no request parser')
|
||||
}
|
||||
const call = vi.fn(async (method: string, request: Record<string, unknown>) => {
|
||||
if (method === 'aiVault.searchStatus') {
|
||||
throw { code: 'method_not_found' }
|
||||
}
|
||||
parser.parse(request)
|
||||
expect(request).toMatchObject({
|
||||
filters: {
|
||||
agents: AI_VAULT_AGENTS.filter(
|
||||
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent)
|
||||
),
|
||||
scopePaths: ['/execution-host/folder']
|
||||
}
|
||||
})
|
||||
return searchResults()
|
||||
})
|
||||
expect(
|
||||
await createSessionSearchClient(call, 'relay').searchSessions({
|
||||
query: 'needle',
|
||||
filters: { agents: [...AI_VAULT_AGENTS], scopePaths: ['/execution-host/folder'] }
|
||||
})
|
||||
).toMatchObject({ kind: 'results' })
|
||||
expect(call.mock.calls.map(([method]) => method)).toEqual([
|
||||
'aiVault.searchStatus',
|
||||
'aiVault.searchSessions'
|
||||
])
|
||||
}
|
||||
)
|
||||
@@ -11,6 +11,10 @@
|
||||
import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope'
|
||||
import {
|
||||
createStructuredAgentSessionOutboxEntry,
|
||||
structuredAgentSessionSendRequest
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
|
||||
export const SESSION = 'session-alpha'
|
||||
export const WORKSPACE = 'workspace-1'
|
||||
@@ -237,9 +241,21 @@ export function createIntentParams(): Record<string, unknown> {
|
||||
return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields }
|
||||
}
|
||||
|
||||
export function sendParams(text: string, fence: number): Record<string, unknown> {
|
||||
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body }
|
||||
/** Built by the outbox clients send from, so an older host is handed exactly what a current
|
||||
* client puts on the wire, fingerprint included. */
|
||||
export function sendParams(
|
||||
text: string,
|
||||
fence: number,
|
||||
sentDelivery?: 'queue-if-active'
|
||||
): Record<string, unknown> {
|
||||
const entry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: operationId(),
|
||||
sessionId: SESSION,
|
||||
text,
|
||||
attachments: [],
|
||||
queuedAt: NOW
|
||||
})
|
||||
return structuredAgentSessionSendRequest({ ...entry, sentDelivery }, fence)
|
||||
}
|
||||
|
||||
/** Schema-valid params per method; values only need to survive validation. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'
|
||||
import {
|
||||
importReleaseCheckoutModule,
|
||||
materializeReleaseCheckout,
|
||||
@@ -17,6 +18,10 @@ export const WORKING_TREE = 'working-tree' as const
|
||||
* in current source is invisible to a release checkout's dispatcher. */
|
||||
const STRUCTURED_HOST_REGISTRY =
|
||||
'/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts'
|
||||
const MUTATION_PLANS =
|
||||
'/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts'
|
||||
const MUTATION_ADMISSION =
|
||||
'/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts'
|
||||
|
||||
export type RpcReply = {
|
||||
id: string
|
||||
@@ -60,6 +65,10 @@ export type AgentSessionWireBuild = {
|
||||
* the surface stays loadable, and throws rather than no-opping so a build with
|
||||
* no slot cannot read as a surface that answered. */
|
||||
installStructuredHost: (host: unknown) => Promise<void>
|
||||
/** This build's own admission of the `agentSession.send` params its host was handed. With no
|
||||
* journal it stops after the fingerprint check: a fingerprint it derives differently refuses
|
||||
* as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */
|
||||
admitSend: (sent: SentMessage) => Promise<unknown>
|
||||
}
|
||||
|
||||
type DispatcherModule = {
|
||||
@@ -86,6 +95,32 @@ function applyStructuredHost(module: Record<string, unknown>, label: string, hos
|
||||
;(install as (next: unknown) => void)(host)
|
||||
}
|
||||
|
||||
/** The `agentSession.send` params a host is handed. */
|
||||
export type SentMessage = Parameters<typeof sendPlan>[0]
|
||||
|
||||
type SendAdmissionModules = {
|
||||
sendPlan: (sent: SentMessage) => unknown
|
||||
admitAndRunAgentSessionMutation: (request: {
|
||||
plan: unknown
|
||||
envelope: SentMessage['envelope']
|
||||
journal: () => undefined
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
|
||||
async function admitSend(
|
||||
plans: Record<string, unknown>,
|
||||
admission: Record<string, unknown>,
|
||||
sent: SentMessage
|
||||
): Promise<unknown> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; with no `prepareSession` it reads only plan, envelope and journal. A drifted export or shape fails this check.
|
||||
const build = { ...plans, ...admission } as unknown as SendAdmissionModules
|
||||
return build.admitAndRunAgentSessionMutation({
|
||||
plan: build.sendPlan(sent),
|
||||
envelope: sent.envelope,
|
||||
journal: () => undefined
|
||||
})
|
||||
}
|
||||
|
||||
function capabilityStrings(module: Record<string, unknown>): readonly string[] {
|
||||
const declared = module.RUNTIME_CAPABILITIES
|
||||
if (!Array.isArray(declared) || declared.length === 0) {
|
||||
@@ -117,6 +152,13 @@ async function loadWorkingTreeBuild(): Promise<AgentSessionWireBuild> {
|
||||
const registry =
|
||||
await import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry')
|
||||
applyStructuredHost(registry as unknown as Record<string, unknown>, WORKING_TREE, host)
|
||||
},
|
||||
admitSend: async (sent) => {
|
||||
const [plans, admission] = await Promise.all([
|
||||
import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'),
|
||||
import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission')
|
||||
])
|
||||
return admitSend(plans, admission, sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +188,13 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise<AgentSession
|
||||
checkout.ref,
|
||||
host
|
||||
)
|
||||
},
|
||||
admitSend: async (sent) => {
|
||||
const [plans, admission] = await Promise.all([
|
||||
importReleaseCheckoutModule(checkout, MUTATION_PLANS),
|
||||
importReleaseCheckoutModule(checkout, MUTATION_ADMISSION)
|
||||
])
|
||||
return admitSend(plans, admission, sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
|
||||
|
||||
type PageRichMarkdownLinkEditorElement = HTMLElement & {
|
||||
editor?: Editor
|
||||
}
|
||||
|
||||
test('collapses a selection beside a document link without rewriting the link', async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-links',
|
||||
'selection',
|
||||
testInfo.workerIndex,
|
||||
'[[Guide]]after\n\nSelecting text beside a document link should keep the link intact.'
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1)
|
||||
await orcaPage.evaluate(() => {
|
||||
const editorElement =
|
||||
document.querySelector<PageRichMarkdownLinkEditorElement>('.rich-markdown-editor')
|
||||
const instance = editorElement?.editor
|
||||
if (!editorElement || !instance) {
|
||||
throw new Error('Editor unavailable')
|
||||
}
|
||||
editorElement.focus()
|
||||
if (!instance.commands.setTextSelection({ from: 2, to: 7 })) {
|
||||
throw new Error('Selection unavailable')
|
||||
}
|
||||
})
|
||||
await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('after')
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(() => {
|
||||
const selection =
|
||||
document.querySelector<PageRichMarkdownLinkEditorElement>('.rich-markdown-editor')?.editor
|
||||
?.state.selection
|
||||
return selection ? { from: selection.from, to: selection.to, empty: selection.empty } : null
|
||||
})
|
||||
)
|
||||
.toEqual({ from: 2, to: 7, empty: false })
|
||||
await expect(editor).toBeFocused()
|
||||
await orcaPage.keyboard.press('ArrowLeft')
|
||||
await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1)
|
||||
await expect(editor.locator('p').first()).toHaveText('Guideafter')
|
||||
await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('link-after-collapse.png') })
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { pressShortcut } from './helpers/shortcuts'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
const FIRST = 'First alpha paragraph for pointer selection.'
|
||||
const SECOND = 'Second beta paragraph for pointer selection.'
|
||||
const SOURCE = [
|
||||
'# Adversarial Find interactions',
|
||||
'Needle first match.',
|
||||
...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`),
|
||||
FIRST,
|
||||
SECOND,
|
||||
'| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |',
|
||||
'- [ ] Task marker',
|
||||
'<details open>\n<summary>Details marker</summary>\n\nDetails body marker\n\n</details>',
|
||||
'```javascript\nconst codeMarker = "clean";\n```',
|
||||
'Late target paragraph for a pending query.',
|
||||
...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`),
|
||||
'Needle final match.'
|
||||
].join('\n\n')
|
||||
|
||||
async function textPoint(target: Locator, offset: number) {
|
||||
return target.evaluate((element, offset) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
let remaining = offset
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
if (node instanceof Text && remaining <= node.length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
const bounds = range.getBoundingClientRect()
|
||||
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
|
||||
}
|
||||
remaining -= node.textContent?.length ?? 0
|
||||
node = walker.nextNode()
|
||||
}
|
||||
throw new Error('Text offset was not found')
|
||||
}, offset)
|
||||
}
|
||||
|
||||
async function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString() ?? '')
|
||||
}
|
||||
|
||||
async function centered(target: Locator) {
|
||||
await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
}
|
||||
|
||||
async function find(page: Page, query = 'Needle', status = '1/2') {
|
||||
await pressShortcut(page, 'f')
|
||||
const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' })
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(query)
|
||||
await expect(page.locator('.rich-markdown-search-status')).toHaveText(status)
|
||||
return input
|
||||
}
|
||||
|
||||
async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) {
|
||||
const firstPoint = await textPoint(start, from)
|
||||
const lastPoint = await textPoint(end, to)
|
||||
await page.mouse.move(firstPoint.x, firstPoint.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-adversarial',
|
||||
'find-interactions',
|
||||
testInfo.workerIndex,
|
||||
SOURCE
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await editor.locator('p').first().click()
|
||||
})
|
||||
|
||||
test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const first = editor.getByText(FIRST, { exact: true })
|
||||
const second = editor.getByText(SECOND, { exact: true })
|
||||
const search = await find(orcaPage)
|
||||
await centered(first)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const start = await textPoint(first, 6)
|
||||
const end = await textPoint(second, 11)
|
||||
await orcaPage.mouse.click(start.x, start.y)
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.click(end.x, end.y)
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(
|
||||
orcaPage
|
||||
.locator('[data-tab-id]')
|
||||
.filter({ hasText: 'find-interactions' })
|
||||
.last()
|
||||
.locator('span.rounded-full')
|
||||
).toHaveCount(0)
|
||||
await orcaPage.mouse.click(start.x, start.y)
|
||||
await search.focus()
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.click(end.x, end.y)
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
const copied = await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer()
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard })
|
||||
)
|
||||
return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') }
|
||||
})
|
||||
expect(copied.plain).toContain(FIRST.slice(6))
|
||||
expect(copied.plain).toContain(SECOND.slice(0, 11))
|
||||
expect(copied.html).toContain('<p>')
|
||||
await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') })
|
||||
await search.focus()
|
||||
const word = await textPoint(first, 9)
|
||||
await orcaPage.mouse.dblclick(word.x, word.y)
|
||||
expect(await selectedText(orcaPage)).toBe('alpha')
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText('First xy paragraph for pointer selection.', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
})
|
||||
|
||||
test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
|
||||
const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' })
|
||||
await replace.fill('Thread')
|
||||
const first = editor.getByText(FIRST, { exact: true })
|
||||
const second = editor.getByText(SECOND, { exact: true })
|
||||
await centered(first)
|
||||
await drag(orcaPage, first, second, 6, 11)
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click()
|
||||
await expect.poll(() => selectedText(orcaPage)).toBe('Needle')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
|
||||
await replace.focus()
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect(replace).toBeFocused()
|
||||
await orcaPage.keyboard.press('Escape')
|
||||
await expect(search).toHaveCount(0)
|
||||
await pressShortcut(orcaPage, 'f')
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue('')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') })
|
||||
})
|
||||
|
||||
test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage, 'Cell alpha', '1/1')
|
||||
const firstCell = editor.getByText('Cell alpha', { exact: true })
|
||||
const lastCell = editor.getByText('Cell delta', { exact: true })
|
||||
await centered(firstCell)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const point = await textPoint(lastCell, 6)
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.move(point.x, point.y)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
)
|
||||
await orcaPage.mouse.up()
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
await expect(editor.locator('td.selectedCell')).toHaveCount(4)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') })
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(search).toHaveValue('Cell alpha')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last()
|
||||
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
|
||||
const copied = await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer()
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('copy', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: clipboard
|
||||
})
|
||||
)
|
||||
return clipboard.getData('text/plain')
|
||||
})
|
||||
expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta')
|
||||
})
|
||||
|
||||
test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
await centered(editor.getByRole('checkbox'))
|
||||
await search.focus()
|
||||
const checkbox = editor.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await search.focus()
|
||||
const details = editor.locator('[data-type="details"]')
|
||||
await details.getByRole('button').click()
|
||||
await expect(details.locator('[data-type="detailsContent"]')).toBeHidden()
|
||||
await search.focus()
|
||||
await details.getByRole('button').click()
|
||||
await expect(details.locator('[data-type="detailsContent"]')).toBeVisible()
|
||||
const body = editor.getByText('Details body marker', { exact: true })
|
||||
const bodyPoint = await textPoint(body, 7)
|
||||
await search.focus()
|
||||
await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible()
|
||||
const code = editor.getByText('const codeMarker = "clean";', { exact: true })
|
||||
await centered(code)
|
||||
const codePoint = await textPoint(code, 6)
|
||||
await search.focus()
|
||||
await orcaPage.mouse.click(codePoint.x, codePoint.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') })
|
||||
})
|
||||
|
||||
test('a pending Find query cannot claim focus after document and checkbox interaction', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
const target = editor.getByText('Late target paragraph for a pending query.', { exact: true })
|
||||
await centered(target)
|
||||
const point = await textPoint(target, 5)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const checkbox = editor.getByRole('checkbox')
|
||||
const checkboxPoint = await checkbox.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
|
||||
})
|
||||
await search.fill('Cell alpha')
|
||||
await orcaPage.mouse.click(point.x, point.y)
|
||||
await expect(editor).toBeFocused()
|
||||
await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y)
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await expect(target).toHaveText('Late target paragraph for a pending query.')
|
||||
await orcaPage.mouse.click(point.x, point.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText('Late xytarget paragraph for a pending query.', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await expect(search).toHaveValue('Cell alpha')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') })
|
||||
})
|
||||
|
||||
test('Replace advances when its replacement still contains the search query', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
await find(orcaPage)
|
||||
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
|
||||
await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX')
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
|
||||
await expect(
|
||||
editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]')
|
||||
).toHaveText('Needle')
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible()
|
||||
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') })
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { pressShortcut } from './helpers/shortcuts'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.'
|
||||
const MARKDOWN = [
|
||||
'# Find and pointer editing',
|
||||
'Needle remains at the beginning.',
|
||||
...Array.from({ length: 100 }, (_, index) =>
|
||||
index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.`
|
||||
),
|
||||
'Needle remains at the end.'
|
||||
].join('\n\n')
|
||||
const TABLE_MARKDOWN = MARKDOWN.replace(
|
||||
TARGET_TEXT,
|
||||
`| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |`
|
||||
)
|
||||
|
||||
async function textPoint(paragraph: Locator, offset: number) {
|
||||
return paragraph.evaluate((element, offset) => {
|
||||
const text = element.firstChild
|
||||
if (!(text instanceof Text)) {
|
||||
throw new Error('Expected a plain-text paragraph')
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(text, offset)
|
||||
range.collapse(true)
|
||||
const bounds = range.getBoundingClientRect()
|
||||
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
|
||||
}, offset)
|
||||
}
|
||||
|
||||
async function readParagraphSelection(paragraph: Locator) {
|
||||
return paragraph.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.anchorNode || !selection.focusNode) {
|
||||
return null
|
||||
}
|
||||
if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
|
||||
return null
|
||||
}
|
||||
const start = document.createRange()
|
||||
start.selectNodeContents(element)
|
||||
start.setEnd(selection.anchorNode, selection.anchorOffset)
|
||||
const end = document.createRange()
|
||||
end.selectNodeContents(element)
|
||||
end.setEnd(selection.focusNode, selection.focusOffset)
|
||||
return {
|
||||
from: Math.min(start.toString().length, end.toString().length),
|
||||
to: Math.max(start.toString().length, end.toString().length),
|
||||
text: selection.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function centerParagraph(paragraph: Locator): Promise<void> {
|
||||
await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
}
|
||||
|
||||
async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) {
|
||||
const start = await textPoint(paragraph, 8)
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
if (drag) {
|
||||
const end = await textPoint(paragraph, 24)
|
||||
await page.mouse.move(end.x, end.y, { steps: 12 })
|
||||
}
|
||||
// Tiptap focus commands can schedule a stale-selection scroll for the next frame.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
)
|
||||
await page.mouse.up()
|
||||
const selection = await readParagraphSelection(paragraph)
|
||||
expect(selection).not.toBeNull()
|
||||
if (!selection) {
|
||||
throw new Error('Pointer selection left the intended paragraph')
|
||||
}
|
||||
if (drag) {
|
||||
expect(selection.text).toBe('target paragraph')
|
||||
} else {
|
||||
expect(selection.from).toBe(selection.to)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
for (const interaction of ['click', 'drag'] as const) {
|
||||
test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-find-editing',
|
||||
`find-${interaction}`,
|
||||
testInfo.workerIndex,
|
||||
MARKDOWN
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
const paragraph = editor.locator('p').filter({ hasText: 'Editing' })
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
await editor.locator('p').first().click()
|
||||
await pressShortcut(orcaPage, 'f')
|
||||
const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill('Needle')
|
||||
await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click()
|
||||
await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await centerParagraph(paragraph)
|
||||
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
|
||||
|
||||
// Separate key events allow document updates to expose a caret reset between characters.
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') })
|
||||
await expect(paragraph).toHaveText(
|
||||
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
|
||||
)
|
||||
await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.')
|
||||
await expect
|
||||
.poll(() => readParagraphSelection(paragraph))
|
||||
.toEqual({
|
||||
from: selection.from + 2,
|
||||
to: selection.from + 2,
|
||||
text: ''
|
||||
})
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
await expect(search).toBeVisible()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Match whole word', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
})
|
||||
|
||||
test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-find-editing',
|
||||
`source-${interaction}`,
|
||||
testInfo.workerIndex,
|
||||
TABLE_MARKDOWN
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click()
|
||||
await expect(orcaPage.locator('.monaco-editor')).toBeVisible()
|
||||
await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click()
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await expect(editor).not.toBeFocused()
|
||||
const paragraph = editor.locator('td p').filter({ hasText: 'Editing' })
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
await centerParagraph(paragraph)
|
||||
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(paragraph).toHaveText(TARGET_TEXT)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
const tab = orcaPage
|
||||
.locator('[data-tab-id]')
|
||||
.filter({ hasText: path.basename(filePath) })
|
||||
.last()
|
||||
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
|
||||
await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible()
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') })
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') })
|
||||
await expect(paragraph).toHaveText(
|
||||
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
|
||||
)
|
||||
await expect
|
||||
.poll(() => readParagraphSelection(paragraph))
|
||||
.toEqual({
|
||||
from: selection.from + 2,
|
||||
to: selection.from + 2,
|
||||
text: ''
|
||||
})
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import type { Editor, JSONContent } from '@tiptap/core'
|
||||
import type { MarkdownManager } from '@tiptap/markdown'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500')
|
||||
const targetIndex = Math.floor(PARAGRAPH_COUNT / 2)
|
||||
const paragraph = (index: number) =>
|
||||
`Paragraph ${index}. Ordinary editing and selection of plain prose.`
|
||||
const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n')
|
||||
|
||||
type ReviewSelectionMetrics = {
|
||||
calls: number
|
||||
jsonCalls: number
|
||||
serializeMs: number
|
||||
jsonMs: number
|
||||
frameGaps: number[]
|
||||
}
|
||||
|
||||
type ReviewSelectionProbe = {
|
||||
snapshot: () => ReviewSelectionMetrics
|
||||
reset: () => void
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
type PageRichMarkdownReviewEditorElement = HTMLElement & {
|
||||
editor?: Editor & { markdown?: MarkdownManager }
|
||||
__reviewSelectionProbe?: ReviewSelectionProbe
|
||||
}
|
||||
|
||||
async function frames(page: Page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function measure(editor: Locator, reset = false): Promise<ReviewSelectionMetrics> {
|
||||
return editor.evaluate((element, reset) => {
|
||||
const editorElement =
|
||||
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
|
||||
const probe = editorElement?.__reviewSelectionProbe
|
||||
if (!probe) {
|
||||
throw new Error('Review selection probe missing')
|
||||
}
|
||||
if (reset) {
|
||||
probe.reset()
|
||||
}
|
||||
return probe.snapshot()
|
||||
}, reset)
|
||||
}
|
||||
|
||||
test('selection and scrolling reuse Markdown review source lines', async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024)
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-review-perf',
|
||||
'review-selection',
|
||||
testInfo.workerIndex,
|
||||
SOURCE
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 })
|
||||
await editor.evaluate((element) => {
|
||||
const editorElement =
|
||||
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
|
||||
const instance = editorElement?.editor
|
||||
if (!editorElement || !instance) {
|
||||
throw new Error('Editor unavailable')
|
||||
}
|
||||
const markdown = instance.markdown
|
||||
if (!markdown) {
|
||||
throw new Error('Markdown manager missing')
|
||||
}
|
||||
const serialize = markdown.serialize
|
||||
const getJSON = instance.getJSON
|
||||
let calls = 0
|
||||
let jsonCalls = 0
|
||||
let serializeMs = 0
|
||||
let jsonMs = 0
|
||||
let frameGaps: number[] = []
|
||||
let previousFrame: number | null = null
|
||||
let frameId = 0
|
||||
const tick = (now: number) => {
|
||||
if (previousFrame !== null) {
|
||||
frameGaps.push(now - previousFrame)
|
||||
}
|
||||
previousFrame = now
|
||||
frameId = requestAnimationFrame(tick)
|
||||
}
|
||||
frameId = requestAnimationFrame(tick)
|
||||
markdown.serialize = (content: JSONContent): string => {
|
||||
const start = performance.now()
|
||||
try {
|
||||
calls++
|
||||
return serialize.call(markdown, content)
|
||||
} finally {
|
||||
serializeMs += performance.now() - start
|
||||
}
|
||||
}
|
||||
instance.getJSON = (): ReturnType<Editor['getJSON']> => {
|
||||
const start = performance.now()
|
||||
try {
|
||||
jsonCalls++
|
||||
return getJSON.call(instance)
|
||||
} finally {
|
||||
jsonMs += performance.now() - start
|
||||
}
|
||||
}
|
||||
editorElement.__reviewSelectionProbe = {
|
||||
snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }),
|
||||
reset: () => {
|
||||
calls = 0
|
||||
jsonCalls = 0
|
||||
serializeMs = 0
|
||||
jsonMs = 0
|
||||
frameGaps = []
|
||||
previousFrame = null
|
||||
},
|
||||
restore: () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
markdown.serialize = serialize
|
||||
instance.getJSON = getJSON
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const target = editor.getByText(paragraph(targetIndex), { exact: true })
|
||||
await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
const point = await target.evaluate((element) => {
|
||||
const text = element.firstChild
|
||||
if (!(text instanceof Text)) {
|
||||
throw new Error('Paragraph text missing')
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(text, 0)
|
||||
range.collapse(true)
|
||||
const rect = range.getBoundingClientRect()
|
||||
return { x: rect.left, y: rect.top + rect.height / 2 }
|
||||
})
|
||||
await orcaPage.mouse.click(point.x, point.y)
|
||||
await orcaPage.keyboard.press('Shift+ArrowRight')
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Add review note', exact: true })
|
||||
).toBeVisible()
|
||||
await frames(orcaPage)
|
||||
const cold = await measure(editor)
|
||||
await measure(editor, true)
|
||||
for (let index = 0; index < 20; index++) {
|
||||
await orcaPage.keyboard.press('Shift+ArrowRight')
|
||||
await frames(orcaPage)
|
||||
}
|
||||
const selection = await measure(editor)
|
||||
expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe(
|
||||
paragraph(targetIndex).slice(0, 21)
|
||||
)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
await measure(editor, true)
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await viewport.evaluate((element, index) => {
|
||||
element.scrollTop += index % 2 ? -8 : 8
|
||||
}, index)
|
||||
await frames(orcaPage)
|
||||
}
|
||||
const scroll = await measure(editor)
|
||||
const result = {
|
||||
paragraphs: PARAGRAPH_COUNT,
|
||||
sourceBytes: Buffer.byteLength(SOURCE),
|
||||
cold,
|
||||
selection,
|
||||
scroll
|
||||
}
|
||||
await testInfo.attach('review-selection-metrics', {
|
||||
body: JSON.stringify(result, null, 2),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
await expect(editor).toBeFocused()
|
||||
const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last()
|
||||
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') })
|
||||
expect(selection.calls).toBe(0)
|
||||
expect(selection.jsonCalls).toBe(0)
|
||||
expect(scroll.calls).toBe(0)
|
||||
expect(scroll.jsonCalls).toBe(0)
|
||||
} finally {
|
||||
await editor.evaluate((element) => {
|
||||
const editorElement =
|
||||
element.closest<PageRichMarkdownReviewEditorElement>('.rich-markdown-editor')
|
||||
editorElement?.__reviewSelectionProbe?.restore()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { parseIpynb } from '../../src/renderer/src/components/editor/ipynb-parse'
|
||||
|
||||
const FIRST = { id: 'first', source: 'print("original")' }
|
||||
const SECOND = { id: 'second', source: 'print("other")' }
|
||||
|
||||
function notebookContent(cells: { id: string; source: string }[]): string {
|
||||
return JSON.stringify({
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
metadata: { language_info: { name: 'python' } },
|
||||
cells: cells.map(({ id, source }) => ({
|
||||
id,
|
||||
cell_type: 'code',
|
||||
metadata: {},
|
||||
execution_count: null,
|
||||
outputs: [],
|
||||
source: [source]
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function openNotebook(page: Page, filePath: string): Promise<void> {
|
||||
const context = await getActiveWorktreeContext(page)
|
||||
await page.evaluate(
|
||||
({ filePath, worktreeId, relativePath }) => {
|
||||
window.__store?.getState().openFile({
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
language: 'json',
|
||||
mode: 'edit'
|
||||
})
|
||||
},
|
||||
{
|
||||
filePath,
|
||||
worktreeId: context.worktreeId,
|
||||
relativePath: path.relative(context.rootPath, filePath)
|
||||
}
|
||||
)
|
||||
await expect(page.locator('.ipynb-code-surface')).toHaveCount(2)
|
||||
}
|
||||
|
||||
async function fileIsDirty(page: Page): Promise<boolean | null> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
return state?.openFiles.find((file) => file.id === state.activeFileId)?.isDirty ?? null
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
for (const reload of ['source', 'reorder'] as const) {
|
||||
test(`keeps the active cell current after an external notebook ${reload} reload`, async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const directory = path.join(context.rootPath, '.orca-e2e-notebook-external-sync')
|
||||
await mkdir(directory, { recursive: true })
|
||||
const filePath = path.join(directory, `${reload}-${testInfo.workerIndex}-${randomUUID()}.ipynb`)
|
||||
registerPostElectronShutdownCleanup(() => rm(filePath, { force: true }))
|
||||
await writeFile(filePath, notebookContent([FIRST, SECOND]), 'utf8')
|
||||
await openNotebook(orcaPage, filePath)
|
||||
const surfaces = orcaPage.locator('.ipynb-code-surface')
|
||||
await surfaces.first().getByRole('button').click()
|
||||
await expect(orcaPage.locator('.ipynb-code-surface .monaco-editor')).toBeVisible()
|
||||
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
|
||||
|
||||
const updatedFirst = reload === 'source' ? { ...FIRST, source: 'print("updated")' } : FIRST
|
||||
const updatedSecond = { ...SECOND, source: 'print("reload marker")' }
|
||||
const externalCells =
|
||||
reload === 'source' ? [updatedFirst, updatedSecond] : [updatedSecond, updatedFirst]
|
||||
await writeFile(filePath, notebookContent(externalCells), 'utf8')
|
||||
const preview = surfaces.nth(reload === 'source' ? 1 : 0)
|
||||
await expect(preview).toHaveText(updatedSecond.source, { timeout: 25_000 })
|
||||
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-clean.png') })
|
||||
const activeCell = surfaces.nth(reload === 'source' ? 0 : 1)
|
||||
await expect.soft(activeCell.locator('.view-lines')).toHaveText(updatedFirst.source)
|
||||
|
||||
await orcaPage.keyboard.press('End')
|
||||
await orcaPage.keyboard.type('!', { delay: 100 })
|
||||
await expect.poll(() => fileIsDirty(orcaPage)).toBe(true)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-edit.png') })
|
||||
await orcaPage.getByRole('button', { name: 'Save notebook', exact: true }).click()
|
||||
const editedFirst = { ...updatedFirst, source: `${updatedFirst.source}!` }
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const saved = parseIpynb(await readFile(filePath, 'utf8'))
|
||||
return saved.cells.map(({ id, source }) => ({ id, source }))
|
||||
})
|
||||
.toEqual(reload === 'source' ? [editedFirst, updatedSecond] : [updatedSecond, editedFirst])
|
||||
await expect.poll(() => fileIsDirty(orcaPage)).toBe(false)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-saved.png') })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
// Freeze the oracle only after the fixture's reveal and measured rows have settled.
|
||||
export async function waitForLineageScrollFixtureReady(
|
||||
page: Page,
|
||||
parentId: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
(parentId) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const sidebar = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
||||
if (!sidebar) {
|
||||
reject(new Error('Missing sidebar'))
|
||||
return
|
||||
}
|
||||
const quietSpanMs = 500
|
||||
const started = performance.now()
|
||||
let lastScrollAt = started
|
||||
let unchangedSince = started
|
||||
let signature = ''
|
||||
let frameId: number | null = null
|
||||
let frames = 0
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const cleanup = (): void => {
|
||||
sidebar.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('pagehide', onPageHide)
|
||||
clearInterval(intervalId)
|
||||
clearTimeout(timeoutId)
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
const finish = (error?: Error): void => {
|
||||
cleanup()
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const onScroll = (): void => {
|
||||
lastScrollAt = performance.now()
|
||||
}
|
||||
const onPageHide = (): void => finish(new Error('Renderer closed before fixture settled'))
|
||||
const readSignature = (): string => {
|
||||
const parent = sidebar.querySelector<HTMLElement>(
|
||||
`[role="option"][data-worktree-id=${JSON.stringify(parentId)}]`
|
||||
)
|
||||
const wrapper = parent?.closest<HTMLElement>('[data-worktree-virtual-row]')
|
||||
if (
|
||||
!parent?.isConnected ||
|
||||
!wrapper ||
|
||||
document.fonts.status !== 'loaded' ||
|
||||
sidebar.scrollTop <= 0
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
const mounted = Array.from(
|
||||
sidebar.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')
|
||||
)
|
||||
if (mounted.length > 100) {
|
||||
throw new Error('Unbounded lineage fixture row measurement')
|
||||
}
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
const sidebarRect = sidebar.getBoundingClientRect()
|
||||
return JSON.stringify({
|
||||
id: parent.dataset.worktreeId,
|
||||
key: wrapper.dataset.worktreeVirtualRowKey,
|
||||
index: wrapper.dataset.index,
|
||||
start: wrapper.dataset.worktreeVirtualRowStart,
|
||||
transform: wrapper.style.transform,
|
||||
parentTop: parentRect.top,
|
||||
parentHeight: parentRect.height,
|
||||
scrollTop: sidebar.scrollTop,
|
||||
scrollHeight: sidebar.scrollHeight,
|
||||
sidebarTop: sidebarRect.top,
|
||||
sidebarHeight: sidebarRect.height,
|
||||
canvasHeight: wrapper.parentElement?.style.height,
|
||||
rows: mounted.map((row) => ({
|
||||
key: row.dataset.worktreeVirtualRowKey,
|
||||
index: row.dataset.index,
|
||||
start: row.dataset.worktreeVirtualRowStart,
|
||||
transform: row.style.transform,
|
||||
height: row.getBoundingClientRect().height
|
||||
}))
|
||||
})
|
||||
}
|
||||
const inspect = (): boolean => {
|
||||
const next = readSignature()
|
||||
if (!next || next !== signature) {
|
||||
signature = next
|
||||
unchangedSince = performance.now()
|
||||
frames = 0
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
frameId = null
|
||||
return false
|
||||
}
|
||||
const now = performance.now()
|
||||
const ready = now - unchangedSince >= quietSpanMs && now - lastScrollAt >= quietSpanMs
|
||||
if (!ready) {
|
||||
frames = 0
|
||||
}
|
||||
return ready
|
||||
}
|
||||
const onFrame = (): void => {
|
||||
frameId = null
|
||||
try {
|
||||
if (!inspect()) {
|
||||
return
|
||||
}
|
||||
frames += 1
|
||||
if (frames === 2) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
frameId = requestAnimationFrame(onFrame)
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
sidebar.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('pagehide', onPageHide, { once: true })
|
||||
intervalId = setInterval(() => {
|
||||
try {
|
||||
if (inspect() && frameId === null) {
|
||||
frameId = requestAnimationFrame(onFrame)
|
||||
}
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}, 50)
|
||||
timeoutId = setTimeout(
|
||||
() => finish(new Error(`Lineage fixture did not settle: ${signature}`)),
|
||||
10000
|
||||
)
|
||||
}),
|
||||
parentId
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,9 @@ export type LineageScenario = {
|
||||
|
||||
export async function seedLineageScenario(
|
||||
page: Page,
|
||||
options: { inlineOnly?: boolean } = {}
|
||||
options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {}
|
||||
): Promise<LineageScenario> {
|
||||
return page.evaluate(({ inlineOnly }) => {
|
||||
return page.evaluate(({ inlineOnly, preserveGrouping }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
@@ -18,7 +18,9 @@ export async function seedLineageScenario(
|
||||
const state = store.getState()
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy('none')
|
||||
if (!preserveGrouping) {
|
||||
state.setGroupBy('none')
|
||||
}
|
||||
state.setSortBy('recent')
|
||||
// Why: these specs assert lineage structure, not the user's persisted
|
||||
// sidebar filters. Make the seeded child render even when it has no live PTY.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
|
||||
import { seedLineageScenario } from './worktree-lineage-state'
|
||||
|
||||
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
|
||||
|
||||
test('locally controlled project-group modal keeps child-toggle input', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
expect(
|
||||
await electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
|
||||
)
|
||||
).toBe(true)
|
||||
const family = await seedLineageScenario(orcaPage)
|
||||
const groupId = await orcaPage.evaluate(async ({ parentId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Missing store')
|
||||
}
|
||||
const parent = Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.find((row) => row.id === parentId)
|
||||
if (!parent) {
|
||||
throw new Error('Missing parent')
|
||||
}
|
||||
const group = await store.getState().createProjectGroup('Shortcut dialog group')
|
||||
if (!group) {
|
||||
throw new Error('Missing project group')
|
||||
}
|
||||
await store.getState().moveProjectToGroup(parent.repoId, group.id)
|
||||
store.getState().setGroupBy('repo')
|
||||
await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H'])
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'KeyH') {
|
||||
document.body.dataset.childToggleClaimed = String(event.defaultPrevented)
|
||||
}
|
||||
})
|
||||
return group.id
|
||||
}, family)
|
||||
const group = orcaPage.locator(`[data-project-group-header-id="${groupId}"]`)
|
||||
await expect(
|
||||
orcaPage
|
||||
.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`
|
||||
)
|
||||
.first()
|
||||
.getByRole('button', { name: 'Hide 1 child workspace' })
|
||||
).toBeVisible()
|
||||
await group.hover()
|
||||
await group.getByRole('button', { name: /Group actions/i }).click()
|
||||
// The menu is not a modal input owner.
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(1)
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(0)
|
||||
await orcaPage.getByRole('menuitem', { name: /Delete/i }).click()
|
||||
const dialog = orcaPage.getByRole('dialog', { name: 'Delete Project Group' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Delete Group', exact: true })).toBeFocused()
|
||||
expect(await orcaPage.evaluate(() => window.__store?.getState().activeModal)).toBe('none')
|
||||
const before = await orcaPage.evaluate(() =>
|
||||
[...(window.__store?.getState().collapsedGroups ?? [])].sort()
|
||||
)
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
const proof = testInfo.outputPath('local-modal-guard.png')
|
||||
await orcaPage.screenshot({ path: proof })
|
||||
await testInfo.attach('local-modal-guard', { path: proof, contentType: 'image/png' })
|
||||
expect(
|
||||
await orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort())
|
||||
).toEqual(before)
|
||||
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
orcaPage
|
||||
.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`
|
||||
)
|
||||
.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
|
||||
import { seedLineageScenario } from './worktree-lineage-state'
|
||||
import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness'
|
||||
|
||||
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
|
||||
|
||||
test('keyboard and chip preserve the scrolled parent, and unfolding opens the sidebar', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
expect(
|
||||
await electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
|
||||
)
|
||||
).toBe(true)
|
||||
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await orcaPage.setViewportSize({ width: 1200, height: 800 })
|
||||
const family = await seedLineageScenario(orcaPage)
|
||||
await orcaPage.evaluate(async ({ parentId, childId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Missing store')
|
||||
}
|
||||
const state = store.getState()
|
||||
const parent = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((row) => row.id === parentId)
|
||||
if (!parent) {
|
||||
throw new Error('Missing parent')
|
||||
}
|
||||
const surrounding = Array.from({ length: 40 }, (_, index) => ({
|
||||
...parent,
|
||||
id: `scroll-card-${index}`,
|
||||
instanceId: `scroll-instance-${index}`,
|
||||
displayName: `Surrounding workspace ${index}`,
|
||||
isMainWorktree: false,
|
||||
isPinned: false,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
sortOrder: 40 - index
|
||||
}))
|
||||
store.setState({
|
||||
sortBy: 'manual',
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
[parent.repoId]: [
|
||||
...state.worktreesByRepo[parent.repoId].map((row) => ({
|
||||
...row,
|
||||
sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder
|
||||
})),
|
||||
...surrounding
|
||||
]
|
||||
}
|
||||
})
|
||||
await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H'])
|
||||
}, family)
|
||||
|
||||
const sidebar = orcaPage.locator('[data-worktree-sidebar]')
|
||||
const parentRow = sidebar
|
||||
.locator(`[role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`)
|
||||
.first()
|
||||
const childRow = sidebar
|
||||
.locator(`[role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`)
|
||||
.first()
|
||||
await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click()
|
||||
await expect(parentRow).toBeVisible()
|
||||
await parentRow.evaluate((row) => row.scrollIntoView({ block: 'center' }))
|
||||
await expect.poll(() => sidebar.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
const geometry = () =>
|
||||
parentRow.evaluate((row) => ({
|
||||
top: row.getBoundingClientRect().top,
|
||||
scrollTop: row.closest('[data-worktree-sidebar]')?.scrollTop ?? 0
|
||||
}))
|
||||
await waitForLineageScrollFixtureReady(orcaPage, family.parentId)
|
||||
const before = await geometry()
|
||||
await orcaPage.mouse.move(1150, 400)
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
await expect(childRow).toBeHidden()
|
||||
await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2)
|
||||
expect((await geometry()).scrollTop).toBeGreaterThan(0)
|
||||
const collapsedProof = testInfo.outputPath('keyboard-collapsed-parent-anchor.png')
|
||||
await sidebar.screenshot({ path: collapsedProof })
|
||||
await testInfo.attach('keyboard-collapsed-parent-anchor', {
|
||||
path: collapsedProof,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click()
|
||||
await expect(childRow).toBeVisible()
|
||||
await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2)
|
||||
await orcaPage.mouse.move(1150, 400)
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
await expect(childRow).toBeHidden()
|
||||
await orcaPage.evaluate(() => window.__store?.getState().setSidebarOpen(false))
|
||||
await expect(sidebar).toBeHidden()
|
||||
await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH')
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(childRow).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,485 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state'
|
||||
|
||||
test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } })
|
||||
|
||||
const ACTION_ID = 'sidebar.childWorkspaces.toggle' as const
|
||||
const CHORD = 'ControlOrMeta+Alt+KeyH'
|
||||
|
||||
function sidebarWorktreeRow(page: Page, worktreeId: string) {
|
||||
return page
|
||||
.locator(
|
||||
`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]`
|
||||
)
|
||||
.first()
|
||||
}
|
||||
|
||||
async function waitForLineageWorktrees(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return 0
|
||||
}
|
||||
for (const repo of store.getState().repos) {
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
}
|
||||
return Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.filter((worktree) => !worktree.isArchived).length
|
||||
}),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
|
||||
async function captureEvidence(page: Page, name: string): Promise<void> {
|
||||
if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') {
|
||||
return
|
||||
}
|
||||
const directory = join(process.cwd(), 'pr-evidence')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
await page.screenshot({ path: join(directory, name) })
|
||||
}
|
||||
|
||||
async function setToggleBinding(page: Page, bindings: string[] | null): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ actionId, bindings }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
await (bindings
|
||||
? state.setKeybindingOverride(actionId, bindings)
|
||||
: state.resetKeybindingOverride(actionId))
|
||||
},
|
||||
{ actionId: ACTION_ID, bindings }
|
||||
)
|
||||
}
|
||||
|
||||
async function movePointerOffSidebar(page: Page): Promise<void> {
|
||||
const viewport =
|
||||
page.viewportSize() ??
|
||||
(await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})))
|
||||
await page.mouse.move(viewport.width - 20, viewport.height / 2)
|
||||
}
|
||||
|
||||
async function observeShortcutClaim(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'KeyH') {
|
||||
document.body.dataset.childToggleClaimed = String(event.defaultPrevented)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Toggle Child Workspaces shortcut', () => {
|
||||
test.beforeEach(async ({ orcaPage, electronApp }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
expect(
|
||||
await electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows().every((window) => !window.isVisible())
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ orcaPage }) => {
|
||||
await setToggleBinding(orcaPage, null)
|
||||
})
|
||||
|
||||
test('Settings exposes the unbound action and records an assigned shortcut', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await orcaPage.evaluate(async () => {
|
||||
await window.__store?.getState().updateSettings({ uiLanguage: 'en' })
|
||||
window.__store?.getState().openSettingsPage()
|
||||
})
|
||||
await orcaPage.getByPlaceholder('Search settings').fill('shortcuts')
|
||||
const search = orcaPage.getByPlaceholder('Search command or keys')
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill('Toggle Child Workspaces')
|
||||
await expect(orcaPage.getByText('Toggle Child Workspaces', { exact: true })).toBeVisible()
|
||||
const add = orcaPage.getByRole('button', { name: 'Add shortcut for Toggle Child Workspaces' })
|
||||
await expect(add).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-settings-unbound.png')
|
||||
|
||||
await add.click()
|
||||
const recorder = orcaPage.locator('[data-shortcut-recorder-active]')
|
||||
await expect(recorder).toBeVisible()
|
||||
await recorder.press(CHORD)
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Change shortcut for Toggle Child Workspaces' })
|
||||
).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-settings-assigned.png')
|
||||
})
|
||||
|
||||
test('hides and shows children of the active or hovered workspace', async ({ orcaPage }) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
|
||||
const childRow = sidebarWorktreeRow(orcaPage, childId)
|
||||
|
||||
await parentRow.click()
|
||||
await expect(parentRow).toHaveAttribute('aria-current', 'page')
|
||||
await expect(childRow).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-sidebar-expanded.png')
|
||||
|
||||
// No hovered card: the active parent is the target.
|
||||
await movePointerOffSidebar(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeHidden()
|
||||
await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-sidebar-collapsed.png')
|
||||
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeVisible()
|
||||
await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible()
|
||||
|
||||
await childRow.click()
|
||||
await expect(childRow).toHaveAttribute('aria-current', 'page')
|
||||
await movePointerOffSidebar(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeHidden()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeVisible()
|
||||
await parentRow.click()
|
||||
|
||||
// A hovered leaf child folds its parent.
|
||||
await childRow.hover()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeHidden()
|
||||
await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible()
|
||||
|
||||
// A hovered parent unfolds its own children.
|
||||
await parentRow.hover()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(childRow).toBeVisible()
|
||||
})
|
||||
|
||||
test('a modal keeps its shortcut input while the parent chip is visible', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
await orcaPage.evaluate(() => window.__store?.getState().openModal('worktree-palette'))
|
||||
await expect(orcaPage.getByRole('dialog')).toBeVisible()
|
||||
await orcaPage.getByRole('dialog').evaluate((dialog) => {
|
||||
dialog.setAttribute('tabindex', '-1')
|
||||
dialog.focus()
|
||||
})
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(
|
||||
sidebarWorktreeRow(orcaPage, parentId).getByRole('button', {
|
||||
name: 'Hide 1 child workspace',
|
||||
includeHidden: true
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await orcaPage.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
const other = await orcaPage.evaluate(
|
||||
({ parentId, childId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Missing store')
|
||||
}
|
||||
const state = store.getState()
|
||||
const worktrees = Object.values(state.worktreesByRepo).flat()
|
||||
const parent = worktrees.find((worktree) => worktree.id === parentId)
|
||||
const child = worktrees.find((worktree) => worktree.id === childId)
|
||||
const edge = state.worktreeLineageById[childId]
|
||||
if (!parent || !child || !edge) {
|
||||
throw new Error('Missing seeded family')
|
||||
}
|
||||
const otherParent = {
|
||||
...parent,
|
||||
id: `${parentId}-other`,
|
||||
instanceId: 'other-parent',
|
||||
displayName: 'Another parent'
|
||||
}
|
||||
const otherChild = {
|
||||
...child,
|
||||
id: `${childId}-other`,
|
||||
instanceId: 'other-child',
|
||||
displayName: 'Another child'
|
||||
}
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
[parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild]
|
||||
},
|
||||
worktreeLineageById: {
|
||||
...state.worktreeLineageById,
|
||||
[otherChild.id]: {
|
||||
...edge,
|
||||
worktreeId: otherChild.id,
|
||||
worktreeInstanceId: otherChild.instanceId,
|
||||
parentWorktreeId: otherParent.id,
|
||||
parentWorktreeInstanceId: otherParent.instanceId
|
||||
}
|
||||
}
|
||||
})
|
||||
return { parentId: otherParent.id, childId: otherChild.id }
|
||||
},
|
||||
{ parentId, childId }
|
||||
)
|
||||
await sidebarWorktreeRow(orcaPage, other.parentId).hover()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden()
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-hover-precedence.png')
|
||||
})
|
||||
|
||||
test('a focused floating workspace keeps the shortcut input', async ({ orcaPage }) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
await orcaPage.getByRole('button', { name: 'Show floating workspace', exact: true }).click()
|
||||
const panel = orcaPage.locator('[data-floating-terminal-panel][aria-hidden="false"]')
|
||||
await expect(panel).toBeVisible()
|
||||
await panel.locator('[data-floating-terminal-shortcut-surface]').first().focus()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(
|
||||
sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { name: 'Hide 1 child workspace' })
|
||||
).toBeVisible()
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-floating-guard.png')
|
||||
})
|
||||
|
||||
test('a stale child instance leaves no chip and does not claim the chord', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
await orcaPage.evaluate((childId) => {
|
||||
window.__store?.setState((state) => ({
|
||||
worktreesByRepo: Object.fromEntries(
|
||||
Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [
|
||||
repoId,
|
||||
rows.map((row) =>
|
||||
row.id === childId ? { ...row, instanceId: 'recreated-child' } : row
|
||||
)
|
||||
])
|
||||
)
|
||||
}))
|
||||
}, childId)
|
||||
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
|
||||
await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
|
||||
await parentRow.click()
|
||||
await orcaPage.locator('[data-worktree-sidebar]').focus()
|
||||
await movePointerOffSidebar(orcaPage)
|
||||
await observeShortcutClaim(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-stale-no-chip.png')
|
||||
})
|
||||
|
||||
test('folder cards pass the chord through while another family has a chip', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
const folderKey = 'folder:child-toggle-folder'
|
||||
await orcaPage.evaluate(() => {
|
||||
const store = window.__store
|
||||
const repo = store?.getState().repos[0]
|
||||
if (!store || !repo) {
|
||||
throw new Error('Missing seeded project')
|
||||
}
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'child-toggle-group',
|
||||
name: 'Folder project',
|
||||
parentPath: repo.path,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'child-toggle-folder',
|
||||
projectGroupId: 'child-toggle-group',
|
||||
name: 'Folder without a child chip',
|
||||
folderPath: repo.path,
|
||||
executionHostId: 'local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const folder = sidebarWorktreeRow(orcaPage, folderKey)
|
||||
await expect(folder).toBeVisible()
|
||||
await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
|
||||
await sidebarWorktreeRow(orcaPage, parentId).click()
|
||||
await orcaPage.locator('[data-worktree-sidebar]').focus()
|
||||
await folder.hover()
|
||||
await observeShortcutClaim(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await folder.click()
|
||||
await expect(folder).toHaveAttribute('aria-current', 'page')
|
||||
await orcaPage.locator('[data-worktree-sidebar]').focus()
|
||||
await movePointerOffSidebar(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false')
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible()
|
||||
await captureEvidence(orcaPage, 'child-toggle-folder-pass-through.png')
|
||||
})
|
||||
|
||||
test('pinned duplicates share the chip state and record the sidebar anchor', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
await orcaPage.evaluate(async (parentId) => {
|
||||
await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true })
|
||||
window.__store?.setState((state) => ({
|
||||
worktreesByRepo: Object.fromEntries(
|
||||
Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [
|
||||
repoId,
|
||||
rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row))
|
||||
])
|
||||
)
|
||||
}))
|
||||
const sidebar = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
||||
sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => {
|
||||
sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1)
|
||||
})
|
||||
}, parentId)
|
||||
const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true })
|
||||
await expect(chips).toHaveCount(2)
|
||||
const natural = orcaPage
|
||||
.locator('[data-worktree-section-key="all"]')
|
||||
.filter({ has: chips })
|
||||
.first()
|
||||
await natural.hover()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Show 1 child workspace', exact: true })
|
||||
).toHaveCount(2)
|
||||
await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute(
|
||||
'data-anchor-records',
|
||||
'1'
|
||||
)
|
||||
await expect(sidebarWorktreeRow(orcaPage, childId)).toBeHidden()
|
||||
await orcaPage
|
||||
.getByRole('button', { name: 'Show 1 child workspace', exact: true })
|
||||
.first()
|
||||
.click()
|
||||
await expect(chips).toHaveCount(2)
|
||||
await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute(
|
||||
'data-anchor-records',
|
||||
'2'
|
||||
)
|
||||
await captureEvidence(orcaPage, 'child-toggle-pinned-chip-parity.png')
|
||||
})
|
||||
|
||||
test('does nothing while a sidebar filter hides every child', async ({ orcaPage }) => {
|
||||
const { parentId, childId } = await seedLineageScenario(orcaPage)
|
||||
await setToggleBinding(orcaPage, ['Mod+Alt+H'])
|
||||
await seedWorkspaceLiveTerminal(orcaPage, parentId)
|
||||
const parentRow = sidebarWorktreeRow(orcaPage, parentId)
|
||||
const childRow = sidebarWorktreeRow(orcaPage, childId)
|
||||
await parentRow.click()
|
||||
await expect(parentRow).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
const readCollapsedGroups = (): Promise<string[]> =>
|
||||
orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort())
|
||||
const setShowSleeping = (show: boolean): Promise<void> =>
|
||||
orcaPage.evaluate((show) => window.__store?.getState().setShowSleepingWorkspaces(show), show)
|
||||
|
||||
// The child has no live terminal, so hiding sleeping workspaces removes it and its chip.
|
||||
await setShowSleeping(false)
|
||||
try {
|
||||
await expect(childRow).toBeHidden()
|
||||
await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0)
|
||||
const before = await readCollapsedGroups()
|
||||
|
||||
// Checked after each press: two toggles of one key would cancel out.
|
||||
await movePointerOffSidebar(orcaPage)
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
expect(await readCollapsedGroups()).toEqual(before)
|
||||
await parentRow.hover()
|
||||
await orcaPage.keyboard.press(CHORD)
|
||||
expect(await readCollapsedGroups()).toEqual(before)
|
||||
} finally {
|
||||
await setShowSleeping(true)
|
||||
}
|
||||
await expect(childRow).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('child-workspace shortcut and chip collapse survive an app restart', async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
const session = createRestartSession(testInfo)
|
||||
let app: Awaited<ReturnType<typeof session.launch>>['app'] | undefined
|
||||
try {
|
||||
const first = await session.launch()
|
||||
app = first.app
|
||||
await attachRepoAndOpenTerminal(first.page, testRepoPath)
|
||||
await waitForLineageWorktrees(first.page)
|
||||
const family = await seedLineageScenario(first.page)
|
||||
await setToggleBinding(first.page, ['Mod+Alt+H'])
|
||||
await sidebarWorktreeRow(first.page, family.parentId).click()
|
||||
await movePointerOffSidebar(first.page)
|
||||
await first.page.keyboard.press(CHORD)
|
||||
await expect(sidebarWorktreeRow(first.page, family.childId)).toBeHidden()
|
||||
await session.close(app)
|
||||
app = undefined
|
||||
|
||||
const second = await session.launch()
|
||||
app = second.app
|
||||
await attachRepoAndOpenTerminal(second.page, testRepoPath)
|
||||
await waitForLineageWorktrees(second.page)
|
||||
// Resetting the grouping mode also clears the persisted collapsed groups.
|
||||
await seedLineageScenario(second.page, { preserveGrouping: true })
|
||||
await expect(
|
||||
sidebarWorktreeRow(second.page, family.parentId).getByRole('button', {
|
||||
name: 'Show 1 child workspace'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(sidebarWorktreeRow(second.page, family.childId)).toBeHidden()
|
||||
await movePointerOffSidebar(second.page)
|
||||
await second.page.keyboard.press(CHORD)
|
||||
await expect(sidebarWorktreeRow(second.page, family.childId)).toBeVisible()
|
||||
await captureEvidence(second.page, 'child-toggle-restart-persistence.png')
|
||||
} finally {
|
||||
if (app) {
|
||||
await session.close(app)
|
||||
}
|
||||
await session.dispose()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user