fix: restore authenticated home and account navigation
This commit is contained in:
@@ -22,7 +22,7 @@
|
|||||||
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
||||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
|
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
|
||||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
|
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
|
||||||
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts"
|
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
|
||||||
|
|||||||
@@ -10,7 +10,22 @@ function assertEquals(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
import {
|
||||||
|
authEntryUrl,
|
||||||
|
isAuthPage,
|
||||||
|
isPublicPage,
|
||||||
|
loginUrl,
|
||||||
|
safeReturnTo,
|
||||||
|
} from "./routes.ts";
|
||||||
|
|
||||||
|
Deno.test("account uses the authenticated app shell, not the standalone auth entry shell", () => {
|
||||||
|
assertEquals(String(isAuthPage("/account")), "false");
|
||||||
|
assertEquals(String(isPublicPage("/account")), "false");
|
||||||
|
for (const path of ["/login", "/register", "/activate", "/login/device"]) {
|
||||||
|
assertEquals(String(isAuthPage(path)), "true", path);
|
||||||
|
assertEquals(String(isPublicPage(path)), "true", path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("return destination preserves query and fragment only on known app screens", () => {
|
Deno.test("return destination preserves query and fragment only on known app screens", () => {
|
||||||
for (
|
for (
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ export function isPublicPage(path: string): boolean {
|
|||||||
.includes(path);
|
.includes(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 認証入口は独立したshellを使う。本人用/accountは通常のナビゲーションを残す。
|
||||||
export function isAuthPage(path: string): boolean {
|
export function isAuthPage(path: string): boolean {
|
||||||
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
return ["/login", "/register", "/activate", "/login/device"]
|
||||||
.includes(path);
|
.includes(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1344,7 +1344,7 @@ Deno.test("Root chrome owns global navigation and scoped override slots", async
|
|||||||
globalNavSections.includes("workspace.display_name") &&
|
globalNavSections.includes("workspace.display_name") &&
|
||||||
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
|
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
|
||||||
rootLayout.includes("observeDeferred(() => data.deferredCatalog") &&
|
rootLayout.includes("observeDeferred(() => data.deferredCatalog") &&
|
||||||
rootLayout.includes("workspaces={catalog.data?.accessibleWorkspaces ?? null}") &&
|
rootLayout.includes("workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}") &&
|
||||||
rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") &&
|
rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") &&
|
||||||
rootLayoutLoad.includes("deferredCatalog: settle(") &&
|
rootLayoutLoad.includes("deferredCatalog: settle(") &&
|
||||||
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
|
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
||||||
import { onMount, untrack } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { loadWhoami } from "#lib/workspace/auth/api.ts";
|
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||||
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||||
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
@@ -12,8 +12,9 @@
|
|||||||
const request = ++accessRequest;
|
const request = ++accessRequest;
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
try {
|
try {
|
||||||
const whoami = await loadWhoami();
|
// ページが確認したsessionを共有し、Sidebar追加でwhoamiの再取得を増やさない。
|
||||||
if (request !== accessRequest || !whoami.actor) return;
|
const session = browserSession.snapshot;
|
||||||
|
if (session.kind !== 'ready' || !session.value.actor) return;
|
||||||
const access = await loadHostingAccess();
|
const access = await loadHostingAccess();
|
||||||
if (request === accessRequest) isAdmin = access.is_admin;
|
if (request === accessRequest) isAdmin = access.is_admin;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -24,10 +25,12 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
const refresh = () => { void refreshAccess(); };
|
const refresh = () => { void refreshAccess(); };
|
||||||
|
const unsubscribe = browserSession.subscribe(refresh);
|
||||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||||
window.addEventListener("focus", refresh);
|
window.addEventListener("focus", refresh);
|
||||||
return () => {
|
return () => {
|
||||||
++accessRequest;
|
++accessRequest;
|
||||||
|
unsubscribe();
|
||||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||||
window.removeEventListener("focus", refresh);
|
window.removeEventListener("focus", refresh);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import GlobalNavSections from "./GlobalNavSections.svelte";
|
import GlobalNavSections from "./GlobalNavSections.svelte";
|
||||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
|
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ for (
|
|||||||
throw new Error(`Unexpected API ${input}`);
|
throw new Error(`Unexpected API ${input}`);
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetcher);
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
await browserSession.refresh();
|
||||||
render(GlobalNavSections, {
|
render(GlobalNavSections, {
|
||||||
currentPath: "/hosting/settings",
|
currentPath: "/hosting/settings",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
@@ -45,6 +48,7 @@ for (
|
|||||||
expect(link.getAttribute("href")).toBe("/hosting/settings");
|
expect(link.getAttribute("href")).toBe("/hosting/settings");
|
||||||
expect(link.getAttribute("aria-current")).toBe("page");
|
expect(link.getAttribute("aria-current")).toBe("page");
|
||||||
actor = false;
|
actor = false;
|
||||||
|
await browserSession.refresh();
|
||||||
notifySessionChanged();
|
notifySessionChanged();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
||||||
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
|
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { setContext } from 'svelte';
|
import { onMount, setContext } from 'svelte';
|
||||||
|
import { browserSession, type SessionSnapshot } from '#lib/workspace/auth/session-state.ts';
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
import WorkspaceAlerts from '#lib/workspace/alerts/WorkspaceAlerts.svelte';
|
import WorkspaceAlerts from '#lib/workspace/alerts/WorkspaceAlerts.svelte';
|
||||||
import { dismissWorkspaceAlert, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
import { dismissWorkspaceAlert, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||||
@@ -28,8 +29,11 @@
|
|||||||
|
|
||||||
let { children, data }: LayoutProps = $props();
|
let { children, data }: LayoutProps = $props();
|
||||||
import { isAuthPage } from '#lib/workspace/auth/routes.ts';
|
import { isAuthPage } from '#lib/workspace/auth/routes.ts';
|
||||||
// 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。
|
let session = $state<SessionSnapshot>(browserSession.snapshot);
|
||||||
const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
|
onMount(() => browserSession.subscribe((snapshot) => { session = snapshot; }));
|
||||||
|
const authenticated = $derived(session.kind === 'ready' && session.value.session.kind === 'authenticated');
|
||||||
|
// 公開トップのSidebarはログイン確認済みの場合だけ表示する。
|
||||||
|
const standalone = $derived((page.url.pathname === '/' && !authenticated) || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
|
||||||
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
|
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
|
||||||
let sidebar = $state<SidebarSnippet | null>(null);
|
let sidebar = $state<SidebarSnippet | null>(null);
|
||||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||||
@@ -103,7 +107,10 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<WorkspaceAlerts />
|
<!-- Workspace通知は公開トップや本人設定へ持ち越さない。 -->
|
||||||
|
{#if page.url.pathname !== '/' && page.url.pathname !== '/account'}
|
||||||
|
<WorkspaceAlerts />
|
||||||
|
{/if}
|
||||||
<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}
|
||||||
@@ -115,7 +122,7 @@
|
|||||||
<GlobalSidebar
|
<GlobalSidebar
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
content={sidebar}
|
content={sidebar}
|
||||||
workspaces={catalog.data?.accessibleWorkspaces ?? null}
|
workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}
|
||||||
workspaceError={catalog.data?.workspaceCatalogError}
|
workspaceError={catalog.data?.workspaceCatalogError}
|
||||||
/>
|
/>
|
||||||
</SidebarFrame>
|
</SidebarFrame>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||||
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
|
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
|
||||||
import RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
|
|
||||||
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
||||||
import '#lib/workspace/auth/auth-page.css';
|
import '#lib/workspace/auth/auth-page.css';
|
||||||
|
|
||||||
@@ -31,8 +30,6 @@
|
|||||||
{#key auth.actor.account_id}<AccountNavigation />{/key}
|
{#key auth.actor.account_id}<AccountNavigation />{/key}
|
||||||
{:else}
|
{:else}
|
||||||
<a class="entry-primary" href="/login">Log in</a>
|
<a class="entry-primary" href="/login">Log in</a>
|
||||||
<RegistrationLink />
|
|
||||||
<a href="/activate">Activate an account with a code</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ function boundary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
for (const policy of [true, false, "error", "loading"] as const) {
|
for (const policy of [true, false, "error", "loading"] as const) {
|
||||||
test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => {
|
test(`public home routes account actions through login without loading registration policy: ${policy}`, async () => {
|
||||||
const { state, workspaceCalls } = boundary();
|
const { state, fetcher, workspaceCalls } = boundary();
|
||||||
state.policy = policy;
|
state.policy = policy;
|
||||||
state.catalogStatus = 401;
|
state.catalogStatus = 401;
|
||||||
render(Home);
|
render(Home);
|
||||||
@@ -128,22 +128,15 @@ for (const policy of [true, false, "error", "loading"] as const) {
|
|||||||
"href",
|
"href",
|
||||||
),
|
),
|
||||||
).toBe("/login");
|
).toBe("/login");
|
||||||
if (policy === true) {
|
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
||||||
expect(
|
.toBeNull();
|
||||||
(await screen.findByRole("link", { name: "Create a new account" }))
|
expect(
|
||||||
.getAttribute("href"),
|
screen.queryByRole("link", { name: "Activate an account with a code" }),
|
||||||
).toBe("/register?returnTo=%2Fdashboard");
|
)
|
||||||
} else {
|
.toBeNull();
|
||||||
await screen.findByText(
|
expect(screen.queryByText(/registration/i)).toBeNull();
|
||||||
policy === false
|
expect(fetcher.mock.calls.map(([path]) => String(path)))
|
||||||
? /registration is disabled/
|
.not.toContain("/api/auth/registration-policy");
|
||||||
: policy === "error"
|
|
||||||
? /availability is unknown/
|
|
||||||
: /Checking registration/,
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
|
||||||
.toBeNull();
|
|
||||||
}
|
|
||||||
expect(workspaceCalls()).toEqual([]);
|
expect(workspaceCalls()).toEqual([]);
|
||||||
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
|
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
const root = resolve(import.meta.dirname!, "../../../..");
|
const root = resolve(import.meta.dirname!, "../../../..");
|
||||||
const origin = "http://127.0.0.1:15411";
|
const origin = "http://127.0.0.1:15411";
|
||||||
const ready = (page: Page, name: string) =>
|
const ready = (page: Page, name: string) =>
|
||||||
page.getByRole("link", { name, exact: true }).waitFor();
|
page.getByRole("main").getByRole("link", { name, exact: true }).waitFor();
|
||||||
async function focusWithoutSessionCheck(page: Page) {
|
async function focusWithoutSessionCheck(page: Page) {
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
const observe = (request: import("playwright").Request) => {
|
const observe = (request: import("playwright").Request) => {
|
||||||
@@ -123,6 +123,24 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
|||||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
await ready(page, "Log in");
|
await ready(page, "Log in");
|
||||||
|
assertEquals(
|
||||||
|
await page.locator(
|
||||||
|
'a[href^="/register"], a[href="/activate"]',
|
||||||
|
).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assertEquals(await page.getByText(/registration/i).count(), 0);
|
||||||
|
await page.getByRole("link", { name: "Log in", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "Log in with passkey" })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("link", {
|
||||||
|
name: "Activate an account with a code",
|
||||||
|
exact: true,
|
||||||
|
}).getAttribute("href"),
|
||||||
|
"/activate",
|
||||||
|
);
|
||||||
if (mode === "anonymous") {
|
if (mode === "anonymous") {
|
||||||
await ready(page, "Create a new account");
|
await ready(page, "Create a new account");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -146,19 +164,6 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await page.getByRole("link", { name: "Log in", exact: true })
|
|
||||||
.click();
|
|
||||||
await page.getByRole("button", { name: "Log in with passkey" })
|
|
||||||
.waitFor();
|
|
||||||
if (mode === "policy-disabled") {
|
|
||||||
await page.getByText(/registration is disabled/).waitFor();
|
|
||||||
assertEquals(
|
|
||||||
await page.getByRole("link", {
|
|
||||||
name: "Create a new account",
|
|
||||||
}).count(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertEquals(workspaceCalls, 0);
|
assertEquals(workspaceCalls, 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.locator(".workspace-catalog-alert").count(),
|
await page.locator(".workspace-catalog-alert").count(),
|
||||||
|
|||||||
@@ -289,7 +289,9 @@ export async function sessionNavigation(
|
|||||||
if (active) {
|
if (active) {
|
||||||
await at(page, target);
|
await at(page, target);
|
||||||
assertEquals(await page.getByText("Loading session…").count(), 0);
|
assertEquals(await page.getByText("Loading session…").count(), 0);
|
||||||
await page.getByRole("link", { name: "Dashboard / Workspaces" })
|
await page.getByRole("main").getByRole("link", {
|
||||||
|
name: "Dashboard / Workspaces",
|
||||||
|
})
|
||||||
.click();
|
.click();
|
||||||
await at(page, loginTarget("/dashboard"));
|
await at(page, loginTarget("/dashboard"));
|
||||||
await login(page);
|
await login(page);
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
// 本番shell/routerと合成HTTP。実アカウントや認証器は操作しない。
|
||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { resolve } from "@std/path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import {
|
||||||
|
startOwnedProcesses,
|
||||||
|
stopOwnedProcesses,
|
||||||
|
} from "../../../../tools/web-ux/src/processes.ts";
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname!, "../../../..");
|
||||||
|
const origin = "http://127.0.0.1:15421";
|
||||||
|
const output = `${root}/target/web-ux/sidebar-restoration`;
|
||||||
|
|
||||||
|
Deno.test("only authenticated account and home show the sidebar; public entry remains standalone", async (t) => {
|
||||||
|
const processes = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: "sidebar-fixture",
|
||||||
|
command: "deno",
|
||||||
|
cwd: root,
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--config",
|
||||||
|
"tools/web-ux/deno.json",
|
||||||
|
"--allow-read",
|
||||||
|
"--allow-net",
|
||||||
|
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||||
|
"15421",
|
||||||
|
"web/workspace/build",
|
||||||
|
],
|
||||||
|
readyUrl: `${origin}/health`,
|
||||||
|
}],
|
||||||
|
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||||
|
`${output}/processes`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
for (
|
||||||
|
const [path, mode] of [
|
||||||
|
["/account", "workspace-one"],
|
||||||
|
["/account", "account"],
|
||||||
|
["/account", "workspace-error"],
|
||||||
|
["/account", "workspace-loading"],
|
||||||
|
["/", "anonymous"],
|
||||||
|
["/", "account"],
|
||||||
|
["/", "session-error"],
|
||||||
|
["/", "session-loading"],
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
await t.step(`${width}: ${path} ${mode}`, async () => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme: width === 390 ? "dark" : "light",
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10_000);
|
||||||
|
const errors: string[] = [];
|
||||||
|
let catalogCalls = 0;
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (new URL(request.url()).pathname === "/api/workspaces") {
|
||||||
|
catalogCalls++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.request.get(`${origin}/?fixture=${mode}`);
|
||||||
|
await page.goto(`${origin}${path}`);
|
||||||
|
await page.getByRole("heading", {
|
||||||
|
name: path === "/" ? "Yoi" : "Account",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
if (path === "/" && mode !== "account") {
|
||||||
|
if (mode === "anonymous") {
|
||||||
|
await page.getByRole("link", { name: "Log in", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
} else if (mode === "session-error") {
|
||||||
|
await page.getByRole("alert").waitFor();
|
||||||
|
} else {
|
||||||
|
await page.getByText("Loading session…", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
}
|
||||||
|
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||||
|
assertEquals(
|
||||||
|
await page.locator(".app-shell__topbar").count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (mode === "anonymous") {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${output}/anonymous-${width}.png`,
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Log in", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Log in with passkey",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||||
|
await page.goBack();
|
||||||
|
await page.getByRole("link", { name: "Log in", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||||
|
}
|
||||||
|
assertEquals(catalogCalls, 0);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sidebar = page.getByRole("complementary", {
|
||||||
|
name: "Sidebar",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
if (width === 390) {
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Show sidebar",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("main").evaluate((main) =>
|
||||||
|
main.hasAttribute("inert")
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const navigation = sidebar.getByRole("navigation", {
|
||||||
|
name: "Global pages",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await navigation.getByRole("link", {
|
||||||
|
name: "Dashboard / Workspaces",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
const account = navigation.getByRole("link", {
|
||||||
|
name: "Account",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assertEquals(await account.getAttribute("href"), "/account");
|
||||||
|
assertEquals(
|
||||||
|
await account.getAttribute("aria-current"),
|
||||||
|
path === "/account" ? "page" : null,
|
||||||
|
);
|
||||||
|
if (mode === "workspace-one") {
|
||||||
|
await navigation.getByRole("link", {
|
||||||
|
name: "New Workspace",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
} else if (path === "/account" && mode === "account") {
|
||||||
|
await navigation.getByText("No accessible Workspaces.", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
} else {
|
||||||
|
assertEquals(
|
||||||
|
await navigation.getByText("No accessible Workspaces.", {
|
||||||
|
exact: true,
|
||||||
|
}).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === "/") assertEquals(catalogCalls, 0);
|
||||||
|
if (
|
||||||
|
mode === "workspace-one" ||
|
||||||
|
(path === "/" && mode === "account")
|
||||||
|
) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${output}/${
|
||||||
|
path === "/" ? "home" : "account"
|
||||||
|
}-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (width === 390) {
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Hide sidebar",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("main").evaluate((main) =>
|
||||||
|
main.hasAttribute("inert")
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === "/account") {
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Add passkey to alice",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
} else {
|
||||||
|
// The authenticated home has a sidebar; logout must remove it on return.
|
||||||
|
await page.getByRole("main").getByRole("link", {
|
||||||
|
name: "Account",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("link", { name: "Log in", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(new URL(page.url()).pathname, "/");
|
||||||
|
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.documentElement.scrollWidth > innerWidth
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await stopOwnedProcesses(processes);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,7 +10,6 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
|
|||||||
"/activate",
|
"/activate",
|
||||||
"/login",
|
"/login",
|
||||||
"/register",
|
"/register",
|
||||||
"/account",
|
|
||||||
"/login/device",
|
"/login/device",
|
||||||
"/hosting/accounts",
|
"/hosting/accounts",
|
||||||
"/hosting/settings",
|
"/hosting/settings",
|
||||||
@@ -49,6 +48,45 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("account loads its sidebar catalog without making catalog failure an admission failure", async () => {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
Response.json({
|
||||||
|
actor: {
|
||||||
|
user_id: "u",
|
||||||
|
account_id: "a",
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
},
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
});
|
||||||
|
browserSession.invalidate();
|
||||||
|
let calls = 0;
|
||||||
|
const event = {
|
||||||
|
depends: () => {},
|
||||||
|
url: new URL("https://workspace.test/account"),
|
||||||
|
fetch: () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error("Workspace catalog unavailable");
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof load>[0];
|
||||||
|
const result = await load(event);
|
||||||
|
assertEquals(calls, 1);
|
||||||
|
assertEquals(await result.deferredCatalog, {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
accessibleWorkspaces: [],
|
||||||
|
workspaceCatalogError: "Workspace catalog unavailable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
browserSession.dispose();
|
||||||
|
globalThis.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
||||||
assertEquals(ssr, false);
|
assertEquals(ssr, false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user