fix: isolate Workspace notifications from public entry
This commit is contained in:
@@ -96,13 +96,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WorkspaceAlerts />
|
|
||||||
|
|
||||||
{#if standalone}
|
{#if standalone}
|
||||||
<main class="standalone-main">
|
<main class="standalone-main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
|
<WorkspaceAlerts />
|
||||||
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
||||||
<SidebarFrame
|
<SidebarFrame
|
||||||
mode={sidebarMode}
|
mode={sidebarMode}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
|||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { createRawSnippet, tick } from "svelte";
|
import { createRawSnippet, tick } from "svelte";
|
||||||
import { logout } from "#lib/workspace/auth/api.ts";
|
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 { settle } from '#lib/workspace/navigation/deferred.ts';
|
||||||
import Layout from "./+layout.svelte";
|
import Layout from "./+layout.svelte";
|
||||||
|
|
||||||
@@ -22,7 +24,16 @@ const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse"
|
|||||||
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||||
const delay = () => vi.advanceTimersByTimeAsync(200);
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -31,6 +42,24 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
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 () => {
|
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
|
||||||
render(Layout, {
|
render(Layout, {
|
||||||
props: {
|
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"]) {
|
for (const mode of ["session-error", "session-loading"]) {
|
||||||
await step(
|
await step(
|
||||||
`public ${mode} is not anonymous or authenticated`,
|
`public ${mode} is not anonymous or authenticated`,
|
||||||
@@ -452,7 +637,9 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
|||||||
fullPage: true,
|
fullPage: true,
|
||||||
});
|
});
|
||||||
const hash = Array.from(
|
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("");
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
||||||
const source = await new Deno.Command("git", {
|
const source = await new Deno.Command("git", {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
|
|||||||
Reference in New Issue
Block a user