Revoke acted on a single click, yet it strands the screen until someone re-pairs it in person. Explain the consequence in the shared in-product Dialog first (DESIGN 21.4), with an obvious Cancel. Co-Authored-By: Claude Opus 5.5 <[email protected]>
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { DevicePanel } from "../src/browser/DevicePanel.js";
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
}
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { "content-type": "application/json" },
|
|
status,
|
|
});
|
|
}
|
|
|
|
function server() {
|
|
let revoked = false;
|
|
const revokes: string[] = [];
|
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith("/api/v1/devices")) {
|
|
return json({
|
|
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
|
|
});
|
|
}
|
|
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
|
|
revoked = true;
|
|
revokes.push(url);
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
return json({ code: "NOT_FOUND" }, 404);
|
|
}) as unknown as typeof globalThis.fetch;
|
|
return { fetch, revokes };
|
|
}
|
|
|
|
function query(testId: string): HTMLElement | null {
|
|
return container.querySelector(`[data-testid="${testId}"]`);
|
|
}
|
|
|
|
async function click(testId: string): Promise<void> {
|
|
await act(async () => {
|
|
query(testId)?.click();
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
|
|
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
|
*/
|
|
describe("display device revocation", () => {
|
|
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
|
|
const platform = server();
|
|
const confirm = vi.fn(() => true);
|
|
Object.assign(window, { confirm });
|
|
await act(async () => {
|
|
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
|
});
|
|
|
|
await click("device-revoke-device-1");
|
|
const dialog = container.querySelector('[role="dialog"]');
|
|
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
|
|
expect(dialog?.textContent).toContain("pairing code");
|
|
expect(platform.revokes).toEqual([]);
|
|
|
|
await click("device-revoke-cancel");
|
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
expect(platform.revokes).toEqual([]);
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("revokes only after the destructive action is confirmed", async () => {
|
|
const platform = server();
|
|
await act(async () => {
|
|
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
|
});
|
|
|
|
await click("device-revoke-device-1");
|
|
await click("device-revoke-confirm");
|
|
|
|
expect(platform.revokes).toHaveLength(1);
|
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
expect(query("device-device-1")?.textContent).toContain("(revoked)");
|
|
});
|
|
});
|