import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { type DID, Identity, legacySpaceDid } from "@commonfabric/identity"; import { createLegacyTestSpace, createTestSpace, env, waitForCondition, } from "@commonfabric/integration"; import { ShellIntegration } from "@commonfabric/integration/shell-utils"; import { experimentalOptionsFromEnv, Runtime, withServerExecutionDefault, } from "@commonfabric/runner"; import { StorageManager } from "@commonfabric/runner/storage/cache"; import "../src/globals.ts"; import { clickPierce } from "./shadow-dom.ts"; const { API_URL, FRONTEND_URL } = env; const ASYNC_FAILURE_MESSAGE = "The selected piece failed while it was starting"; const ASYNC_FAILURE_SOURCE = ` import { computed, pattern, UI } from "commonfabric"; export default pattern(() => { const failed = computed(() => { throw new Error("${ASYNC_FAILURE_MESSAGE}"); }); return { [UI]:
{failed}
}; }); `; /** * Whether a space answers to `space`, asked over the API as `identity`. A * space exists when its DID has an access-control document or a space cell; * asking opens nothing and writes nothing. */ async function spaceExists(identity: Identity, space: DID): Promise { const runtime = new Runtime({ apiUrl: new URL(API_URL), experimental: withServerExecutionDefault( experimentalOptionsFromEnv(Deno.env.get), ), storageManager: StorageManager.open({ as: identity, memoryHost: new URL(API_URL), }), }); try { return await runtime.spaceExists(space); } finally { await runtime.dispose(); } } /** Waits until the shell says no space answers to the name `name`. */ async function waitForNoSpaceAlert( shell: ShellIntegration, name: string, ): Promise { await waitForCondition( shell.page(), (probe, name) => probe.collect(".load-error").some((element) => { const text = probe.deepText(element).replace(/\s+/g, " ").trim(); return text.includes(`No space answers to the name "${name}"`) && text.includes(`Create a new space called "${name}"`) && !text.includes("We could not load this space"); }), { args: [name] }, ); } /** * Waits until the shell has opened a space by DID and loaded its root * pattern, and answers that DID. */ async function waitForOpenedSpace(shell: ShellIntegration): Promise { const opened = await waitForCondition(shell.page(), () => { const view = globalThis.app?.state().view; const appView = document.querySelector("x-root-view")?.shadowRoot ?.querySelector("x-app-view") as | { space?: DID; _spaceRootPattern?: { value?: unknown } } | null; if ( !view || !("spaceDid" in view) || !appView?.space || appView.space !== view.spaceDid || !appView._spaceRootPattern?.value ) { return false; } return view.spaceDid; }); if (opened === undefined) throw new Error("The shell opened no space"); return opened; } const ALLOWED_CONSOLE_ERRORS = [ "[AppView] Failed to load space root pattern:", "[AppView] Failed to load selected piece:", ASYNC_FAILURE_MESSAGE, ]; describe("shell load errors", () => { const shell = new ShellIntegration({ allowedConsoleErrors: ALLOWED_CONSOLE_ERRORS, }); shell.bindLifecycle(); it("shows an informative alert when a space cannot be loaded", async () => { const page = shell.page(); const identity = await Identity.generate({ implementation: "noble" }); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceDid: identity.did() }, identity, }); await page.evaluate(async () => { const root = document.querySelector("x-root-view"); const appView = root?.shadowRoot?.querySelector("x-app-view") as | { rt?: { getSpaceRootPattern: (...args: unknown[]) => Promise; }; _spaceRootPattern?: { run(): void; taskComplete: Promise; }; } | null; const runtime = appView?.rt; const task = appView?._spaceRootPattern; if (!runtime || !task) throw new Error("App view was not ready"); const getSpaceRootPattern = runtime.getSpaceRootPattern; runtime.getSpaceRootPattern = () => Promise.reject(new Error("Space storage is unavailable")); try { task.run(); await task.taskComplete; } catch { // The task's error state is the condition this test renders. } finally { runtime.getSpaceRootPattern = getSpaceRootPattern; } }); await waitForCondition( page, (probe) => probe.collect(".load-error").some((element) => { const text = probe.deepText(element).replace(/\s+/g, " ").trim(); return text.includes("We could not load this space") && text.includes("Space storage is unavailable"); }), ); await waitForCondition( page, (probe) => probe.collect('[role="alert"]').length === 1, ); }); it("shows an informative alert when a piece cannot be loaded", async () => { const page = shell.page(); const identity = await Identity.generate({ implementation: "noble" }); const spaceDid = await createTestSpace(identity); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceDid, pieceId: "fid1:missing:piece", }, identity, }); await waitForCondition( page, (probe) => probe.collect(".load-error").some((element) => { const text = probe.deepText(element).replace(/\s+/g, " ").trim(); return text.includes("We could not load this piece") && text.includes("Error details"); }), ); await waitForCondition( page, (probe) => probe.collect('[role="alert"]').length === 1, ); await waitForCondition( page, (probe) => probe.collect(".load-error-details code").some((element) => probe.deepText(element).trim().length > 0 ), ); }); it("opens the space's menu over the surface a piece failed to load into", async () => { const page = shell.page(); const identity = await Identity.generate({ implementation: "noble" }); const spaceDid = await createTestSpace(identity); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceDid, pieceId: "fid1:missing:piece", }, identity, }); await waitForCondition( page, (probe) => probe.collect(".load-error").some((element) => probe.deepText(element).includes("We could not load this piece") ), ); // The click lands at the foot of the viewport, four pixels above the // bottom edge, which is also the margin the menu keeps from that edge. So // a menu placed from its own measured height comes to rest with its bottom // exactly on that margin, however tall the space heading makes it, and its // left edge still at the click. const space = await page.evaluate(() => { const rootView = document.querySelector("x-root-view"); const appView = rootView?.shadowRoot?.querySelector("x-app-view") as | (Element & { space?: string }) | null; const surface = appView?.shadowRoot?.querySelector("x-body-view") ?.shadowRoot?.querySelector(".load-error"); if (!appView?.space) throw new Error("the app view names no space"); if (!surface) throw new Error("no load-error surface to right-click"); surface.dispatchEvent( new MouseEvent("contextmenu", { bubbles: true, composed: true, cancelable: true, clientX: 40, clientY: globalThis.innerHeight - 4, }), ); return appView.space; }); const menu = await waitForCondition(page, (probe, heading: string) => { const root = document.querySelector("cf-piece-menu")?.shadowRoot; const element = root?.querySelector(".menu"); if (!element || !probe.isRendered(element)) return false; const text = probe.deepText(element).replace(/\s+/g, " ").trim(); if (!text.includes(heading)) return false; const entries: Record = {}; for (const button of root!.querySelectorAll("button[test-id]")) { entries[button.getAttribute("test-id")!] = (button as HTMLButtonElement).disabled; } const box = element.getBoundingClientRect(); return { piece: text.startsWith("Piece unavailable"), entries, left: Math.round(box.left), gap: Math.round(globalThis.innerHeight - box.bottom), }; }, { args: [`Space ${space}`] }); expect(menu).toEqual({ piece: true, entries: { "piece-menu-source": true, "piece-menu-origin": true, "piece-menu-data": true, "piece-menu-actions": true, "piece-menu-clone-fresh": true, "piece-menu-clone-copy-data": true, "piece-menu-space-access": false, }, left: 40, gap: 4, }); // The rights the panel shows were read through the space and the runtime // the menu was handed, there being no piece to read them through. The // panel names that space in its subject line, and lists the one entry the // space was created with: its creator, who is the identity reading it. expect(space).toBe(spaceDid); await clickPierce(page, '[test-id="piece-menu-space-access"]'); await waitForCondition( page, (probe, subject: string, owner: string) => probe.collect('[test-id="piece-panel-access"]').some((panel) => { const text = probe.deepText(panel).replace(/\s+/g, " "); return text.includes(`Space access rights ${subject}`) && text.includes(`${owner} (you)`) && text.includes("OWNER") && !text.includes("Anyone (*)"); }), { args: [space, identity.did()] }, ); }); it("tells the reader no space answers to a name nobody has used, and writes nothing for it", async () => { const identity = await Identity.generate({ implementation: "noble" }); const name = `no-space-${crypto.randomUUID()}`; const derived = await legacySpaceDid(name); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceName: name }, identity, }); await waitForNoSpaceAlert(shell, name); expect(await spaceExists(identity, derived)).toBe(false); }); it("creates a space with a new DID from the no-space alert, and opens it", async () => { const identity = await Identity.generate({ implementation: "noble" }); const name = `create-from-alert-${crypto.randomUUID()}`; const derived = await legacySpaceDid(name); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceName: name }, identity, }); await waitForNoSpaceAlert(shell, name); await clickPierce(shell.page(), ".load-error cf-button"); const created = await waitForOpenedSpace(shell); expect(created).not.toBe(derived); expect(created).not.toBe(identity.did()); expect(await spaceExists(identity, created)).toBe(true); // The typed name labels the new space and does not address it: the DID // it derives still has no space behind it. expect(await spaceExists(identity, derived)).toBe(false); await waitForCondition( shell.page(), (probe) => probe.collect(".load-error").length === 0, ); }); it("opens the existing legacy space a name resolves to", async () => { const identity = await Identity.generate({ implementation: "noble" }); const name = `legacy-${crypto.randomUUID()}`; const legacy = await createLegacyTestSpace(identity, name); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceName: name }, identity, }); const opened = await waitForCondition(shell.page(), () => { const appView = document.querySelector("x-root-view")?.shadowRoot ?.querySelector("x-app-view") as | { space?: string; _spaceRootPattern?: { value?: unknown } } | null; if (!appView?._spaceRootPattern?.value) return false; return appView.space ?? false; }); expect(opened).toBe(legacy); await waitForCondition( shell.page(), (probe) => probe.collect(".load-error").length === 0, ); }); it("shows an asynchronous runtime failure for the selected piece", async () => { const page = shell.page(); const identity = await Identity.generate({ implementation: "noble" }); await shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceDid: identity.did() }, identity, }); await waitForCondition(page, () => { const root = document.querySelector("x-root-view"); const appView = root?.shadowRoot?.querySelector("x-app-view") as | { rt?: { createPiece( space: string, source: string, options: { run: boolean }, ): Promise<{ id(): string }>; }; } | null; return !!appView?.rt; }); const pieceId = await page.evaluate(async (space, source) => { const root = document.querySelector("x-root-view"); const appView = root?.shadowRoot?.querySelector("x-app-view") as | { rt?: { createPiece( space: string, source: string, options: { run: boolean }, ): Promise<{ id(): string }>; }; } | null; if (!appView?.rt) throw new Error("Runtime was not ready"); const piece = await appView.rt.createPiece(space, source, { run: false }); return piece.id(); }, { args: [identity.did(), ASYNC_FAILURE_SOURCE] }); await page.evaluate(async (space, pieceId) => { await globalThis.app.setView({ spaceDid: space, pieceId }); }, { args: [identity.did(), pieceId] }); await waitForCondition( page, (probe, expectedMessage) => probe.collect(".runtime-error").some((element) => { const text = probe.deepText(element).replace(/\s+/g, " ").trim(); return text.includes("This piece encountered an error") && text.includes(expectedMessage); }), { args: [ASYNC_FAILURE_MESSAGE] }, ); }); describe("with a second reader", () => { const secondShell = new ShellIntegration({ allowedConsoleErrors: ALLOWED_CONSOLE_ERRORS, }); secondShell.bindLifecycle(); it("shows the no-space alert to each identity that opens a fresh name, and neither creates a space", async () => { const [first, second] = await Promise.all([ Identity.generate({ implementation: "noble" }), Identity.generate({ implementation: "noble" }), ]); const name = `two-readers-${crypto.randomUUID()}`; const derived = await legacySpaceDid(name); await Promise.all([ shell.goto({ frontendUrl: FRONTEND_URL, view: { spaceName: name }, identity: first, }), secondShell.goto({ frontendUrl: FRONTEND_URL, view: { spaceName: name }, identity: second, }), ]); await Promise.all([ waitForNoSpaceAlert(shell, name), waitForNoSpaceAlert(secondShell, name), ]); expect(await spaceExists(first, derived)).toBe(false); expect(await spaceExists(second, derived)).toBe(false); }); }); });