Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
| "onDidChangeDiffLayout"
| "setupTooltip"
| "notify"
| "confirm"
| "ready"
| "reportDiagnostic"
| "reportSidebar"
Expand All @@ -1472,6 +1473,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
setDiffLayout: (scope, layout) => this.diffViews.layout(scope).set(layout),
onDidChangeDiffLayout: (scope, listener) => this.diffViews.layout(scope).onDidChange(listener),
notify: (notification) => showReviewNotification(this.notificationService, notification),
confirm: async (request) => (await this.dialogService.confirm({ type: 'warning', ...request })).confirmed,
setupTooltip: this.setupTooltip,
ready: () => {
if (generation !== this.loadGeneration || !this.targetDocument) return;
Expand Down
53 changes: 52 additions & 1 deletion apps/review-desktop/scripts/e2e/journeys/share-link.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** A board shared, edited and shared again keeps one link, with a revision per version. */
/** A board shared, edited and shared again keeps one link, with a revision per version, until it is revoked. */
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
import path from "node:path";
Expand All @@ -11,6 +11,7 @@ export const name = "share-link";
export const phase = 1;

export const options = {
settings: { "window.dialogStyle": "custom" },
async beforeLaunch(ctx) {
ctx.service = await startShareService();
await writeFile(
Expand Down Expand Up @@ -90,6 +91,56 @@ export async function run(ctx) {
assert.equal(service.posts.length, 2);
assert.equal(await input.inputValue(), u1);
ctx.check("reopening an unchanged board uploads nothing");

const rows = page.locator("[data-share-revision]");

await ctx.until(async () => (await rows.count()) === 2, "two history rows");

const [latest, older] = await rows.allTextContents();

assert.match(
latest,
new RegExp(` · v${revisions.items[0].version} · .* · Latest$`),
);
assert.match(older, new RegExp(` · v${revisions.items[1].version} · `));
assert.doesNotMatch(older, /Latest/);
ctx.check("the popover lists both revisions, the newest marked Latest");

const revokeButton = page.getByRole("button", { name: "Revoke link" });

const dialog = ctx.page
.getByRole("dialog")
.filter({ hasText: "Revoke this share link?" });

const notice = page.getByText(
"Link revoked. Sharing this board again creates a new link.",
);

await revokeButton.click();
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await dialog.waitFor({ state: "hidden" });
await input.waitFor();
assert.equal(service.links.get(reviewId)?.shareId, shareId);
ctx.check("cancelling the revoke keeps the link and the popover");

await revokeButton.click();
await dialog.getByRole("button", { name: "Revoke", exact: true }).click();
await notice.waitFor();
assert.equal(service.links.has(reviewId), false);
ctx.check("revoking deletes the link at the service");

await page.keyboard.press("Escape");
await notice.waitFor({ state: "detached" });
await button.click();
await ctx.until(
async () =>
service.posts.length === 3 &&
Boolean(await input.inputValue().catch(() => "")),
"the new link",
);
assert.notEqual(await input.inputValue(), u1);
assert.notEqual(service.links.get(reviewId).shareId, shareId);
ctx.check("sharing a revoked board again uploads a new link");
} finally {
await service.stop();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/review-protocol/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,12 @@ export interface ReviewCanvasBridge {
listener: (show: boolean) => void,
): ReviewDisposable;
notify?(notification: ReviewNotification): ReviewDisposable;
/** Asks in the host's modal dialog; resolves true when the primary button is chosen. */
confirm?(request: {
message: string;
detail?: string;
primaryButton: string;
}): Promise<boolean>;
setupTooltip?(
target: HTMLElement,
text: string,
Expand Down
1 change: 1 addition & 0 deletions packages/review/app/src/canvas-query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createCanvasQueryClient(): QueryClient {
export const canvasQueryKeys = {
sharingAccount: () => ["sharing", "account"] as const,
shareLink: (board: string) => ["sharing", "link", board] as const,
shareHistory: (shareId: string) => ["sharing", "history", shareId] as const,
traceList: (
board: string,
version: number | undefined,
Expand Down
8 changes: 7 additions & 1 deletion packages/review/app/src/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, it } from "vitest";

import { formatRelativeTime } from "./format";
import { formatBytes, formatRelativeTime } from "./format";

const now = Date.parse("2026-10-15T12:00:00Z");

Expand All @@ -27,3 +27,9 @@ it("writes hours and days in short for a narrow row", () => {
"2d ago",
);
});

it("writes a size in B, KB or MB", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(2048)).toBe("2 KB");
expect(formatBytes(1_572_864)).toBe("1.5 MB");
});
10 changes: 10 additions & 0 deletions packages/review/app/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,13 @@ export function formatDuration(durationMs: number): string {

return rest ? `${hours}h ${rest}m` : `${hours}h`;
}

/** A byte count to one decimal: "512 B", "2 KB", "1.5 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kilobytes = bytes / 1024;

if (kilobytes < 1024) return `${Number(kilobytes.toFixed(1))} KB`;

return `${Number((kilobytes / 1024).toFixed(1))} MB`;
}
200 changes: 199 additions & 1 deletion packages/review/app/src/share-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import { ShareControl, SharingContext } from "./share-control";

let dispose: (() => void) | undefined;

const shareId = "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a";

const revision5 = "0b8d6c3e-3a9f-4c1e-8f2a-5d7e9b1c4a60";

const revision4 = "1c9e7d4f-4b0a-4d2f-9a3b-6e8f0c2d5b71";

const u5 = `https://app.dev.fast/s/capability?rev=${revision5}`;

const u4 = `https://app.dev.fast/s/capability?rev=${revision4}`;

afterEach(async () => {
await act(async () => dispose?.());
document.body.replaceChildren();
Expand All @@ -28,12 +38,15 @@ function mount(options: {
holdPublish?: Promise<void>;
holdVersion?: { version: number; until: Promise<void> };
sender?: string;
confirm?: () => Promise<boolean>;
historyStatus?: number;
}) {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const state = { ...options };
const posts: Array<{ path: string; body: unknown }> = [];
const telemetry: string[] = [];
let accountReads = 0;
let historyReads = 0;

const session = testReviewSession(
{},
Expand All @@ -44,6 +57,7 @@ function mount(options: {

return Response.json({ ok: true });
},
confirm: options.confirm,
},
);

Expand Down Expand Up @@ -75,6 +89,43 @@ function mount(options: {
});
}

if (path.includes("/history/")) {
historyReads += 1;

if (state.historyStatus === 401) state.signedIn = false;

if (state.historyStatus)
return Response.json(
{ error: "History is unavailable." },
{ status: state.historyStatus },
);

return Response.json({
latest: revision5,
items: [
{
id: revision5,
version: 5,
size: 2048,
createdAt: Date.now(),
url: u5,
},
{
id: revision4,
version: 4,
size: 1024,
createdAt: Date.now() - 2 * 3_600_000,
url: u4,
},
],
});
}

if (path.endsWith("/open")) return Response.json({ opened: true });

if (path.endsWith("/revoke"))
return Response.json({ shareId, revoked: true });

if (state.holdPublish) await state.holdPublish;

if (
Expand All @@ -101,7 +152,7 @@ function mount(options: {
);

return Response.json({
shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a",
shareId,
url: "https://app.dev.fast/s/capability",
version: JSON.parse(String(init!.body)).version,
});
Expand Down Expand Up @@ -163,6 +214,7 @@ function mount(options: {
click,
settle,
accountReads: () => accountReads,
historyReads: () => historyReads,
};
}

Expand Down Expand Up @@ -410,6 +462,152 @@ it("shows a newer version's link when the popover opens on an older version", as
expect(container.querySelector("input")?.value).toContain("/s/capability");
});

it("lists the link's revisions newest first, marks the latest and opens one in the browser", async () => {
const harness = mount({ signedIn: true });
const { container } = harness;

await harness.render(5);
await harness.click("Share review");
await harness.settle();
expect(
[...container.querySelectorAll("[role=dialog] [data-share-revision]")].map(
(row) => row.textContent,
),
).toEqual(["just now · v5 · 2 KB · Latest", "2 hours ago · v4 · 1 KB"]);
await harness.click("2 hours ago · v4 · 1 KB");
expect(harness.posts.at(-1)).toEqual({
path: "/reviews-api/sharing/open",
body: { url: u4 },
});
expect(container.textContent).not.toContain("Revoke link");
await harness.click("Share review");
await harness.render(6);
await harness.click("Share review");
await harness.settle();
expect(publishes(harness)).toHaveLength(2);
expect(harness.historyReads()).toBe(2);
});

it("revokes the link after the host confirms, and the next opening shares a new one", async () => {
let answer = false;
const confirm = vi.fn<() => Promise<boolean>>(async () => answer);
const harness = mount({ signedIn: true, confirm });
const { container } = harness;

const posts = (suffix: string) =>
harness.posts.filter((post) => post.path.endsWith(suffix));

await harness.render(5);
await harness.click("Share review");
await harness.settle();
await harness.click("Revoke link");
expect(confirm).toHaveBeenCalledTimes(1);
expect(posts("/revoke")).toHaveLength(0);
answer = true;
await harness.click("Revoke link");
await harness.settle();
expect(posts("/revoke").map((post) => post.body)).toEqual([{ shareId }]);
expect(container.textContent).toContain(
"Link revoked. Sharing this board again creates a new link.",
);
expect(container.querySelector("input")).toBeNull();
expect(publishes(harness)).toHaveLength(1);
await harness.click("Share review");
await harness.click("Share review");
await harness.settle();
expect(publishes(harness)).toHaveLength(2);
expect(container.querySelector("input")?.value).toContain("/s/capability");
expect(container.textContent).not.toContain("Link revoked");
});

it("shows a history failure under the link and keeps it usable", async () => {
const harness = mount({ signedIn: true, historyStatus: 500 });
const { container } = harness;

await harness.render(5);
await harness.click("Share review");
await vi.waitFor(async () => {
await harness.settle();
expect(container.textContent).toContain("Could not load share history.");
});
expect(container.querySelector("[role=alert]")).toBeNull();
expect(container.querySelector("input")?.value).toContain("/s/capability");
const dialog = container.querySelector("[role=dialog]")!;
expect(
[...dialog.children].findIndex((child) =>
child.textContent?.includes("Could not load share history."),
),
).toBeGreaterThan(
[...dialog.children].findIndex((child) => child.tagName === "INPUT"),
);
expect(
[...dialog.querySelectorAll("button")].map((button) => button.textContent),
).toContain("Copy link");
});

it("shows sign-in when the history read finds the login expired", async () => {
const harness = mount({ signedIn: true, historyStatus: 401 });
const { container } = harness;

await harness.render(5);
await harness.settle();
const reads = harness.accountReads();
await harness.click("Share review");
await vi.waitFor(async () => {
await harness.settle();
expect(harness.historyReads()).toBe(1);
expect(harness.accountReads()).toBeGreaterThan(reads);
expect(
[...container.querySelectorAll("[role=dialog] button")].map(
(button) => button.textContent,
),
).toContain("Sign in to share");
});
});

it("opens one confirmation while the first is unanswered", async () => {
let answer: (confirmed: boolean) => void = () => {};

const confirm = vi.fn<() => Promise<boolean>>(
() => new Promise((resolve) => (answer = resolve)),
);

const harness = mount({ signedIn: true, confirm });

await harness.render(5);
await harness.click("Share review");
await harness.settle();
await harness.click("Revoke link");
await harness.click("Revoke link");
expect(confirm).toHaveBeenCalledTimes(1);
await act(async () => answer(false));
await harness.click("Revoke link");
expect(confirm).toHaveBeenCalledTimes(2);
});

it("shares again from the revoked state without closing", async () => {
const harness = mount({ signedIn: true, confirm: async () => true });
const { container } = harness;

await harness.render(5);
await harness.click("Share review");
await harness.settle();
await harness.click("Revoke link");
await harness.settle();
expect(container.textContent).toContain("Link revoked");
await harness.click("Share again");
await harness.settle();

const [first, second] = publishes(harness).map(
(post) => (post.body as { requestId: string }).requestId,
);

expect(second).toBeDefined();
expect(second).not.toBe(first);
expect(container.querySelector("input")?.value).toContain("/s/capability");
expect(container.textContent).not.toContain("Link revoked");
});

it("polls only while sign-in is pending and stops when the control unmounts", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"],
Expand Down
Loading
Loading