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((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(); }); });