lib/app-tabs-session.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi } from "vitest";
import { AppTabsSession, type AppTabsTransport } from "./app-tabs-session";
import { createHomeTab, moveAppTabBefore, type AppTab } from "./app-tabs";
import { AppTabRequestError } from "./app-tabs-api";
function setup(initial: AppTab[] = [createHomeTab("owner"), createHomeTab("owner", undefined, 1)]) {
let rows = [...initial];
const transport: AppTabsTransport = {
create: vi.fn(async (ensure, id) => {
if (ensure && rows.length) return rows[0];
const tab = createHomeTab("owner", id, rows.length); rows.push(tab); return tab;
}),
patch: vi.fn(async (tab, patch) => {
const current = rows.find((row) => row.id === tab.id);
if (!current) throw new AppTabRequestError("not_found");
if (current.revision !== tab.revision) throw new AppTabRequestError("conflict", current);
const next = { ...current, ...patch, revision: current.revision + 1 };
rows = rows.map((row) => row.id === tab.id ? next : row); return next;
}),
close: vi.fn(async (tab) => { rows = rows.filter((row) => row.id !== tab.id); }),
move: vi.fn(async (tab, beforeId) => { rows = moveAppTabBefore(rows, tab.id, beforeId); return rows; }),
};
const session = new AppTabsSession("owner", transport);
const navigate = vi.fn(); session.navigate = navigate; session.receive(rows);
return { session, transport, navigate, rows: () => rows, replace: (next: AppTab[]) => { rows = next; session.receive(rows); } };
}
describe("application tab sessions", () => {
it("recovers a unique published view when refresh precedes the active-window snapshot", async () => {
const board = { ...createHomeTab("owner"), href: "/all?view=saved" };
const home = createHomeTab("owner", undefined, 1);
const { session, transport, navigate, rows } = setup([board, home]);
try {
await session.initialize("/all");
expect(session.getSnapshot().activeId).toBe(board.id);
expect(session.getActiveHref()).toBe(board.href);
expect(navigate).toHaveBeenCalledWith(board.href);
expect(transport.create).not.toHaveBeenCalled();
expect(transport.patch).not.toHaveBeenCalled();
expect(rows()).toEqual([board, home]);
} finally { session.dispose(); }
});
it.each(["/all?view=other", "/all?objective=other"])("preserves an explicit selection instead of recovering a published view: %s", async (href) => {
const board = { ...createHomeTab("owner"), href: "/all?view=saved" };
const { session, transport, rows } = setup([board]);
try {
await session.initialize(href);
await vi.waitFor(() => expect(transport.create).toHaveBeenCalledOnce());
expect(session.getActiveHref()).toBe(href);
expect(rows().find((tab) => tab.id === board.id)).toEqual(board);
} finally { session.dispose(); }
});
it("does not guess between two published views without remembered identity", async () => {
const boards = ["first", "second"].map((view, index) => ({ ...createHomeTab("owner", undefined, index), href: `/all?view=${view}` }));
const { session, transport, rows } = setup(boards);
try {
await session.initialize("/all");
await vi.waitFor(() => expect(transport.create).toHaveBeenCalledOnce());
expect(session.getActiveHref()).toBe("/all");
expect(rows().filter((tab) => boards.some((board) => board.id === tab.id))).toEqual(boards);
} finally { session.dispose(); }
});
it("navigates a prepared project in the active tab and persists only after route observation", async () => {
const board = { ...createHomeTab("owner"), href: "/projects/first" };
const other = { ...createHomeTab("owner", undefined, 1), href: "/home" };
const { session, transport, navigate, rows } = setup([board, other]);
try {
await session.initialize(board.href);
expect(session.navigateInCurrentTab("/projects/second")).toBe(true);
await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith("/projects/second"));
expect(session.getSnapshot().activeId).toBe(board.id);
expect(session.getActiveHref()).toBe("/projects/second");
expect(transport.patch).not.toHaveBeenCalled();
session.observe("/projects/second", null); session.onPageHide();
await vi.waitFor(() => expect(rows().find((tab) => tab.id === board.id)?.href).toBe("/projects/second"));
expect(rows().find((tab) => tab.id === other.id)).toEqual(other);
expect(transport.create).not.toHaveBeenCalled();
} finally { session.dispose(); }
});
it("does not leave an unsaved Page when a prepared navigation guard fails", async () => {
const { session, navigate } = setup();
try {
await session.initialize("/home");
let finish!: (saved: boolean) => void;
session.registerDeparture(() => new Promise((resolve) => { finish = resolve; }));
expect(session.navigateInCurrentTab("/projects/second")).toBe(true);
await vi.waitFor(() => expect(finish).toBeDefined());
expect(navigate).not.toHaveBeenCalled(); finish(false);
await vi.waitFor(() => expect(session.getSnapshot().error).toBe("save_failed"));
expect(session.getActiveHref()).toBe("/home");
expect(navigate).not.toHaveBeenCalled();
expect(session.navigateInCurrentTab("https://example.com")).toBe(false);
} finally { session.dispose(); }
});
it.each([true, false])("keeps Home when entering desktop with an existing Home row: %s", async (hasHome) => {
const board = { ...createHomeTab("owner"), href: "/all" };
const home = createHomeTab("owner", undefined, 1);
const original = hasHome ? [board, home] : [board];
const { session, transport, navigate, rows } = setup(original);
await session.initialize("/home", { id: board.id, href: board.href }, true);
await session.retry();
expect(session.getActiveHref()).toBe("/home");
expect(navigate).not.toHaveBeenCalled();
expect(rows().find((tab) => tab.id === board.id)).toEqual(board);
expect(session.getSnapshot().tabs).toHaveLength(2);
if (hasHome) {
expect(session.getSnapshot().activeId).toBe(home.id);
expect(transport.create).not.toHaveBeenCalled();
} else {
expect(session.getSnapshot().activeId).not.toBe(board.id);
expect(transport.create).toHaveBeenCalledOnce();
}
session.dispose();
});
it("does not restore a remembered hidden selection when switching to desktop", async () => {
const pr = { ...createHomeTab("owner"), href: "/pull-requests?pr=a" };
const { session, navigate, rows } = setup([pr]);
await session.initialize("/pull-requests", { id: pr.id, href: pr.href }, true);
await session.retry();
expect(session.getActiveHref()).toBe("/pull-requests");
expect(navigate).not.toHaveBeenCalled();
expect(rows().find((tab) => tab.id === pr.id)).toEqual(pr);
session.dispose();
});
it("reuses an exact destination without replacing the outgoing tab or losing local state", async () => {
const home = createHomeTab("owner");
const pr = { ...createHomeTab("owner", undefined, 1), href: "/pull-requests?pr=a" };
const { session, transport, navigate } = setup([home, pr]);
await session.initialize("/home");
session.setLocalState(`${pr.id}:review`, "selected-file");
expect(session.reuseDestination(pr.href)).toBe(true);
await vi.waitFor(() => expect(session.getSnapshot().activeId).toBe(pr.id));
expect(navigate).toHaveBeenCalledWith(pr.href);
expect(session.getSnapshot().tabs.find((tab) => tab.id === home.id)?.href).toBe("/home");
expect(session.getLocalState(`${pr.id}:review`)).toBe("selected-file");
expect(transport.create).not.toHaveBeenCalled();
expect(session.reuseDestination("/pull-requests")).toBe(false);
expect(session.reuseDestination("/pull-requests?pr=b")).toBe(false);
session.dispose();
});
it("prefers the active duplicate and still allows explicit new tabs", async () => {
const { session, navigate } = setup();
await session.initialize("/home");
await session.activate(session.getSnapshot().tabs[1].id);
const active = session.getSnapshot().activeId;
navigate.mockClear();
expect(session.reuseDestination("/home")).toBe(true);
await session.activate(active!);
expect(session.getSnapshot().activeId).toBe(active);
expect(navigate).not.toHaveBeenCalled();
await session.create("/home");
expect(session.getSnapshot().tabs).toHaveLength(3);
expect(session.getSnapshot().activeId).not.toBe(active);
session.dispose();
});
it("honors editor departure failures when reusing a destination", async () => {
const home = createHomeTab("owner");
const board = { ...createHomeTab("owner", undefined, 1), href: "/all" };
const { session, navigate } = setup([home, board]);
await session.initialize("/home");
session.registerDeparture(async () => false);
expect(session.reuseDestination("/all")).toBe(true);
await vi.waitFor(() => expect(session.getSnapshot().error).toBe("save_failed"));
expect(session.getSnapshot().activeId).toBe(home.id);
expect(navigate).not.toHaveBeenCalled();
session.dispose();
});
it("does not switch tabs when passive route observation reaches a duplicate", async () => {
const home = createHomeTab("owner");
const board = { ...createHomeTab("owner", undefined, 1), href: "/all" };
const { session, navigate } = setup([home, board]);
await session.initialize("/home");
session.observe("/all", null);
expect(session.getSnapshot().activeId).toBe(home.id);
expect(session.getSnapshot().tabs.filter((tab) => tab.href === "/all")).toHaveLength(2);
expect(navigate).not.toHaveBeenCalled();
session.dispose();
});
it("opens new tabs immediately while outgoing synchronization is slow", async () => {
const { session, transport, rows, navigate } = setup(); await session.initialize("/home");
const original = rows()[0];
let finish!: (tab: AppTab) => void;
vi.mocked(transport.patch).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
session.observe("/all", null);
await session.create();
const first = session.getSnapshot().activeId!;
expect(first).not.toBe(original.id);
expect(session.getSnapshot().busy).toBe(false);
expect(navigate).toHaveBeenLastCalledWith("/home");
await session.create();
expect(session.getSnapshot().tabs).toHaveLength(4);
session.receive(rows());
expect(session.getSnapshot().tabs).toHaveLength(4);
expect(session.getSnapshot().recovering).toBe(false);
finish({ ...original, href: "/all", revision: 2 });
await session.retry();
expect(transport.create).toHaveBeenCalledTimes(2); session.dispose();
});
it("renders a new tab before a slow departure guard permits activation", async () => {
const { session } = setup(); await session.initialize("/home");
const active = session.getSnapshot().activeId;
let finish!: (saved: boolean) => void;
session.registerDeparture(() => new Promise((resolve) => { finish = resolve; }));
const creation = session.create();
expect(session.getSnapshot().tabs).toHaveLength(3);
expect(session.getSnapshot().activeId).toBe(active);
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
finish(true);
await creation;
expect(session.getSnapshot().activeId).not.toBe(active);
session.dispose();
});
it("creates, activates, and persists a selected destination", async () => {
const { session, transport, navigate } = setup(); await session.initialize("/home");
await session.create("/routines?routine=routine-1");
const created = session.getSnapshot().tabs.find((tab) => tab.id === session.getSnapshot().activeId);
expect(created?.href).toBe("/routines?routine=routine-1");
expect(navigate).toHaveBeenLastCalledWith("/routines?routine=routine-1");
await session.retry();
expect(transport.patch).toHaveBeenCalledWith(
expect.objectContaining({ id: created?.id }),
{ href: "/routines?routine=routine-1" },
);
session.dispose();
});
it("removes a closed tab immediately and ignores stale refetches until confirmation", async () => {
const { session, transport, rows, navigate } = setup(); await session.initialize("/home");
const id = session.getSnapshot().activeId!;
let finish!: () => void;
vi.mocked(transport.close).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
await session.close(id);
expect(session.getSnapshot().tabs).toHaveLength(1);
expect(session.getSnapshot().activeId).not.toBe(id);
expect(navigate).toHaveBeenCalledWith("/home");
session.receive(rows());
expect(session.getSnapshot().tabs).toHaveLength(1);
await session.close(session.getSnapshot().activeId!);
expect(transport.close).toHaveBeenCalledTimes(1);
finish(); await session.retry(); session.dispose();
});
it("restores a failed close without discarding local state or stealing navigation", async () => {
const { session, transport, navigate } = setup(); await session.initialize("/home");
const id = session.getSnapshot().activeId!;
session.setLocalState(`${id}:view`, "unsaved-filters");
let fail!: (error: Error) => void;
vi.mocked(transport.close).mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject; }));
await session.close(id);
const active = session.getSnapshot().activeId;
fail(new AppTabRequestError("last_tab"));
await vi.waitFor(() => expect(session.getSnapshot().error).toBe("last_tab"));
expect(session.getSnapshot().tabs).toHaveLength(2);
expect(session.getSnapshot().activeId).toBe(active);
expect(session.getLocalState(`${id}:view`)).toBe("unsaved-filters");
expect(navigate).toHaveBeenCalledTimes(1); session.dispose();
});
it("retries an uncertain creation using the same ID without losing its destination", async () => {
const { session, transport } = setup(); await session.initialize("/home");
vi.mocked(transport.create).mockRejectedValueOnce(new Error("Offline"));
await session.create();
const id = session.getSnapshot().activeId!;
await vi.waitFor(() => expect(session.getSnapshot().error).toBe("database"));
session.observe("/home", null); session.observe("/all", null);
await session.retry();
expect(transport.create).toHaveBeenNthCalledWith(1, false, id);
expect(transport.create).toHaveBeenNthCalledWith(2, false, id);
expect(session.getSnapshot().tabs.find((tab) => tab.id === id)?.href).toBe("/all");
session.dispose();
});
it("moves optimistically and rolls back a failed reorder", async () => {
const { session, transport, rows } = setup(); await session.initialize("/home");
const [a, b] = rows();
let fail!: (error: Error) => void;
vi.mocked(transport.move).mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject; }));
await session.move(b.id, a.id);
expect(session.getSnapshot().tabs.map((tab) => tab.id)).toEqual([b.id, a.id]);
session.receive(rows());
expect(session.getSnapshot().tabs.map((tab) => tab.id)).toEqual([b.id, a.id]);
fail(new Error("Offline"));
await vi.waitFor(() => expect(session.getSnapshot().error).toBe("database"));
expect(session.getSnapshot().tabs.map((tab) => tab.id)).toEqual([a.id, b.id]); session.dispose();
});
it("keeps a slow pin request from blocking creation or optimistic closure", async () => {
const { session, transport, rows } = setup(); await session.initialize("/home");
const original = rows()[0];
let finish!: (tab: AppTab) => void;
vi.mocked(transport.patch).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
const pin = session.update(original.id, { pinned: true });
await session.create();
const created = session.getSnapshot().activeId!;
await session.close(created);
expect(session.getSnapshot().tabs).toHaveLength(2);
finish({ ...original, pinned: true, revision: 2 });
await pin; await session.retry();
expect(transport.close).toHaveBeenCalledWith(expect.objectContaining({ id: created, revision: 1 }));
expect(session.getSnapshot().tabs).toHaveLength(2); session.dispose();
});
it("restores canonical order after two queued reorders both fail", async () => {
const { session, transport, rows } = setup(); await session.initialize("/home");
const [a, b] = rows();
let fail!: (error: Error) => void;
vi.mocked(transport.move).mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject; })).mockRejectedValueOnce(new Error("Offline"));
await session.move(b.id, a.id);
await session.move(a.id, b.id);
fail(new Error("Offline")); await session.retry();
expect(session.getSnapshot().tabs.map((tab) => tab.id)).toEqual([a.id, b.id]); session.dispose();
});
it("can retry failed initial creation even when the empty list has not changed", async () => {
const { session, transport } = setup([]);
vi.mocked(transport.create).mockRejectedValueOnce(new Error("Offline"));
await session.initialize("/home");
expect(session.getSnapshot().activeId).toBeNull();
await session.retry();
expect(session.getSnapshot().tabs).toHaveLength(1);
expect(session.getSnapshot().activeId).not.toBeNull(); session.dispose();
});
it("synchronizes two clients without changing the other client's active route", async () => {
const first = setup();
const second = new AppTabsSession("owner", first.transport);
second.receive(first.rows()); const navigate = vi.fn(); second.navigate = navigate;
await first.session.initialize("/home"); await second.initialize("/home");
first.session.observe("/routines?routine=a", null); await first.session.retry();
second.receive(first.rows());
expect(second.getActiveHref()).toBe("/home");
expect(navigate).not.toHaveBeenCalled();
const other = first.rows().find((tab) => tab.id !== first.session.getSnapshot().activeId)!;
await first.session.activate(other.id);
expect(second.getSnapshot().activeId).not.toBe(first.session.getSnapshot().activeId);
first.session.dispose(); second.dispose();
});
it("opens the synchronized destination when a fresh window has no local selection", async () => {
const a = { ...createHomeTab("owner"), href: "/routines?routine=a" };
const { session, navigate, transport } = setup([a]);
await session.initialize("/home");
expect(navigate).toHaveBeenCalledWith(a.href);
expect(transport.patch).not.toHaveBeenCalled(); session.dispose();
});
it("leaves a notification URL intact while persisting only repeatable selection", async () => {
const { session, navigate } = setup();
await session.initialize("/all?issue=notification-issue&view=a");
expect(navigate).not.toHaveBeenCalled();
expect(session.getActiveHref()).toBe("/all?view=a"); session.dispose();
});
it("prioritizes an explicit deep link over window restoration", async () => {
const { session, rows, navigate } = setup();
await session.initialize("/routines?routine=a", { id: rows()[1].id, href: "/all" });
expect(navigate).not.toHaveBeenCalled();
expect(session.getSnapshot().tabs.find((row) => row.id === session.getSnapshot().activeId)?.href).toBe("/routines?routine=a");
session.dispose();
});
it("restores on reload without overwriting another tab's location", async () => {
// The load-time race: a reload lands on the URL the previous session left
// open (an active tab, not the first row); the first row must keep its own
// href instead of being overwritten with that URL.
const first = { ...createHomeTab("owner"), href: "/home" };
const second = { ...createHomeTab("owner", undefined, 1), href: "/all?view=x" };
const { session, transport, navigate, rows } = setup([first, second]);
await session.initialize("/all?view=x", { id: second.id, href: "/all?view=x" });
expect(session.getSnapshot().activeId).toBe(second.id);
expect(transport.patch).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(rows().find((row) => row.id === first.id)?.href).toBe("/home");
session.dispose();
});
it("claims a fresh row for a load no row displays, keeping the first row intact", async () => {
// A reconnect or a hard navigation lands on an address the previous
// document never observed (the server died mid-navigation, the memory was
// lost): that load must claim a row of its own instead of grinding the
// first pinned one under the load URL.
const home = { ...createHomeTab("owner"), pinned: true, href: "/home" };
const board = { ...createHomeTab("owner", undefined, 1), href: "/all?view=x" };
const { session, navigate } = setup([home, board]);
await session.initialize("/pull-requests", { id: board.id, href: "/all?view=x" });
const snapshot = session.getSnapshot();
expect(snapshot.tabs).toHaveLength(3);
expect(snapshot.activeId).not.toBe(home.id);
expect(snapshot.tabs.find((row) => row.id === home.id)?.href).toBe("/home");
expect(snapshot.tabs.find((row) => row.id === board.id)?.href).toBe("/all?view=x");
expect(snapshot.tabs.find((row) => row.id === snapshot.activeId)?.href).toBe("/pull-requests");
expect(navigate).not.toHaveBeenCalled();
session.dispose();
});
it("restores a reload of a page whose selection lives outside the address", async () => {
// /pull-requests keeps the open PR out of the URL and publishes
// `/pull-requests?pr=` instead, so a reload lands on the bare path. That
// load restores the remembered tab — it must not be treated as a deep
// link that claims the first row and grinds its location under it.
const admin = { ...createHomeTab("owner"), href: "/admin" };
const pr = { ...createHomeTab("owner", undefined, 1), href: "/pull-requests?pr=x" };
const { session, transport, navigate, rows } = setup([admin, pr]);
await session.initialize("/pull-requests", { id: pr.id, href: "/pull-requests?pr=x" });
expect(session.getSnapshot().activeId).toBe(pr.id);
expect(navigate).toHaveBeenCalledWith("/pull-requests?pr=x");
expect(rows().find((row) => row.id === admin.id)?.href).toBe("/admin");
expect(transport.patch).not.toHaveBeenCalled();
session.dispose();
});
it("keeps a URL that brings its own selection from being swallowed by restoration", async () => {
// The address is the freshest intent, but a URL no row displays claims its
// OWN row instead of grinding the first one under the load URL: the admin
// row keeps /admin, the remembered PR row keeps its own selection.
const admin = { ...createHomeTab("owner"), href: "/admin" };
const pr = { ...createHomeTab("owner", undefined, 1), href: "/pull-requests?pr=x" };
const { session } = setup([admin, pr]);
await session.initialize("/pull-requests?pr=y", { id: pr.id, href: "/pull-requests?pr=x" });
const snapshot = session.getSnapshot();
expect(snapshot.tabs).toHaveLength(3);
expect(snapshot.tabs.find((row) => row.id === admin.id)?.href).toBe("/admin");
expect(snapshot.tabs.find((row) => row.id === pr.id)?.href).toBe("/pull-requests?pr=x");
expect(snapshot.tabs.find((row) => row.id === snapshot.activeId)?.href).toBe("/pull-requests?pr=y");
session.dispose();
});
it("restores a reload whose URL carries an anchor the memory lacks", async () => {
// In-page anchors do not re-arm route sync, so the remembered destination
// can lack the hash the reload lands on. That is not a deep link: the
// remembered tab comes back, the first row keeps its own href, and the
// anchor survives in the restored destination.
const admin = { ...createHomeTab("owner"), href: "/admin" };
const wiki = { ...createHomeTab("owner", undefined, 1), href: "/projects/p1/pages/pg1?entry=e1" };
const { session, transport, navigate, rows } = setup([admin, wiki]);
await session.initialize("/projects/p1/pages/pg1?entry=e1#usage", { id: wiki.id, href: wiki.href });
expect(session.getSnapshot().activeId).toBe(wiki.id);
expect(navigate).not.toHaveBeenCalled();
expect(rows().find((row) => row.id === admin.id)?.href).toBe("/admin");
// The fresher address (with the anchor) becomes the tab's destination.
await session.retry();
expect(transport.patch).toHaveBeenLastCalledWith(
expect.objectContaining({ id: wiki.id }),
{ href: "/projects/p1/pages/pg1?entry=e1#usage" },
);
session.dispose();
});
it("restores the matching tab when the remembered row was closed elsewhere", async () => {
// The memory names a tab another window has since closed: the row that
// still displays the remembered page stands in for it, instead of letting
// the reload grind the first row under the load URL.
const admin = { ...createHomeTab("owner"), href: "/admin" };
const board = { ...createHomeTab("owner", undefined, 1), href: "/all?view=x" };
const { session, transport, rows } = setup([admin, board]);
await session.initialize("/all?view=x", { id: "closed-elsewhere", href: "/all?view=x" });
expect(session.getSnapshot().activeId).toBe(board.id);
expect(transport.patch).not.toHaveBeenCalled();
expect(rows().find((row) => row.id === admin.id)?.href).toBe("/admin");
session.dispose();
});
describe("a reload whose address carries short codes where the row holds full ids", () => {
// Navigation writes short codes into the address while a stored row, and
// the window's memory, may still hold full ids (MIN-875). Both name the
// same destination: comparing them raw read the reload as an unmatched
// deep link and claimed a duplicate row on every reload (MIN-887).
const project = "3f2a9c81-1111-4222-8333-444455556666";
const objective = "ab12cd34-1111-4222-8333-444455556666";
const stored = `/projects/${project}?objective=${objective}`;
const address = "/projects/min-3f2a9c81?objective=ab12cd34";
it("restores the remembered tab", async () => {
const home = { ...createHomeTab("owner"), href: "/home" };
const board = { ...createHomeTab("owner", undefined, 1), href: stored };
const { session, transport, navigate, rows } = setup([home, board]);
try {
await session.initialize(address, { id: board.id, href: stored });
expect(session.getSnapshot().activeId).toBe(board.id);
expect(session.getSnapshot().tabs).toHaveLength(2);
expect(navigate).not.toHaveBeenCalled();
expect(transport.create).not.toHaveBeenCalled();
expect(rows().find((row) => row.id === home.id)?.href).toBe("/home");
} finally { session.dispose(); }
});
it("restores the row that displays it when the remembered row was closed elsewhere", async () => {
const home = { ...createHomeTab("owner"), href: "/home" };
const board = { ...createHomeTab("owner", undefined, 1), href: stored };
const { session, transport } = setup([home, board]);
try {
await session.initialize(address, { id: "closed-elsewhere", href: stored });
expect(session.getSnapshot().activeId).toBe(board.id);
expect(transport.create).not.toHaveBeenCalled();
} finally { session.dispose(); }
});
it("finds the row without a window memory", async () => {
const home = { ...createHomeTab("owner"), href: "/home" };
const board = { ...createHomeTab("owner", undefined, 1), href: stored };
const { session, transport } = setup([home, board]);
try {
await session.initialize(address);
expect(session.getSnapshot().activeId).toBe(board.id);
expect(session.getSnapshot().tabs).toHaveLength(2);
expect(transport.create).not.toHaveBeenCalled();
} finally { session.dispose(); }
});
it("still claims a row for another record of the same page", async () => {
const board = { ...createHomeTab("owner"), href: stored };
const { session } = setup([board]);
try {
await session.initialize("/projects/min-3f2a9c81?objective=99999999", { id: board.id, href: stored });
expect(session.getSnapshot().tabs).toHaveLength(2);
expect(session.getSnapshot().activeId).not.toBe(board.id);
} finally { session.dispose(); }
});
});
it("restores every surface whose selection is kept out of the address", async () => {
// The same prefix rule must hold wherever a page publishes the href that
// reconstructs it while cleaning its address: the boards consume ?view=,
// the objectives page drops ?open=, feedback drops ?post=, wiki pages
// carry ?entry= and an anchor. (Numo conversations have no URL at all
// any more — the FAB is not a tab surface.)
const cases: [address: string, remembered: string][] = [
["/all", "/all?view=v1"],
["/all", "/all?view=cycle"],
["/projects/p1", "/projects/p1?view=v2"],
["/projects/p1/objectives", "/projects/p1/objectives?open=o1"],
["/projects/p1/feedback", "/projects/p1/feedback?post=p1"],
["/projects/p1/pages/pg1", "/projects/p1/pages/pg1?entry=e1#a"],
];
for (const [address, remembered] of cases) {
const admin = { ...createHomeTab("owner"), href: "/admin" };
const surface = { ...createHomeTab("owner", undefined, 1), href: remembered };
const { session, transport, navigate, rows } = setup([admin, surface]);
await session.initialize(address, { id: surface.id, href: remembered });
expect(session.getSnapshot().activeId, address).toBe(surface.id);
expect(navigate, address).toHaveBeenCalledWith(remembered);
expect(rows().find((row) => row.id === admin.id)?.href, address).toBe("/admin");
expect(transport.patch, address).not.toHaveBeenCalled();
session.dispose();
}
});
it("restores local activation without changing a different window", async () => {
const { session, rows, navigate } = setup();
await session.initialize("/home", { id: rows()[1].id, href: "/all?view=a" });
expect(session.getSnapshot().activeId).toBe(rows()[1].id);
expect(navigate).toHaveBeenCalledWith("/all?view=a"); session.dispose();
});
it("saves editors and outgoing location before switching, including pinned tabs", async () => {
const { session, transport, rows, navigate } = setup();
await session.initialize("/home");
const first = session.getSnapshot().activeId!;
await session.update(first, { pinned: true });
const save = vi.fn(async () => true); session.registerDeparture(save);
session.observe("/all?view=a", null);
const second = rows().find((row) => row.id !== first)!;
await session.activate(second.id);
expect(save).toHaveBeenCalled();
expect(transport.patch).toHaveBeenLastCalledWith(expect.objectContaining({ id: first }), { href: "/all?view=a" });
expect(navigate).toHaveBeenLastCalledWith(second.href);
expect(rows().find((row) => row.id === first)?.pinned).toBe(true); session.dispose();
});
it("retains the active editor on a failed departure", async () => {
const { session, rows, navigate } = setup(); await session.initialize("/home");
const first = session.getSnapshot().activeId;
session.registerDeparture(async () => false);
await session.activate(rows().find((row) => row.id !== first)!.id);
expect(session.getSnapshot().activeId).toBe(first);
expect(session.getSnapshot().error).toBe("save_failed");
expect(navigate).not.toHaveBeenCalled(); session.dispose();
});
it("does not echo a remote destination change or steal navigation", async () => {
const { session, rows, transport, navigate, replace } = setup(); await session.initialize("/home");
replace(rows().map((row) => row.id === session.getSnapshot().activeId ? { ...row, href: "/all", revision: 2 } : row));
session.observe("/home", null);
await session.retry();
expect(transport.patch).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); session.dispose();
});
it("keeps remotely closed content recoverable until every editor saves", async () => {
const { session, rows, replace, navigate, transport } = setup(); await session.initialize("/home");
const id = session.getSnapshot().activeId; let canSave = false;
session.registerDeparture(async () => canSave);
replace(rows().filter((row) => row.id !== id));
await session.retry();
expect(session.getSnapshot().recovering).toBe(true); expect(navigate).not.toHaveBeenCalled();
session.observe("/all", null);
canSave = true; await session.retry();
expect(session.getSnapshot().recovering).toBe(false);
expect(session.getSnapshot().activeId).not.toBe(id);
expect(transport.patch).not.toHaveBeenCalled(); session.dispose();
});
it("ignores the old publication while a same-path selection is being restored", async () => {
const a = { ...createHomeTab("owner", undefined, 0), href: "/all?view=a" };
const b = { ...createHomeTab("owner", undefined, 1), href: "/all?view=b" };
const { session, transport } = setup([a, b]); await session.initialize(a.href);
await session.activate(b.id);
session.observe(b.href, a.href);
await session.retry(); expect(transport.patch).not.toHaveBeenCalled();
session.observe(b.href, b.href);
session.observe("/all", "/all?view=c"); await session.retry();
expect(transport.patch).toHaveBeenLastCalledWith(expect.objectContaining({ id: b.id }), { href: "/all?view=c" }); session.dispose();
});
it("serializes name and pin updates using the latest revision", async () => {
const { session, transport } = setup(); await session.initialize("/home");
const id = session.getSnapshot().activeId!;
await Promise.all([session.update(id, { custom_name: "Work" }), session.update(id, { pinned: true })]);
expect(transport.patch).toHaveBeenNthCalledWith(2, expect.objectContaining({ revision: 2 }), { pinned: true }); session.dispose();
});
it("does not create a tab on list/network failure or run queued work after disposal", async () => {
const { session, transport } = setup(); session.dispose(); await session.create();
expect(transport.create).not.toHaveBeenCalled();
});
it("waits for every mounted editor before an active close", async () => {
const { session, transport } = setup(); await session.initialize("/home");
let finish!: (saved: boolean) => void;
const saved = new Promise<boolean>((resolve) => { finish = resolve; });
const preview = vi.fn(async () => true);
session.registerDeparture(() => saved); session.registerDeparture(preview);
const close = session.close(session.getSnapshot().activeId!);
await Promise.resolve(); expect(transport.close).not.toHaveBeenCalled();
finish(true); await close;
expect(preview).toHaveBeenCalledOnce(); expect(transport.close).toHaveBeenCalledOnce(); session.dispose();
});
it("does not resurrect a remotely closed editor after manually leaving recovery", async () => {
const { session, rows, replace } = setup(); await session.initialize("/home");
const old = session.getSnapshot().activeId!;
let saved = false; session.registerDeparture(async () => saved);
replace(rows().filter((row) => row.id !== old)); await session.retry();
saved = true; await session.activate(rows()[0].id);
expect(session.getSnapshot().tabs.some((tab) => tab.id === old)).toBe(false); session.dispose();
});
it("keeps browser history navigation in the active tab, even after another tab closes", async () => {
const { session, rows, transport } = setup(); await session.initialize("/home");
const first = session.getSnapshot().activeId!;
session.observe("/all?view=a", "/all?view=a");
const second = rows().find((tab) => tab.id !== first)!;
await session.activate(second.id); session.observe("/home", null);
await session.close(first);
session.observe("/all?view=a", "/all?view=a"); await session.retry();
expect(session.getSnapshot().activeId).toBe(second.id);
expect(transport.patch).toHaveBeenLastCalledWith(expect.objectContaining({ id: second.id }), { href: "/all?view=a" });
expect(session.getSnapshot().tabs).toHaveLength(1); session.dispose();
});
it("does not rewrite a location the server row already holds", async () => {
const all = { ...createHomeTab("owner"), href: "/all?view=x" };
const other = { ...createHomeTab("owner", undefined, 1), href: "/routines" };
const { session, transport, navigate, rows } = setup([all, other]);
try {
await session.initialize("/home");
expect(navigate).toHaveBeenCalledWith(all.href);
// Restoration acknowledgement, then the board consumes `?view=` and
// republishes it: the layout pass briefly sees the bare URL.
session.observe(all.href, null);
session.observe("/all", null);
session.observe("/all", all.href);
await session.activate(other.id);
session.observe(other.href, null);
session.onPageHide(); await session.retry();
expect(transport.patch).not.toHaveBeenCalled();
expect(rows().map((row) => row.revision)).toEqual([1, 1]);
expect(session.getSnapshot().tabs.map((tab) => tab.href)).toEqual([all.href, other.href]);
} finally { session.dispose(); }
});
it("still writes a location that only matches a row's superseded href", async () => {
const all = { ...createHomeTab("owner"), href: "/all?view=x" };
const { session, transport, replace, rows } = setup([all]);
try {
await session.initialize("/home");
session.observe(all.href, null); await session.retry();
expect(transport.patch).not.toHaveBeenCalled();
replace([{ ...rows()[0], href: "/projects/p", revision: 2 }]);
// A stale list must not restore the superseded href as the server value.
session.receive([all]);
session.observe("/all", null);
session.observe("/all", all.href); await session.retry();
expect(transport.patch).toHaveBeenCalledOnce();
expect(transport.patch).toHaveBeenLastCalledWith(expect.objectContaining({ id: all.id, revision: 2 }), { href: all.href });
expect(rows()[0]).toMatchObject({ href: all.href, revision: 3 });
} finally { session.dispose(); }
});
it("activates without busy renders when no departure guard can wait", async () => {
const all = { ...createHomeTab("owner", undefined, 1), href: "/all" };
const { session } = setup([createHomeTab("owner"), all]);
try {
await session.initialize("/home");
const listener = vi.fn();
const stop = session.subscribe(listener);
await session.activate(all.id);
expect(session.getSnapshot()).toMatchObject({ activeId: all.id, busy: false });
expect(listener).toHaveBeenCalledOnce();
// A registered guard can wait: the strip still drops input meanwhile.
let finish!: (allowed: boolean) => void;
const unregister = session.registerDeparture(() => new Promise((resolve) => { finish = resolve; }));
const activation = session.activate(session.getSnapshot().tabs[0].id);
await vi.waitFor(() => expect(session.getSnapshot().busy).toBe(true));
finish(true); await activation;
expect(session.getSnapshot().busy).toBe(false);
unregister(); stop();
} finally { session.dispose(); }
});
it("acknowledges an activated destination without another render", async () => {
const all = { ...createHomeTab("owner", undefined, 1), href: "/all?view=a" };
const { session } = setup([createHomeTab("owner"), all]);
try {
await session.initialize("/home");
await session.activate(all.id);
const before = session.getSnapshot();
const listener = vi.fn();
const stop = session.subscribe(listener);
session.observe(all.href, null);
expect(listener).not.toHaveBeenCalled();
expect(session.getSnapshot()).toBe(before);
// A refined selection still publishes its new location.
session.observe("/all?view=b", null);
expect(listener).toHaveBeenCalled();
expect(session.getActiveHref()).toBe("/all?view=b");
expect(session.getSnapshot().tabs.find((tab) => tab.id === all.id)?.href).toBe("/all?view=b");
stop();
} finally { session.dispose(); }
});
it("keeps a conflicting destination pending until retry uses the canonical revision", async () => {
const { session, transport, rows } = setup(); await session.initialize("/home");
const canonical = { ...rows()[0], revision: 2 };
vi.mocked(transport.patch).mockRejectedValueOnce(new AppTabRequestError("conflict", canonical));
session.observe("/all", null); await session.retry();
expect(session.getSnapshot().error).toBe("conflict");
vi.mocked(transport.patch).mockResolvedValueOnce({ ...canonical, href: "/all", revision: 3 });
await session.retry();
expect(transport.patch).toHaveBeenLastCalledWith(expect.objectContaining({ revision: 2 }), { href: "/all" });
expect(session.getSnapshot().error).toBeNull(); session.dispose();
});
});