mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The native runner connects task messages, provider execution, and task outcomes. > - First-time user tests exposed gaps in recovery, completion permissions, message delivery, and Stop behavior. > - These gaps left usable output hidden, completed work waiting for bookkeeping, or safe work unable to continue. > - This pull request fixes the shared lifecycle and receipt paths while preserving process ownership and action checks. > - Users can continue work with accurate task state and durable messages. ## Linked Issues or Issue Description **What happened?** A stopped local Codex execution could remain blocked even after its processes had stopped and its complete transcript proved that no external action needed replay. Claude under Conservative permissions could fail to call task completion tools. Recovery could reuse an assistant item ID and overwrite prior output. A delivered comment could remain marked uncertain after navigation. Stop could look like Pause or a new recovery incident. Workspace contention could look like cancellation. A direct reply reopening Done could enter a clarification loop. **Expected behavior** Recover automatically only with verified termination and complete action receipts. Preserve answers and messages. Keep task completion available under Conservative permissions without broad tool access. Show crashes as Blocked, actual human decisions as In Review, and ordinary workspace contention as waiting. Stop the current response and allow a new direction. **Steps to reproduce** 1. Create ordinary response tasks with local Codex and Claude Code, then send follow-up messages through the task composer. 2. Interrupt a disposable local Codex runner during text-only work. Verify automatic continuation and retained output. 3. Stop a response, send a new request, answer a clarification, and reopen completed work with another message. 4. Navigate or reload while a comment submission is pending. Confirm the exact persisted request receipt settles it without removing newer draft text. 5. Run two tasks in a shared Daytona workspace. Confirm waiting does not appear as failure. **Paperclip version or commit** Initial acceptance baseline: `c9021c6721f91e2c74bd9fee9d3fd41c999d17b7`. Current integration base: `6cef9743c`. Both operator-interruption and workspace-waiting guards are preserved; native restart and legacy permission rules remain documented. **Deployment mode** An isolated source-built test-drive instance, with real local Codex and Claude Code providers and disposable Daytona environments. Related work: #13314, #13316, #13327, #13344, #13239, #13254, #13163. This PR addresses additional failures from ordinary task journeys, including controller restart handoff and repeated warm sandbox setup. Historical task status reconciliation is excluded. ## What Changed - Persist runner ownership immediately at spawn and resume an explicitly adopted runner even when the controller crashed before the first driver checkpoint. Detach the controller safely across graceful restarts, including session startup. Prevent an old finalizer from suspending or signaling an adopted runner. Checkpoint idle warm sessions before shutdown. Preserve the same run and queued follow-up messages. - Scope saved legacy queue successor checks to the queue owner while preserving ordinary task locks, operator identity, assignment gates, and exactly-once delivery. - Preserve managed Codex credential files when an old session is detached for restart; normal owned cleanup still copies refreshed auth back and removes the scoped copy. - Reuse the bound warm shared sandbox and fully verify an existing staged provider pack before using it. This avoids repeated uploads when the pack is already valid. - Add a narrow local Codex replacement path with stopped-process proof, a closed transcript inventory, exact completion receipts, and fresh-session lineage. Preserve no-replay holds when evidence is incomplete. Recovery may clear only the same run's recorded Blocked status version; manual re-blocking and dependency changes invalidate that receipt, while queued comments do not. Later blocks stop scheduled, queued, and final dispatch; queued/final checks re-read dependencies even when the task status stays In Progress. - Permit only task delivery and human-input tools through the isolated Claude runner's exact task bridge. - Scope assistant item identity to the provider turn and ignore only authority-free Codex skill-change notifications during startup. - Reconcile composer submissions by client request ID across response loss, navigation, and reload. Retain text typed during delivery. - Keep acknowledged run-only Stop neutral and show workspace contention as waiting. Project exhausted native failures as Blocked. - Restore the guarded task-page retry action for failed legacy runs, including the server-supported explicit new-attempt path for stopped conversation adapters. Preserve native/process recovery holds and avoid promising Retry while a decision or execution gate hides it. - Refresh delivered artifacts and handle direct user replies that reopen completed work without a clarification loop. - Check the embedded PostgreSQL PID, data directory, and actual port before connecting or migrating. - Document accepted behavior and add focused regressions at lifecycle, route, transcript, and UI boundaries. ## Verification - Final head `fece606ac2` passes the complete GitHub CI matrix: **34 green checks, two expected Storybook skips, no failures or pending checks**, including `ci / verify`, `ci / e2e`, full runner verification, typecheck, build, every server/workspace shard, and all browser shards. [CI run](https://github.com/paperclipai/paperclip/actions/runs/34727183287). Greptile is **5/5 with no open findings**. The final two commits only refine test fixtures; both affected suites pass 24/24 locally and in CI, with server typecheck green. - Complete local Vitest coverage uses the canonical groups/shards: all 635 general server suites, all 145 serialized suites, and all workspace packages. The aggregate began on `0a8001c18` while the final queue fix arrived: 23,903 passed, five failed, 87 skipped. The five port/socket/timing failures passed unchanged in follow-ups (60 tests in the exposure/file suites and 412 tests covering the serialized failures and unrun tails). The final queue/operator-identity suites separately passed 52/52. This is aggregate coverage plus explicit reruns, not a pristine single-command final-head run. - After integration with current master, queue/operator-identity/continuation suites passed 162/162 and affected UI suites passed 140/140. ACP Stop/continuation and legacy task/Inbox/message browser suites passed 9/9, including both task recovery Retry and thread Try again, automatic saved-message delivery, exactly one new run, Done, and retained output after reload. The default process Stop/Pause/Resume browser case passed (the native-provider case is opt-in and skipped by default). The complete Board attachment/receipt browser suite passed 11/11 on a disposable instance, covering both composers, exact receipts after lost responses, no replay, bound attachments, and newer drafts after reload. - Blocking-intent regressions cover pre-existing Blocked, a mismatched run/cause, an explicit manual re-block, changed dependencies, a queued comment after failure, and a block arriving between scheduling and provider dispatch. The negative cases reproduced before the fix. All 478 affected executor/recovery/dispatch tests passed; both database suites ran separately after availability-probe skips in the first combined command. The final late-dependency check passed all 143 affected recovery/dispatch tests (zero skips) after two new negative cases reproduced the bug. - Focused runtime regressions cover awaited runner ownership publication, authenticated adoption before the first checkpoint, old-finalizer detachment, idle and busy warm-session shutdown, rejected checkpoint propagation, provider-pack verification, and managed-Codex credential preservation. Four managed credential detachment cases reproduced the bug before the fix; normal owned cleanup still succeeds exactly once. - Live local Claude: SIGKILL 2.6 seconds into startup recovered the same run automatically in 53 seconds, then a normal follow-up completed in 24 seconds. SIGTERM 2.5 seconds into startup preserved the same run (54 seconds) and its queued follow-up (21 seconds). Answers remained visible and the task reached Done. - Live Claude Daytona: a warm follow-up retained its sandbox and fell from 121 seconds to 44 seconds. A separate cold turn took 127 seconds; after controller shutdown and checkpointing, its follow-up completed in 33 seconds with the same sandbox, workspace, native session, and runner. Both answers remained visible and the task was Done. - Other live journeys covered task completion and follow-up with local and Daytona Codex, local Codex crash recovery, Stop then new direction, clarification response, live artifact refresh, and shared-workspace waiting. - Validation limits: the opt-in native composer Stop/Pause→subtree Resume fixture exposes terminal/result ordering and subtree-cancellation attribution bugs that can leave a child task blocked; that new finding is assigned to a separate follow-up and is not claimed fixed here. Default CI skips this optional native-provider fixture. Managed-Codex credential handoff and the queue-agent integration use automated regression evidence. Cold custom provider-pack uploads still add startup latency. ## Risks - Automatic replacement remains deliberately narrow: local Codex, verified stopped identities, unchanged retained state, and a complete text/completion-only turn. Unknown actions, partial history, or changed ownership remain blocked. - Claude completion permission handling changes an upstream package patch. The exact isolated task bridge must remain pinned; unrelated tools keep their existing permissions. - New task failure projection changes user-visible status. No historical status backfill or database migration is included. - This is a broad lifecycle fix across server and UI. Live proof covers graceful local Claude restart during startup and idle Claude Daytona session recovery across controller shutdown. Live abrupt SIGKILL during local Claude startup also recovered the same run. Unknown ownership or missing action evidence still blocks reuse. Cold custom provider-pack uploads still add startup latency; this change avoids unnecessary repeat uploads. ## Model Used OpenAI GPT-6 (Codex), with reasoning, code execution, browser automation, and tool use. The exact hosted model ID and context window are not exposed in this task. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
expect,
|
|
test,
|
|
type APIRequestContext,
|
|
type Page,
|
|
} from "@playwright/test";
|
|
|
|
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
|
|
// Only the interface flag and explicit transport/upload rejection faults are mocked.
|
|
// Tasks belong to the Board user: no agent, runner, or provider is contacted.
|
|
type Attachment = {
|
|
id: string;
|
|
issueCommentId: string | null;
|
|
contentPath: string;
|
|
};
|
|
type Comment = { id: string; body: string; clientRequestId?: string | null };
|
|
|
|
async function body<T>(
|
|
response: Awaited<ReturnType<APIRequestContext["get"]>>,
|
|
): Promise<T> {
|
|
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(
|
|
true,
|
|
);
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async function setup(page: Page, request: APIRequestContext, classic: boolean) {
|
|
const company = await body<{ id: string; issuePrefix: string }>(
|
|
await request.post("/api/companies", {
|
|
data: { name: `Board receipt browser ${randomUUID()}` },
|
|
}),
|
|
);
|
|
const issue = await body<{ id: string; identifier: string }>(
|
|
await request.post(`/api/companies/${company.id}/issues`, {
|
|
data: {
|
|
title: "Inspect the newly uploaded Board files",
|
|
status: "todo",
|
|
assigneeUserId: "local-board",
|
|
},
|
|
}),
|
|
);
|
|
await page.route("**/api/instance/settings/experimental", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ enableClassicTaskInterface: classic }),
|
|
}),
|
|
);
|
|
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
|
await expect(
|
|
page.getByRole("heading", {
|
|
name: "Inspect the newly uploaded Board files",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
const composer = classic
|
|
? page.getByTestId("issue-chat-composer")
|
|
: page.locator(".paperclip-task-chat-composer");
|
|
const editor = composer.getByRole("textbox", {
|
|
name: "editable markdown",
|
|
exact: true,
|
|
});
|
|
const send = classic
|
|
? composer.getByRole("button", { name: /Send|Reply/ }).last()
|
|
: page.getByTestId("task-chat-composer-send");
|
|
await expect(editor).toBeVisible();
|
|
return {
|
|
company,
|
|
issue,
|
|
composer,
|
|
editor,
|
|
send,
|
|
attachments: () =>
|
|
request
|
|
.get(`/api/issues/${issue.id}/attachments`)
|
|
.then(body<Attachment[]>),
|
|
comments: () =>
|
|
request.get(`/api/issues/${issue.id}/comments`).then(body<Comment[]>),
|
|
};
|
|
}
|
|
|
|
const files = [
|
|
{
|
|
name: "board-fresh.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("Object: cat\nAccent: teal\nCount: 3\n"),
|
|
},
|
|
{
|
|
name: "board-fresh.png",
|
|
mimeType: "image/png",
|
|
buffer: Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=",
|
|
"base64",
|
|
),
|
|
},
|
|
];
|
|
|
|
async function upload(
|
|
page: Page,
|
|
fixture: Awaited<ReturnType<typeof setup>>,
|
|
file: (typeof files)[number],
|
|
) {
|
|
const chooser = page.waitForEvent("filechooser");
|
|
await fixture.composer
|
|
.getByRole("button", { name: "Attach file", exact: true })
|
|
.click();
|
|
const response = page.waitForResponse(
|
|
(res) =>
|
|
res.request().method() === "POST" &&
|
|
new URL(res.url()).pathname.endsWith(
|
|
`/issues/${fixture.issue.id}/attachments`,
|
|
),
|
|
);
|
|
await (await chooser).setFiles(file);
|
|
const receipt = await body<Attachment>(await response);
|
|
await expect
|
|
.poll(async () =>
|
|
(await fixture.attachments()).some((item) => item.id === receipt.id),
|
|
)
|
|
.toBe(true);
|
|
return receipt;
|
|
}
|
|
|
|
for (const classic of [false, true]) {
|
|
test(`uploaded file and image receipts survive reload and bind exactly once (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}, testInfo) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill(
|
|
"Inspect only these fresh Board attachments. Keep this internal.",
|
|
);
|
|
const receipts = [];
|
|
for (const file of files) receipts.push(await upload(page, fixture, file));
|
|
const draftKey = `paperclip:issue-comment-draft:${fixture.issue.id}`;
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) =>
|
|
JSON.parse(localStorage.getItem(`${key}:attachments:v1`) ?? "null")
|
|
?.attachments?.length,
|
|
draftKey,
|
|
),
|
|
)
|
|
.toBe(2);
|
|
await expect
|
|
.poll(() => page.evaluate((key) => localStorage.getItem(key), draftKey))
|
|
.toContain("Inspect only these fresh Board attachments.");
|
|
await testInfo.attach("before-reload-draft", {
|
|
body: JSON.stringify(
|
|
await page.evaluate(
|
|
(key) => ({
|
|
text: localStorage.getItem(key),
|
|
receipts: localStorage.getItem(`${key}:attachments:v1`),
|
|
}),
|
|
draftKey,
|
|
),
|
|
),
|
|
contentType: "application/json",
|
|
});
|
|
await page.reload();
|
|
await expect(fixture.editor).toContainText(
|
|
"Inspect only these fresh Board attachments.",
|
|
);
|
|
await expect(
|
|
fixture.composer.getByText("board-fresh.txt", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(fixture.composer.locator("img")).toHaveCount(1);
|
|
const outbound = page.waitForRequest(
|
|
(req) =>
|
|
req.method() === "POST" &&
|
|
new URL(req.url()).pathname.endsWith("/comments"),
|
|
);
|
|
await fixture.send.click();
|
|
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
|
|
receipts.map((row) => row.id).sort(),
|
|
);
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
const [comment] = await fixture.comments();
|
|
const savedBubble = classic
|
|
? page.locator(`[id="comment-${comment!.id}"]`)
|
|
: page.getByTestId("task-chat-human-bubble").filter({
|
|
hasText:
|
|
"Inspect only these fresh Board attachments. Keep this internal.",
|
|
});
|
|
await expect(savedBubble).toHaveCount(1);
|
|
expect(comment.body).toContain(receipts[0]!.contentPath);
|
|
expect(comment.body).toContain(receipts[1]!.contentPath);
|
|
expect(
|
|
(await fixture.attachments()).map((item) => item.issueCommentId),
|
|
).toEqual([comment.id, comment.id]);
|
|
for (let index = 0; index < receipts.length; index++) {
|
|
const downloaded = await request.get(receipts[index]!.contentPath);
|
|
expect(downloaded.ok()).toBe(true);
|
|
expect(await downloaded.body()).toEqual(files[index]!.buffer);
|
|
}
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => localStorage.getItem(`${key}:attachments:v1`),
|
|
draftKey,
|
|
),
|
|
)
|
|
.toBeNull();
|
|
await page.reload();
|
|
await expect(fixture.editor).toBeEmpty();
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await expect(savedBubble).toHaveCount(1);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("bound-board-attachments.png"),
|
|
fullPage: true,
|
|
});
|
|
});
|
|
|
|
test(`lost accepted response settles from its exact receipt without replay (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}, testInfo) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Accepted once: inspect this exact file.");
|
|
const receipt = await upload(page, fixture, files[0]!);
|
|
let accepted = false;
|
|
let attempts = 0;
|
|
let acceptedRequestId: string | undefined;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST") return route.continue();
|
|
attempts++;
|
|
if (accepted) return route.continue();
|
|
acceptedRequestId = route.request().postDataJSON().clientRequestId;
|
|
const response = await route.fetch();
|
|
expect(response.status()).toBe(201);
|
|
accepted = true;
|
|
await route.abort("connectionreset");
|
|
});
|
|
await fixture.send.click();
|
|
await expect.poll(() => accepted).toBe(true);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
|
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
await page.reload();
|
|
// The durable request receipt proves delivery even though the POST reply
|
|
// was lost. No manual Review/Discard bookkeeping or blind replay remains.
|
|
await expect(fixture.editor).toBeEmpty();
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await expect(fixture.composer.getByText("board-fresh.txt", { exact: true })).toHaveCount(0);
|
|
await expect(fixture.send).toBeDisabled();
|
|
await fixture.editor.press("Control+Enter");
|
|
expect(attempts).toBe(1);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("accepted-response-lost.png"),
|
|
fullPage: true,
|
|
});
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
await page.reload();
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await fixture.editor.fill(
|
|
"A deliberately new comment after the original receipt settled.",
|
|
);
|
|
await fixture.send.click();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
|
});
|
|
|
|
test(`known rejection retains the same uploaded receipt for explicit retry (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Known rejection, then retry the same file.");
|
|
const receipt = await upload(page, fixture, files[0]!);
|
|
let rejected = false;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST" || rejected)
|
|
return route.continue();
|
|
rejected = true;
|
|
await route.fulfill({
|
|
status: 422,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Fixture policy rejected this attempt" }),
|
|
});
|
|
});
|
|
await fixture.send.click();
|
|
await expect(fixture.editor).toContainText("Known rejection,");
|
|
await expect(fixture.send).toBeEnabled();
|
|
expect(await fixture.comments()).toHaveLength(0);
|
|
await page.reload();
|
|
await expect(fixture.editor).toContainText("Known rejection,");
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await fixture.send.click();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test(`reload during a pending save settles its receipt and preserves a newer draft (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("One text-only save interrupted by reload.");
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let accepted = false;
|
|
let attempts = 0;
|
|
let acceptedRequestId: string | undefined;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST") return route.continue();
|
|
attempts++;
|
|
acceptedRequestId ??= route.request().postDataJSON().clientRequestId;
|
|
const response = await route.fetch();
|
|
expect(response.status()).toBe(201);
|
|
accepted = true;
|
|
await held;
|
|
// Reload cancels the original browser request; the owned server response
|
|
// has already been observed and must never be replayed by the fixture.
|
|
await route.fulfill({ response }).catch(() => {});
|
|
});
|
|
try {
|
|
await fixture.send.click();
|
|
await expect.poll(() => accepted).toBe(true);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await fixture.editor.fill("A newer draft written while delivery was pending.");
|
|
await page.reload();
|
|
release();
|
|
await expect(fixture.editor).toHaveText(
|
|
"A newer draft written while delivery was pending.",
|
|
);
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await expect(fixture.send).toBeEnabled();
|
|
expect(attempts).toBe(1);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
|
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
|
await fixture.send.click();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
|
expect(attempts).toBe(2);
|
|
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
|
|
"A newer draft written while delivery was pending.",
|
|
"One text-only save interrupted by reload.",
|
|
]);
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const classic of [false, true])
|
|
test(`removed pending inline upload cannot reappear or submit after its HTTP response (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Send without the removed image.");
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let arrived = false;
|
|
await page.route(
|
|
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
|
|
async (route) => {
|
|
const response = await route.fetch();
|
|
arrived = true;
|
|
await held;
|
|
await route.fulfill({ response });
|
|
},
|
|
);
|
|
const chooser = page.waitForEvent("filechooser");
|
|
await fixture.composer
|
|
.getByRole("button", { name: "Attach file", exact: true })
|
|
.click();
|
|
await (await chooser).setFiles(files[1]!);
|
|
await expect.poll(() => arrived).toBe(true);
|
|
await expect(fixture.send).toBeDisabled();
|
|
try {
|
|
await expect(
|
|
fixture.composer.getByText(
|
|
classic ? "Uploading to task" : "Uploading…",
|
|
{ exact: true },
|
|
),
|
|
).toBeVisible();
|
|
const remove = fixture.composer.getByRole("button", {
|
|
name: "Remove board-fresh.png",
|
|
});
|
|
await expect(remove).toBeVisible();
|
|
await remove.click();
|
|
release();
|
|
await expect(fixture.send).toBeEnabled();
|
|
await expect(fixture.composer.locator("img")).toHaveCount(0);
|
|
const outbound = page.waitForRequest(
|
|
(req) =>
|
|
req.method() === "POST" &&
|
|
new URL(req.url()).pathname.endsWith("/comments"),
|
|
);
|
|
await fixture.send.click();
|
|
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect((await fixture.comments())[0]!.body).not.toContain(
|
|
"/api/attachments/",
|
|
);
|
|
expect((await fixture.attachments())[0]!.issueCommentId).toBeNull();
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
|
|
test("legacy failed upload can be removed before sending the retained text", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, true);
|
|
await fixture.editor.fill("Keep this text after removing the failed file.");
|
|
await page.route(
|
|
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
|
|
(route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Fixture upload rejected" }),
|
|
}),
|
|
);
|
|
const chooser = page.waitForEvent("filechooser");
|
|
await fixture.composer
|
|
.getByRole("button", { name: "Attach file", exact: true })
|
|
.click();
|
|
await (await chooser).setFiles(files[0]!);
|
|
await expect(
|
|
fixture.composer.getByText("Fixture upload rejected", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(fixture.send).toBeDisabled();
|
|
const remove = fixture.composer.getByRole("button", {
|
|
name: "Remove board-fresh.txt",
|
|
});
|
|
await expect(remove).toBeVisible();
|
|
await remove.click();
|
|
await fixture.send.click();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect((await fixture.comments())[0]!.body).toBe(
|
|
"Keep this text after removing the failed file.",
|
|
);
|
|
expect(await fixture.attachments()).toHaveLength(0);
|
|
});
|