From d0341c36b9481fcffd97ea584dcd42dc22e7e52e Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 09:11:44 -0500 Subject: [PATCH] Bind runner browser fixtures to prompt-only task creation Use current composer controls and creation response IDs so generated titles cannot break task lookup. Verify standard, Plan, and Ask creation with paused agents before paid qualification. Co-Authored-By: Paperclip --- tests/e2e/runner-task-creation.spec.ts | 30 ++++++++++ tests/runner-e2e/README.md | 7 +++ tests/runner-e2e/accounting-flow.ts | 4 +- tests/runner-e2e/blocker-flow.ts | 4 +- tests/runner-e2e/context-integrity-flow.ts | 4 +- tests/runner-e2e/continuation-flow.ts | 4 +- tests/runner-e2e/cursor-native-flow.ts | 4 +- tests/runner-e2e/everyday-flow.ts | 6 +- tests/runner-e2e/instruction-persistence.ts | 4 +- tests/runner-e2e/native-active-stop-flow.ts | 4 +- tests/runner-e2e/native-provider-loss-flow.ts | 4 +- tests/runner-e2e/runner.spec.ts | 16 +++--- tests/runner-e2e/user-actions.ts | 56 ++++++++++--------- 13 files changed, 93 insertions(+), 54 deletions(-) create mode 100644 tests/e2e/runner-task-creation.spec.ts diff --git a/tests/e2e/runner-task-creation.spec.ts b/tests/e2e/runner-task-creation.spec.ts new file mode 100644 index 00000000000..14ecad24e98 --- /dev/null +++ b/tests/e2e/runner-task-creation.spec.ts @@ -0,0 +1,30 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { createTaskThroughUi } from "../runner-e2e/user-actions.js"; + +for (const workMode of ["standard", "planning", "ask"] as const) { + test(`runner fixture creates a prompt-only ${workMode} task without starting a provider`, async ({ page, request }) => { + const companyResponse = await request.post("/api/companies", { data: { name: `Runner composer ${randomUUID()}` } }); + expect(companyResponse.ok()).toBe(true); + const company = await companyResponse.json(); + const agentResponse = await request.post(`/api/companies/${company.id}/agents`, { data: { + name: "Paused composer fixture", role: "engineer", adapterType: "codex_local", + runtimeConfig: { heartbeat: { enabled: false } }, + } }); + expect(agentResponse.ok()).toBe(true); + const agent = await agentResponse.json(); + expect((await request.post(`/api/agents/${agent.id}/pause`)).ok()).toBe(true); + const projectResponse = await request.post(`/api/companies/${company.id}/projects`, { data: { name: "Composer project" } }); + expect(projectResponse.ok()).toBe(true); + const project = await projectResponse.json(); + const prompt = `Preserve this exact prompt for ${workMode}.`; + const created = await createTaskThroughUi({ page, issuePrefix: company.issuePrefix, agentName: agent.name, + title: "Fixture label distinct from the generated title", prompt, workMode, projectName: project.name }); + const issue = await (await request.get(`/api/issues/${created.issueId}`)).json(); + expect(issue).toMatchObject({ id: created.issueId, companyId: company.id, assigneeAgentId: agent.id, + projectId: project.id, description: prompt, workMode }); + expect(issue.title).not.toBe("Fixture label distinct from the generated title"); + expect(created.submittedAtMs).toBeGreaterThan(0); + expect(await (await request.get(`/api/companies/${company.id}/heartbeat-runs`)).json()).toEqual([]); + }); +} diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 8e6516cf4a4..9245e03193e 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1629,6 +1629,13 @@ Recovery-state retention uses raw cleanup results before evidence publication. A ## Public installed release smoke +The shared task-creation helper uses the current prompt-only composer and binds +each task to its actual HTTP creation response ID, since its generated title may +change during execution. The provider-free `tests/e2e/runner-task-creation.spec.ts` +regression verifies assignee, project, and all three work modes with paused agents. +Explicit title-preservation cases still require a visible title input and fail +before provider execution when that surface is unavailable. + Set `PAPERCLIP_RUNNER_E2E_INSTALLED_CLI` to the absolute public consumer's `paperclipai/dist/index.js` for an installed-product acceptance run. Install the public package graph and run its ordinary `runtime setup cursor` first. The diff --git a/tests/runner-e2e/accounting-flow.ts b/tests/runner-e2e/accounting-flow.ts index 67b13ee94bc..ec05cd559e1 100644 --- a/tests/runner-e2e/accounting-flow.ts +++ b/tests/runner-e2e/accounting-flow.ts @@ -63,10 +63,10 @@ export async function runAccountingFlow(input: { try { if (execution.profile.generation === "legacy") await prepareLegacyContinuationSkill(api, fixtures.company.id, fixtures.agent.id); await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false }); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce), workMode: "standard" }); issue = await pollUntil({ label: "accounting task creation", deadlineAt: input.deadlineAt, - load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.title === execution.task.buildTitle(nonce)), accept: Boolean }); + load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.id === createdTask.issueId), accept: Boolean }); if (!issue) throw new Error("Missing created accounting task"); if (probe.kind === "productive") { for (let step = 1; step <= 5; step++) { diff --git a/tests/runner-e2e/blocker-flow.ts b/tests/runner-e2e/blocker-flow.ts index 68f3173731b..7cb15788b15 100644 --- a/tests/runner-e2e/blocker-flow.ts +++ b/tests/runner-e2e/blocker-flow.ts @@ -88,10 +88,10 @@ export async function runBlockerFlow(input: { if (hash !== hashes[file]) throw new Error(`Bundled operational skill differs from evaluated source: ${file}`); } await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false }); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(input.nonce), prompt: scenario.prompt, workMode: "standard" }); issue = await pollUntil({ label: "browser-created blocker task", deadlineAt: input.deadlineAt, - load: async () => (await api.get(`${company}/issues`)).find(i => i.title === execution.task.buildTitle(input.nonce)), accept: Boolean }); + load: async () => (await api.get(`${company}/issues`)).find(i => i.id === createdTask.issueId), accept: Boolean }); if (!issue) throw new Error("No browser-created task"); checkpoints.push(await settle("waiting")); await open(); diff --git a/tests/runner-e2e/context-integrity-flow.ts b/tests/runner-e2e/context-integrity-flow.ts index ad1e240111c..1fe6a9d4c32 100644 --- a/tests/runner-e2e/context-integrity-flow.ts +++ b/tests/runner-e2e/context-integrity-flow.ts @@ -179,8 +179,8 @@ export async function runContextIntegrityFlow(input: { ? `${scenario.prompt}\n\nUse /${scenario.skillKey} for this request.` : scenario.prompt; skillRequestText = taskPrompt; - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: taskPrompt, workMode: "standard" }); - issue = await pollUntil({ label: "context-integrity task created", deadlineAt: input.deadlineAt, load: async () => (await api.get(`${companyPath}/issues?limit=100`)).find((row) => row.title === execution.task.buildTitle(nonce)), accept: Boolean }); + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: taskPrompt, workMode: "standard" }); + issue = await pollUntil({ label: "context-integrity task created", deadlineAt: input.deadlineAt, load: async () => (await api.get(`${companyPath}/issues?limit=100`)).find((row) => row.id === createdTask.issueId), accept: Boolean }); if (!issue) throw new Error("Missing context-integrity task"); if (scenario.id === "ordered-comment-continuation") { await pollUntil({ diff --git a/tests/runner-e2e/continuation-flow.ts b/tests/runner-e2e/continuation-flow.ts index 3a2c06a1ce5..a1e122bdc5b 100644 --- a/tests/runner-e2e/continuation-flow.ts +++ b/tests/runner-e2e/continuation-flow.ts @@ -223,7 +223,7 @@ export async function runContinuationFlow(input: { await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false, }); - await createTaskThroughUi({ + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, @@ -236,7 +236,7 @@ export async function runContinuationFlow(input: { deadlineAt: input.deadlineAt, load: async () => (await api.get(tasksPath)).find( - (t) => t.title === execution.task.buildTitle(input.nonce), + (t) => t.id === createdTask.issueId, ), accept: Boolean, }); diff --git a/tests/runner-e2e/cursor-native-flow.ts b/tests/runner-e2e/cursor-native-flow.ts index 284830cd86c..bb1a0dd1900 100644 --- a/tests/runner-e2e/cursor-native-flow.ts +++ b/tests/runner-e2e/cursor-native-flow.ts @@ -263,8 +263,8 @@ export async function runCursorNativeFlow(input: { let expectedMarker = execution.task.buildVisibleMarker(nonce); try { if (!remote && design.id === "native-write-deny-reconnect") await sampleDenied("before-request"); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand!.command}` : ""), workMode: "standard", projectName: project.name }); - issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }) ?? {}; + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand!.command}` : ""), workMode: "standard", projectName: project.name }); + issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: Boolean }) ?? {}; if (!issue.id) throw new Error("Browser-created Cursor task is absent"); if (remote) { const started = await pollUntil({ label: "exact Cursor bootstrap run", deadlineAt: input.deadlineAt, load, reject, diff --git a/tests/runner-e2e/everyday-flow.ts b/tests/runner-e2e/everyday-flow.ts index b9f23d1f474..3e72615b6e7 100644 --- a/tests/runner-e2e/everyday-flow.ts +++ b/tests/runner-e2e/everyday-flow.ts @@ -667,7 +667,7 @@ export async function runEverydayFlow(input: Input) { if (state.connections.length) throw new Error("New-connection story requires an unconnected company"); } - await createTaskThroughUi({ + const createdTask = await createTaskThroughUi({ page, issuePrefix: prefix, agentName: fixtures.agent.name, @@ -682,9 +682,9 @@ export async function runEverydayFlow(input: Input) { load: () => api.get(`/api/companies/${fixtures.company.id}/issues`), accept: (rows) => - rows.some((i) => i.title === execution.task.buildTitle(nonce)), + rows.some((i) => i.id === createdTask.issueId), }).then((rows) => - rows.find((i) => i.title === execution.task.buildTitle(nonce))!, + rows.find((i) => i.id === createdTask.issueId)!, ); input.observe(parent!, []); note("task-submitted", { issueId: parent!.id }); diff --git a/tests/runner-e2e/instruction-persistence.ts b/tests/runner-e2e/instruction-persistence.ts index 6e60b304dd5..baae39b1216 100644 --- a/tests/runner-e2e/instruction-persistence.ts +++ b/tests/runner-e2e/instruction-persistence.ts @@ -68,9 +68,9 @@ export async function runInstructionPersistenceFlow(input: { let issue: Row = {}; let runs: Row[] = []; async function create(title: string, prompt: string) { - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name }); + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name }); const found = await pollUntil({ label: `instruction task ${title}`, deadlineAt: input.deadlineAt, - load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === title), accept: row => Boolean(row) }); + load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: row => Boolean(row) }); if (!found) throw new Error("Browser-created instruction task missing"); issue = found; input.observe(issue, runs); diff --git a/tests/runner-e2e/native-active-stop-flow.ts b/tests/runner-e2e/native-active-stop-flow.ts index 687f3408a95..6d57a77c458 100644 --- a/tests/runner-e2e/native-active-stop-flow.ts +++ b/tests/runner-e2e/native-active-stop-flow.ts @@ -136,8 +136,8 @@ export async function runNativeActiveStopFlow(input: { check("explicit-per-turn-policy", configured.adapterConfig?.acpxPermissionMode === "approve-reads" && configured.adapterConfig?.lifecycleMode === "per_turn" && (provider !== "cursor" || configured.adapterConfig?.acpxSessionMode === "agent"), "Agent mode and per-turn restrictive permission policy selected before startup"); const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { name: `Native active Stop ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } }); if (!remote) await sample("before-request"); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name }); - issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.title === execution.task.buildTitle(nonce)), accept: Boolean }))!; + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name }); + issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.id === createdTask.issueId), accept: Boolean }))!; if (remote) { await pollUntil({ label: "active Stop remote bootstrap", deadlineAt: input.deadlineAt, load, accept: state => state.run.status === "running" }); const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => { diff --git a/tests/runner-e2e/native-provider-loss-flow.ts b/tests/runner-e2e/native-provider-loss-flow.ts index 1652179b2fe..d9a63e36325 100644 --- a/tests/runner-e2e/native-provider-loss-flow.ts +++ b/tests/runner-e2e/native-provider-loss-flow.ts @@ -104,9 +104,9 @@ export async function runNativeProviderLossFlow(input: { await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", acpxSessionMode: "agent", timeoutSec: 120, lifecycleMode: "per_turn" } }); const project = await api.post(`/api/companies/${fixtures.company.id}/projects`, { name: `Native provider loss ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } }); if (!remote) await sample("before-request"); - await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name }); + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name }); issue = (await pollUntil({ label: "browser-created provider-loss task", deadlineAt: input.deadlineAt, - load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }))!; + load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: Boolean }))!; if (remote) { await pollUntil({ label: "provider-loss bootstrap", deadlineAt: input.deadlineAt, load, accept: value => value.run.status === "running" }); fixture = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => { diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index f5f625ac7e3..2a017a32ec3 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -1079,9 +1079,9 @@ for (const execution of executions) { matcherResults = evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${check.id}`, expected: true }, passed: check.passed, detail: check.detail })); }, createOrdinary: async (taskTitle, taskPrompt) => { - await createTaskThroughUi({ page, issuePrefix: fixtures!.company.issuePrefix!, agentName: fixtures!.agent.name, title: taskTitle, prompt: taskPrompt, workMode: "standard" }); + const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures!.company.issuePrefix!, agentName: fixtures!.agent.name, title: taskTitle, prompt: taskPrompt, workMode: "standard" }); const created = await pollUntil({ label: "ordinary UI-created task", deadlineAt: startedAtMs + deadlineMs, - load: async () => (await api.get(`/api/companies/${fixtures!.company.id}/issues?limit=100`)).find(row => row.title === taskTitle), accept: row => Boolean(row) }); + load: async () => (await api.get(`/api/companies/${fixtures!.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: row => Boolean(row) }); if (!created) throw new Error("Missing ordinary task"); return created; }, @@ -1106,8 +1106,7 @@ for (const execution of executions) { { timeout: Math.max(1, startedAtMs + deadlineMs - Date.now()) }) .catch((cause: unknown) => new Error("Could not capture task creation response", { cause })) : null; - turnSubmissionTimesMs.push( - await createTaskThroughUi({ + const createdTask = await createTaskThroughUi({ page, issuePrefix, agentName: fixtures.agent.name, @@ -1115,8 +1114,9 @@ for (const execution of executions) { prompt, workMode: execution.task.workMode, projectName: fixtures.project?.name, - }), - ); + requireExplicitTitle: execution.suite.id === "task-titles" && Boolean(title), + }); + turnSubmissionTimesMs.push(createdTask.submittedAtMs); const deadlineAt = startedAtMs + deadlineMs; if (titleCreationResponse) { @@ -1131,9 +1131,9 @@ for (const execution of executions) { deadlineAt, load: async () => { const issues = await api.get( - `/api/companies/${fixtures!.company.id}/issues?q=${encodeURIComponent(title)}&limit=50`, + `/api/companies/${fixtures!.company.id}/issues?limit=50`, ); - return issues.find((candidate) => candidate.title === title); + return issues.find((candidate) => candidate.id === createdTask.issueId); }, accept: (candidate): candidate is IssueRecord => Boolean(candidate), }); diff --git a/tests/runner-e2e/user-actions.ts b/tests/runner-e2e/user-actions.ts index 70c88b41271..812d24cce00 100644 --- a/tests/runner-e2e/user-actions.ts +++ b/tests/runner-e2e/user-actions.ts @@ -8,6 +8,7 @@ export async function createTaskThroughUi(input: { prompt: string; workMode: "standard" | "planning" | "ask"; projectName?: string; + requireExplicitTitle?: boolean; }) { const issuesUrl = `/${encodeURIComponent(input.issuePrefix)}/issues`; const newTask = input.page.getByRole("button", { name: "New Task" }).first(); @@ -33,41 +34,42 @@ export async function createTaskThroughUi(input: { ); } await newTask.click(); - await input.page.getByPlaceholder("Task title").fill(input.title); - await input.page - .getByRole("dialog") + const dialog = input.page.getByRole("dialog", { name: "New task", exact: true }); + const titleInput = dialog.getByRole("textbox", { name: "Task title", exact: true }); + if (await titleInput.isVisible()) await titleInput.fill(input.title); + else if (input.requireExplicitTitle) throw new Error("This title-preservation case requires a visible explicit title input"); + await dialog .getByRole("textbox", { name: "editable markdown", exact: true }) .fill(input.prompt); if (input.workMode !== "standard") { - await input.page - .getByRole("dialog") - .locator(`[data-issue-work-mode-chip="standard"]`) - .click(); - await input.page - .locator(`[data-issue-work-mode="${input.workMode}"]`) - .click(); + await dialog.getByRole("button", { name: "Add to composer", exact: true }).click(); + await input.page.getByTestId(input.workMode === "planning" ? "composer-add-plan" : "composer-add-ask").click(); } - await input.page - .getByRole("button", { name: "Assignee", exact: true }) + await dialog + .getByRole("button", { name: "Select assignee", exact: true }) .click(); - await input.page - .getByPlaceholder("Search assignees...") - .fill(input.agentName); - await input.page.getByText(input.agentName, { exact: true }).last().click(); + await input.page.getByRole("searchbox", { name: "Search assignees", exact: true }).fill(input.agentName); + await input.page.getByRole("option").filter({ has: input.page.getByText(input.agentName, { exact: true }) }).click(); + await expect(input.page.getByRole("searchbox", { name: "Search assignees", exact: true })).toBeHidden(); if (input.projectName) { - const dialog = input.page.getByRole("dialog"); - // Selecting the assignee advances focus to this selector and opens it. - // Focus is idempotent here; clicking would toggle an already-open popover - // closed before the search field can be filled. - await dialog.getByRole("button", { name: "Project", exact: true }).focus(); - await dialog.getByPlaceholder("Search projects...").fill(input.projectName); - await dialog.getByText(input.projectName, { exact: true }).last().click(); + await dialog.getByRole("button", { name: "Project", exact: true }).click(); + await input.page.getByPlaceholder("Search projects...").fill(input.projectName); + await input.page.getByText(input.projectName, { exact: true }).last().click(); } const submittedAtMs = Date.now(); - await input.page - .getByRole("button", { name: "Create Task", exact: true }) - .click(); - return submittedAtMs; + // The prompt-only composer generates a provisional title which the provider + // can rename immediately. Bind the fixture to the actual creation response. + const [response] = await Promise.all([ + input.page.waitForResponse(response => response.request().method() === "POST" + && /^\/api\/companies\/[^/]+\/issues$/.test(new URL(response.url()).pathname)), + dialog.getByRole("button", { name: "Create task", exact: true }).click(), + ]); + expect(response.status()).toBe(201); + const issue = await response.json() as { id: string; companyId: string }; + expect(new URL(response.url()).pathname).toBe(`/api/companies/${issue.companyId}/issues`); + expect(issue.id).toEqual(expect.any(String)); + expect(issue.id.length).toBeGreaterThan(0); + return { submittedAtMs, issueId: issue.id }; } export async function submitTaskReply(