mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/218/changes/paperclip/218 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - GitHub callbacks can fail before the app records an inbox row. > - Direct GitHub integrations need bounded recovery from provider delivery history. > - The chat timer checks for due recovery every second, even without an eligible integration. > - Existing recovery checkpoints already store the next scan time and runtime epoch. > - This pull request schedules those saved obligations and wakes on committed configuration changes. > - It removes the final chat maintenance interval while preserving authenticated recovery. ## Linked Issues or Issue Description **What happened?** The app queries due GitHub recovery endpoints every second. This still runs when no direct GitHub integration needs recovery. Other chat maintenance duties already have independent workers in the preceding stack. **What did you expect?** Reconstruct existing recovery deadlines at startup and after committed changes. Run only when an eligible endpoint reaches its saved deadline. Make no recurring SQL or HTTP calls for unconfigured, disabled, or Cloud-relayed integrations. **Steps to reproduce** Start an instance without an active direct GitHub integration. Leave it idle. The chat maintenance interval continues to call processFailedGitHubWebhookDeliveries each second. Follows merged #17664. Related #15981 owns the original GitHub webhook ingress/recovery separation. #17662 preserves review-check recovery, which is a separate obligation. No duplicate scheduling migration was found in the related-PR search. ## What Changed - Put deadline reconstruction, scopes, and registration in the GitHub provider owner. Read existing recovery checkpoints in pages of 100. Reload current eligibility before each dispatch. - Dispatch batches of five through the existing shared helper. Preserve company fairness and the default limit of 25 for other consumers. - Notify inside configuration, credential, checkpoint, installation-lifecycle, and Cloud-registration transactions. A signed installation deletion reproduced a missing wake before the fix. - Detect credential-only epoch changes during a future backoff. Preserve the existing floor reset without adopting old events into new credentials. - Gate every new recovery HTTP call on dispatch admission and shutdown. Join in-flight work and retain its existing outcome/uncertainty settlement. Other queues remain independent. - Remove the final chat reconciliation interval and its unused coordinator. Update the old coordinator fixtures; preserve the voice retention worker acceptance. - Restore known-unsent redelivery claims after drain/shutdown, guarded by exact attempt identity. Preserve uncertain outcomes after transport starts and never overwrite a newer receipt. - Keep all existing redelivery, source, authority, lease, receipt, and callback checks. Add no table, migration, or external wake contract. ## Verification Current source: `ab00fc5b30d51d6c85891536599e7bdfd0c822fe`. - **751 distinct cases pass** at `3d7f163bc5259b60f9bd19b7fc90b402f0a6e925`: 274 complete GitHub review cases, 294 other GitHub-named cases, 30 publication cases, 145 voice/retention cases, and eight startup cases. - All 19 new recovery worker cases and all six stop-before-transport variants pass. Coverage includes configured eligibility, callback-only admission, committed configuration changes, outer transaction settlement, restart/drain/standby, saved deadlines, fair batches, independent queues, held HTTP, joined shutdown, known-unsent restoration, and newer-receipt fencing. - The first workflow filter omitted the separate recovery group. The saved case list exposed the gap. The second selection ran the remaining GitHub cases in a new database. Passed-name sets are disjoint. The two commands filtered 1,504 and 1,484 unrelated cases respectively. No full chat or repository-suite claim is made. - Database build, server typecheck/build, module boundaries, and migration order pass. Five fresh PostgreSQL fixtures receive the unchanged parent migration history through 0403. This slice adds no database migration or table. - The parent browser-fixture repair is carried with both complete patches preserved. At predecessor `836acef0370d42323fdfdbd6d62a805b2469f1d4`, entire server, package and UI source trees and owned hashes match the 751-case acceptance. The current follow-up adds three test cases and audit evidence; production source remains unchanged. The parent has merged. - [The owning audit](https://github.com/paperclipai/paperclip/blob/ab00fc5b30d51d6c85891536599e7bdfd0c822fe/doc/plans/2026-10-25-github-webhook-recovery-sleep-audit.md) preserves earlier failures and exact acceptance scope. Fresh exact-head Greptile and all CI gates remain required. ## Risks - Direct GitHub endpoints still require the existing one-minute external history cadence, with five-minute failure/claim backoff and provider backoff up to one day. Cloud relay endpoints are excluded. This does not promise network-idle direct integrations. - The existing history, envelope, attempt, and lease limits remain. Provider history never substitutes for a genuine authenticated callback. - Supported producers share the current database owner's process-local signals. Independent SQL writers and other server processes need their own integration. Unknown transaction outcomes remain held by the existing reconciliation protocol. - Already-started HTTP keeps its existing timeout and is joined during shutdown. No later request starts after drain or stop; durable attempt uncertainty is preserved. - Host-sleep eligibility and Cloud alarm coverage stay unchanged. Local worker tests do not prove host/database suspension or fleet savings. - Each remaining sleep slice needs its own current review and CI checks before merge. ## Model Used OpenAI Codex (GPT-6), with reasoning and code execution. The exact serving model ID and context-window size were not exposed. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Parent startup-test correction: #17664 fixes its legacy coordinator boundary marker. This slice removes that coordinator and timer entirely, so its existing absence assertions supersede that obsolete inspection. All other parent and upstream changes are preserved. At predecessor `836acef0370d42323fdfdbd6d62a805b2469f1d4`, complete server, package and UI trees match the 751-case acceptance; all eight startup cases also pass after the explicit conflict resolution. Fresh review and CI are required for this new head. ## Required review finding: orphan assignment investigation The reported orphan endpoint cannot be committed through the existing schema: assigned_agent_id is NOT NULL, its agent FK is ON DELETE RESTRICT, and a validated nondeferrable composite FK enforces the same company. A first reproduction failed at fixture mutation with PostgreSQL 23502 before reaching the worker; that failure remains recorded. Three new real-PostgreSQL cases verify NULL/missing/deleted assignments are rejected, then prove the preserved valid endpoint recovers once, saves its next deadline, and makes no SQL reads during a 15-second pre-deadline interval. All 22 recovery-scheduling cases pass (19 previous plus three new); unselected cases are not counted. The preceding style-only commit preserved production behavior and non-comment tokens, and the prior 751-case evidence retains its original scope (754 distinct cases across both selections). A new review must assess this evidence before merge; the old conditional verdict is not waived. Actual parent squash ancestry is carried with whole-tree identity before this test-only follow-up. Repository style follow-up removes seven new code-comment lines from the recovery ingress owner and constraint tests. Their explanations remain in the audit. Both files retain identical non-comment TypeScript token streams, preserving all guards, assertions and deadlines. Production eligibility SQL is unchanged. Fresh current-head CI and reviews remain required. ## CI test corrections The original strict deletion assertion reproduced CI's PostgreSQL-18 failure locally: the forbidden delete reports 23001 (restrict violation), versus 23503 on PostgreSQL 17. The corrected assertion accepts only these integrity codes for deletion, retains exact codes for NULL/missing assignments, and verifies the original agent and assignment remain. All 22 recovery cases pass on embedded PostgreSQL 18; the same three integrity probes pass on PostgreSQL 17. A delayed real announcement response reproduced the attachment E2E's filechooser timeout: the dismiss handler tried to click outside an already-open modal menu. It now closes the menu, dismisses through the UI, and restores that menu so the original action resumes. Controlled late-announcement acceptance passes; the temporary fault is removed. All 11 complete attachment receipt journeys pass, retries zero and original assertions/budgets unchanged, using a fresh supported external PostgreSQL-17 database with canonical isolated onboarding/server/UI/storage. An earlier embedded-cluster startup failure occurred before tests and remains recorded. Production server typecheck passes; production source and schema are unchanged. Fresh current-head CI and review are required. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
expect,
|
|
test,
|
|
type APIRequestContext,
|
|
type Locator,
|
|
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 have no recipient: 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) {
|
|
// Announcements can appear after navigation or reload and cover the classic
|
|
// composer's attachment button. Dismiss through the same UI as a Board user.
|
|
await page.addLocatorHandler(
|
|
page.getByRole("complementary", { name: "Paperclip announcements", exact: true })
|
|
.getByRole("button", { name: "Dismiss announcement", exact: true }),
|
|
async (dismiss) => {
|
|
const menuOpen = await page.getByRole("menu", { name: "Add to composer", exact: true }).isVisible();
|
|
if (menuOpen) await page.keyboard.press("Escape");
|
|
await dismiss.click();
|
|
if (menuOpen) await page.getByRole("button", { name: "Add to composer", exact: true }).click();
|
|
},
|
|
);
|
|
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",
|
|
// Keep attachment sends on the comment route. A task assigned to the
|
|
// current human now suggests an empty recipient and sends a reassignment.
|
|
assigneeUserId: null,
|
|
},
|
|
}),
|
|
);
|
|
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,
|
|
sendComment: async () => {
|
|
await send.click();
|
|
if (classic) {
|
|
const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway");
|
|
await expect(confirm).toBeVisible();
|
|
await confirm.click();
|
|
}
|
|
},
|
|
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 openAttachmentChooser(page: Page, composer: Locator) {
|
|
await composer.getByRole("button", { name: "Add to composer" }).click();
|
|
const chooser = page.waitForEvent("filechooser");
|
|
await page.getByRole("menuitem", { name: "Files and images", exact: true }).click();
|
|
return chooser;
|
|
}
|
|
|
|
async function upload(
|
|
page: Page,
|
|
fixture: Awaited<ReturnType<typeof setup>>,
|
|
file: (typeof files)[number],
|
|
) {
|
|
const chooser = await openAttachmentChooser(page, fixture.composer);
|
|
const response = page.waitForResponse(
|
|
(res) =>
|
|
res.request().method() === "POST" &&
|
|
new URL(res.url()).pathname.endsWith(
|
|
`/issues/${fixture.issue.id}/attachments`,
|
|
),
|
|
);
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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.sendComment();
|
|
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 = await openAttachmentChooser(page, fixture.composer);
|
|
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.sendComment();
|
|
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 = await openAttachmentChooser(page, fixture.composer);
|
|
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.sendComment();
|
|
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);
|
|
});
|