fix: isolate Workspace notifications from public entry

This commit is contained in:
2026-10-16 11:30:00 +09:00
parent d74660d1d1
commit a2816a053d
3 changed files with 219 additions and 4 deletions
+1 -2
View File
@@ -96,13 +96,12 @@
});
</script>
<WorkspaceAlerts />
{#if standalone}
<main class="standalone-main">
{@render children()}
</main>
{:else}
<WorkspaceAlerts />
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
<SidebarFrame
mode={sidebarMode}
@@ -4,6 +4,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet, tick } from "svelte";
import { logout } from "#lib/workspace/auth/api.ts";
import { clearWorkspaceAlerts, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { page } from '$app/state';
import { settle } from '#lib/workspace/navigation/deferred.ts';
import Layout from "./+layout.svelte";
@@ -22,7 +24,16 @@ const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse"
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
const delay = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
beforeEach(() => {
localStorage.clear();
clearWorkspaceAlerts();
vi.stubGlobal('fetch', vi.fn(async (input) => {
if (String(input) === '/api/auth/whoami') return Response.json({ actor: null });
throw new Error(`Unexpected request ${input}`);
}));
page.url = new URL('https://example.test/w/example');
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
@@ -31,6 +42,24 @@ afterEach(() => {
localStorage.clear();
});
test.each(['/', '/dashboard', '/login', '/register', '/account', '/hosting/settings'])(
'standalone %s never displays retained private Workspace notifications',
(path) => {
pushWorkspaceAlert('error', 'Private Workspace Orion: worker deletion failed');
page.url = new URL(path, 'https://example.test');
render(Layout, { props });
expect(screen.getByText('Page content')).toBeTruthy();
expect(screen.queryByText('Private Workspace Orion: worker deletion failed')).toBeNull();
expect(screen.queryByRole('alert')).toBeNull();
},
);
test('Workspace shell continues displaying Workspace action notifications', () => {
pushWorkspaceAlert('error', 'Worker deletion failed');
render(Layout, { props });
expect(screen.getByRole('alert').textContent).toContain('Worker deletion failed');
});
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
render(Layout, {
props: {
@@ -146,6 +146,191 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
},
);
}
await step(
"Workspace notifications do not cross logout into the public entry",
async (page) => {
page.setDefaultTimeout(5000);
await page.routeWebSocket(/\/protocol\/ws/, (socket) => {
socket.onMessage((message) => {
const frame = JSON.parse(String(message));
if (frame.message?.method !== "subscribe_events") return;
socket.send(JSON.stringify({
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: frame.message.params.request_id,
subscription_id: "fixture-private-alert",
selector: { topic: "workspace_workers" },
snapshot: {
topic: "workers",
data: {
workers: [{
worker_id: "private-worker",
runtime_id: "fixture-runtime",
resource_key: "W-1",
availability: "observed",
state: "idle",
has_running_internal_workers: false,
display_name: "Private Workspace Orion",
workdir_attachments: [],
}],
},
},
},
},
}));
});
});
await page.route(
"**/api/w/home-owner/runtimes/fixture-runtime/workers/private-worker/stop",
(route) =>
route.fulfill({
json: {
state: "accepted",
runtime_id: "fixture-runtime",
worker_id: "private-worker",
diagnostics: [],
},
}),
);
await page.goto(`${origin}/w/home-owner?fixture=workspace-one`);
if (width === 390) {
await page.getByRole("button", { name: "Show sidebar" })
.click();
}
const actions = page.getByRole("button", {
name: "Actions for Private Workspace Orion",
});
await actions.focus();
await actions.press("Enter");
await page.getByRole("menuitem", { name: "Stop", exact: true })
.click();
await page.getByText("Private Workspace Orion stopped", {
exact: true,
}).waitFor();
if (width === 390) {
const hideSidebar = page.getByRole("button", {
name: "Hide sidebar",
});
await hideSidebar.focus();
await hideSidebar.press("Enter");
}
const accountLink = page.getByRole("link", {
name: "Open Account",
exact: true,
});
await accountLink.focus();
await accountLink.press("Enter");
await page.getByRole("button", { name: "Log out", exact: true })
.waitFor();
const retainedCounts = [
await page.locator(".workspace-alert").count(),
];
await page.getByRole("button", { name: "Log out", exact: true })
.click();
await page.getByRole("button", { name: "Log in with passkey" })
.waitFor();
retainedCounts.push(
await page.locator(".workspace-alert").count(),
);
await page.getByRole("link", {
name: "Dashboard / Workspaces",
exact: true,
}).click();
await ready(page, "Log in");
retainedCounts.push(
await page.locator(".workspace-alert").count(),
);
await page.getByRole("link", { name: "Log in", exact: true })
.click();
await page.getByRole("button", { name: "Log in with passkey" })
.waitFor();
retainedCounts.push(
await page.locator(".workspace-alert").count(),
);
await page.getByRole("link", { name: "Yoi home", exact: true })
.click();
await ready(page, "Log in");
const folder =
`${root}/target/web-ux/entry-browser/private-alerts-${colorScheme}-${width}`;
await Deno.mkdir(folder, { recursive: true });
const bytes = await page.screenshot({
path: folder + "/viewport.png",
fullPage: true,
});
const source = await new Deno.Command("git", {
cwd: root,
args: ["rev-parse", "HEAD"],
stdout: "piped",
}).output();
const dirty = await new Deno.Command("git", {
cwd: root,
args: ["status", "--porcelain"],
stdout: "piped",
}).output();
const hash = Array.from(
new Uint8Array(
await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)),
),
)
.map((v) => v.toString(16).padStart(2, "0")).join("");
await Deno.writeTextFile(
folder + "/review-context.json",
JSON.stringify(
{
scenario:
"Workspace Stop notification → Account logout → dashboard/login → public home (SPA)",
boundary:
"synthetic HTTP and Worker subscription; production UI/router/store",
path: new URL(page.url()).pathname,
source: {
commitSha: new TextDecoder().decode(source.stdout).trim(),
dirty:
new TextDecoder().decode(dirty.stdout).trim() !== "",
},
screenshot: { path: "viewport.png", sha256: hash },
standaloneNotificationCounts: retainedCounts,
colorScheme,
viewport: { width, height: 900 },
visibleNotifications: await page.locator(".workspace-alert")
.allTextContents(),
accessibility: await page.locator("body").ariaSnapshot(),
},
null,
2,
),
);
assertEquals(retainedCounts, [0, 0, 0, 0]);
assertEquals(await page.locator(".workspace-alert").count(), 0);
let release!: () => void;
const recheck = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/api/auth/whoami", async (route) => {
await recheck;
await route.fulfill({
status: 503,
body: "synthetic unavailable session",
});
});
await page.evaluate(() =>
window.dispatchEvent(new Event("focus"))
);
await page.getByText("Loading session…").waitFor();
assertEquals(await page.locator(".workspace-alert").count(), 0);
release();
await page.getByText(/Login status is unknown/).waitFor();
assertEquals(await page.locator(".workspace-alert").count(), 0);
assertEquals(
await page.getByText("Private Workspace Orion stopped", {
exact: true,
}).count(),
0,
);
},
);
for (const mode of ["session-error", "session-loading"]) {
await step(
`public ${mode} is not anonymous or authenticated`,
@@ -452,7 +637,9 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
fullPage: true,
});
const hash = Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes))),
new Uint8Array(
await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)),
),
).map((v) => v.toString(16).padStart(2, "0")).join("");
const source = await new Deno.Command("git", {
cwd: root,