fix: stop background session checks and loading flicker

This commit is contained in:
2026-10-20 22:35:38 +09:00
parent af615ab177
commit 4ef7b03651
14 changed files with 334 additions and 283 deletions
@@ -1,7 +1,3 @@
import {
browserSession,
recheckAfterUnauthorized,
} from "#lib/workspace/auth/session-state.ts";
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
import type { import type {
SkillCatalogResponse, SkillCatalogResponse,
@@ -106,11 +102,7 @@ export async function loadJson<T>(
): Promise<ApiResult<T>> { ): Promise<ApiResult<T>> {
let stage: "request" | "decode" | "validate" = "request"; let stage: "request" | "decode" | "validate" = "request";
try { try {
const authenticationGeneration = browserSession.authenticationGeneration;
const response = await fetchFn(path, init); const response = await fetchFn(path, init);
if (response.status === 401) {
recheckAfterUnauthorized(authenticationGeneration);
}
if (!response.ok) { if (!response.ok) {
if (policy) { if (policy) {
await response.body?.cancel(); await response.body?.cancel();
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { goto, invalidate } from '$app/navigation'; import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state'; import { page, navigating as navigation } from '$app/state';
import { browserSession, observeBrowserSession, type SessionSnapshot } from './session-state.ts'; import { browserSession, observeBrowserSession, type SessionSnapshot } from './session-state.ts';
import { isPublicPage, loginUrl } from './routes.ts'; import { isPublicPage, loginUrl } from './routes.ts';
import './auth-page.css'; import './auth-page.css';
@@ -27,7 +27,9 @@
}); });
}); });
$effect(() => { $effect(() => {
if (!protectedPage || session.kind !== 'ready') return; // The destination loader owns admission during navigation, including returnTo.
// Do not race it with a redirect for the page being left.
if (navigation.to || !protectedPage || session.kind !== 'ready') return;
const state = session.value.session.kind; const state = session.value.session.kind;
if (state === 'authenticated') { if (state === 'authenticated') {
if (!sameActor) { if (!sameActor) {
@@ -47,7 +49,6 @@
void goto(target, { replaceState: true }).finally(() => { navigating = false; }); void goto(target, { replaceState: true }).finally(() => { navigating = false; });
}); });
async function retry() { async function retry() {
await browserSession.refresh();
await invalidate('auth:session'); await invalidate('auth:session');
} }
</script> </script>
@@ -61,6 +61,11 @@ export class BrowserAuth {
return this.session.kind === "ready" ? this.session.actor : null; return this.session.kind === "ready" ? this.session.actor : null;
} }
async ensure(): Promise<void> {
if (this.busy || this.disposed) return;
await browserSession.ensure();
}
async refresh(): Promise<void> { async refresh(): Promise<void> {
if (this.busy || this.disposed) return; if (this.busy || this.disposed) return;
await browserSession.refresh(); await browserSession.refresh();
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
import { afterEach, expect, test, vi } from "vitest";
import { browserSession, observeBrowserSession } from "./session-state.ts";
import { requirePageSession } from "./page-session.ts";
import { loadJson } from "../api/http.ts";
import { authUser, whoamiPayload } from "./api.test-fixtures.ts";
const actor = { ...authUser, auth_method: "browser_session" };
const target = new URL("http://localhost/w/team/tickets?q=mine#entry");
afterEach(() => {
browserSession.invalidate();
localStorage.clear();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function fixture() {
const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor)));
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
test("focus, visibility and pageshow never recheck an admitted session", async () => {
const fetcher = fixture();
await requirePageSession(target);
const admitted = browserSession.snapshot;
for (let i = 0; i < 3; i++) {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("pageshow"));
document.dispatchEvent(new Event("visibilitychange"));
}
await Promise.resolve();
expect(fetcher).toHaveBeenCalledTimes(1);
expect(browserSession.snapshot).toBe(admitted);
});
test("concurrent API 401 responses return errors without checking or changing the session", async () => {
const fetcher = fixture();
await requirePageSession(target);
const admitted = browserSession.snapshot;
const unauthorized = vi.fn(async () =>
new Response("denied", { status: 401 })
);
const results = await Promise.all([
loadJson(unauthorized, "/api/w/team/workspace"),
loadJson(unauthorized, "/api/w/team/repositories"),
]);
for (const result of results) {
expect(result).toEqual({ data: null, error: "denied", status: 401 });
}
expect(fetcher).toHaveBeenCalledTimes(1);
expect(browserSession.snapshot).toBe(admitted);
});
for (const kind of ["anonymous", "expired"] as const) {
test(`the next protected page access checks again and redirects ${kind}`, async () => {
const fetcher = fixture();
await requirePageSession(target);
await browserSession.ensure();
expect(fetcher).toHaveBeenCalledTimes(1);
fetcher.mockResolvedValueOnce(
Response.json({ actor: null, session: { kind } }),
);
await expect(requirePageSession(target)).rejects.toMatchObject({
status: 307,
location: kind === "anonymous"
? "/"
: "/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry",
});
expect(fetcher).toHaveBeenCalledTimes(2);
});
}
test("storage notifications fence the cache without background requests or loading", async () => {
const fetcher = fixture();
observeBrowserSession();
await requirePageSession(target);
const admitted = browserSession.snapshot;
const key = "yoi.browser-auth-generation";
const newValue = JSON.stringify({
id: "other-tab",
reason: "authentication",
});
localStorage.setItem(key, newValue);
window.dispatchEvent(new StorageEvent("storage", { key, newValue }));
await Promise.resolve();
expect(fetcher).toHaveBeenCalledTimes(1);
expect(browserSession.snapshot).toBe(admitted);
await requirePageSession(target);
expect(fetcher).toHaveBeenCalledTimes(2);
});
test("a failed page check reports unknown instead of admitting or redirecting", async () => {
const fetcher = fixture();
await requirePageSession(target);
fetcher.mockResolvedValueOnce(new Response(null, { status: 503 }));
await expect(requirePageSession(target)).rejects.toMatchObject({
status: 503,
});
expect(browserSession.snapshot.kind).toBe("error");
});
@@ -9,13 +9,15 @@ import { goto } from "$app/navigation";
const page = vi.hoisted(() => ({ const page = vi.hoisted(() => ({
url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"), url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"),
})); }));
vi.mock("$app/state", () => ({ page })); const navigation = vi.hoisted(() => ({ to: null as { url: URL } | null }));
vi.mock("$app/state", () => ({ page, navigating: navigation }));
vi.mock( vi.mock(
"$app/navigation", "$app/navigation",
() => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }),
); );
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
navigation.to = null;
browserSession.invalidate(); browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -54,7 +56,23 @@ test("active expired state removes private content and preserves browser query a
); );
}); });
test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => { test("navigation admission does not redirect to the old page return URL", async () => {
await admitted();
navigation.to = { url: new URL("http://localhost/dashboard") };
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } })
),
);
await browserSession.refresh();
await waitFor(() =>
expect(screen.queryByText("Private fragment")).toBeNull()
);
expect(goto).not.toHaveBeenCalled();
});
test("page access keeps admitted content visible while checking, but a failed check is unknown", async () => {
await admitted(); await admitted();
let release!: (response: Response) => void; let release!: (response: Response) => void;
vi.stubGlobal( vi.stubGlobal(
@@ -66,9 +84,9 @@ test("unknown state hides and inerts admitted content instead of declaring anony
), ),
); );
const checking = browserSession.refresh(); const checking = browserSession.refresh();
await screen.findByText("Loading session…"); expect(screen.queryByText("Loading session…")).toBeNull();
expect(screen.getByText("Private fragment").closest("[hidden][inert]")) expect(screen.getByText("Private fragment").closest("[hidden]"))
.toBeTruthy(); .toBeNull();
release(new Response(null, { status: 503 })); release(new Response(null, { status: 503 }));
await checking; await checking;
await screen.findByText( await screen.findByText(
@@ -99,7 +117,7 @@ test("another tab changing actor cannot reveal content loaded for the old actor"
expect(screen.queryByText("Private fragment")).toBeNull(); expect(screen.queryByText("Private fragment")).toBeNull();
}); });
test("a child mount session recheck does not unmount and restart its own loader", async () => { test("a child mount reuses the page session without another request or remount", async () => {
window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry");
const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor))); const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor)));
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
@@ -109,7 +127,7 @@ test("a child mount session recheck does not unmount and restart its own loader"
render: () => "<p>Mounted session reader</p>", render: () => "<p>Mounted session reader</p>",
setup: () => { setup: () => {
++mounts; ++mounts;
void browserSession.refresh(); void browserSession.ensure();
}, },
})); }));
render(SessionBoundary, { children: checkingChild }); render(SessionBoundary, { children: checkingChild });
@@ -117,5 +135,5 @@ test("a child mount session recheck does not unmount and restart its own loader"
expect(screen.getByText("Mounted session reader").closest("[hidden]")) expect(screen.getByText("Mounted session reader").closest("[hidden]"))
.toBeNull(); .toBeNull();
expect(mounts).toBe(1); expect(mounts).toBe(1);
expect(fetcher).toHaveBeenCalledTimes(2); expect(fetcher).toHaveBeenCalledTimes(1);
}); });
@@ -79,19 +79,48 @@ test("storage generation fences responses even before the other tab storage even
expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() });
}); });
test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => { test("deadline does not recheck; the next page check reads the server", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const read = vi.fn<() => Promise<WhoamiResponse>>().mockResolvedValueOnce( const read = vi.fn<() => Promise<WhoamiResponse>>().mockResolvedValueOnce(
authenticated(new Date(Date.now() + 5_000).toISOString()), authenticated(new Date(Date.now() + 5_000).toISOString()),
).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired); ).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired);
const session = state(read); const session = state(read);
await session.refresh(); await session.refresh();
await vi.advanceTimersByTimeAsync(5_000); const admitted = session.snapshot;
await vi.advanceTimersByTimeAsync(60_000);
expect(read).toHaveBeenCalledTimes(1);
expect(session.snapshot).toBe(admitted);
await session.refresh();
expect(session.snapshot).toEqual({ kind: "error" }); expect(session.snapshot).toEqual({ kind: "error" });
await session.refresh(); await session.refresh();
expect(session.snapshot).toEqual({ kind: "ready", value: expired }); expect(session.snapshot).toEqual({ kind: "ready", value: expired });
}); });
test("mounted readers reuse the page check and page access still refreshes", async () => {
const read = vi.fn(async () => authenticated());
const session = state(read);
await session.refresh();
await session.ensure();
await session.ensure();
expect(read).toHaveBeenCalledTimes(1);
await session.refresh();
expect(read).toHaveBeenCalledTimes(2);
});
test("an admitted page remains ready while its next page check is pending", async () => {
const next = gate<WhoamiResponse>();
const read = vi.fn<() => Promise<WhoamiResponse>>()
.mockResolvedValueOnce(authenticated()).mockReturnValueOnce(next.promise);
const session = state(read);
await session.refresh();
const previous = session.snapshot;
const checking = session.refresh();
expect(session.snapshot).toBe(previous);
next.resolve(expired);
await checking;
expect(session.snapshot).toEqual({ kind: "ready", value: expired });
});
test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => { test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => {
for (const value of [authenticated(), anonymous]) { for (const value of [authenticated(), anonymous]) {
const session = state(async () => value); const session = state(async () => value);
@@ -15,7 +15,7 @@ export class SessionState {
private generation = 0; private generation = 0;
private pending: Promise<SessionSnapshot> | null = null; private pending: Promise<SessionSnapshot> | null = null;
private listeners = new Set<(state: SessionSnapshot) => void>(); private listeners = new Set<(state: SessionSnapshot) => void>();
private timer: ReturnType<typeof setTimeout> | undefined; private stale = true;
get authenticationGeneration(): number { get authenticationGeneration(): number {
return this.generation; return this.generation;
@@ -31,34 +31,29 @@ export class SessionState {
private publish(snapshot: SessionSnapshot): void { private publish(snapshot: SessionSnapshot): void {
this.snapshot = snapshot; this.snapshot = snapshot;
clearTimeout(this.timer);
if (
snapshot.kind === "ready" &&
snapshot.value.session.kind === "authenticated"
) {
const expiry = snapshot.value.session.expires_at;
if (expiry !== null) {
// Timerは再照会の契機。local clockだけで期限切れと断定しない。
const remaining = Date.parse(expiry) - Date.now();
this.timer = setTimeout(() => {
void this.refresh();
}, Math.min(2_147_483_647, Math.max(1_000, remaining)));
}
}
for (const listener of this.listeners) listener(snapshot); for (const listener of this.listeners) listener(snapshot);
} }
invalidate(): void { invalidate(preserveSnapshot = false): void {
++this.generation; ++this.generation;
this.pending = null; this.pending = null;
this.publish({ kind: "loading" }); this.stale = true;
if (!preserveSnapshot) this.publish({ kind: "loading" });
}
// Page load owns freshness. Components mounted after it reuse that result.
async ensure(): Promise<SessionSnapshot> {
if (this.pending) return this.pending;
if (!this.stale && this.snapshot.kind === "ready") return this.snapshot;
return this.refresh();
} }
async refresh(): Promise<SessionSnapshot> { async refresh(): Promise<SessionSnapshot> {
if (this.pending) return this.pending; if (this.pending) return this.pending;
const generation = this.generation; const generation = this.generation;
const stamp = storageStamp(); const stamp = storageStamp();
this.publish({ kind: "loading" }); // Keep the admitted page visible while a new page access is checked.
if (this.snapshot.kind !== "ready") this.publish({ kind: "loading" });
const pending = (async () => { const pending = (async () => {
let result: SessionSnapshot; let result: SessionSnapshot;
try { try {
@@ -70,13 +65,16 @@ export class SessionState {
// 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。 // 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。
if (generation !== this.generation) { if (generation !== this.generation) {
if (this.pending) return await this.pending; if (this.pending) return await this.pending;
if (this.snapshot.kind !== "loading") return this.snapshot; if (!this.stale && this.snapshot.kind !== "loading") {
return this.snapshot;
}
} }
// 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。 // 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。
if (generation === this.generation) this.invalidate(); if (generation === this.generation) this.invalidate();
return await this.refresh(); return await this.refresh();
} }
this.pending = null; this.pending = null;
this.stale = false;
this.publish(result); this.publish(result);
return result; return result;
})(); })();
@@ -85,7 +83,6 @@ export class SessionState {
} }
dispose(): void { dispose(): void {
clearTimeout(this.timer);
this.listeners.clear(); this.listeners.clear();
++this.generation; ++this.generation;
} }
@@ -120,34 +117,18 @@ function clearReturnTo(): void {
} }
let installed = false; let installed = false;
export function recheckAfterUnauthorized(
authenticationGeneration: number,
): void {
if (
installed &&
authenticationGeneration === browserSession.authenticationGeneration
) void browserSession.refresh();
}
export function observeBrowserSession(): void { export function observeBrowserSession(): void {
if (installed || typeof window === "undefined") return; if (installed || typeof window === "undefined") return;
installed = true; installed = true;
const refresh = () => {
void browserSession.refresh();
};
window.addEventListener("focus", refresh);
window.addEventListener("pageshow", refresh);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) refresh();
});
window.addEventListener("storage", (event) => { window.addEventListener("storage", (event) => {
if (event.key !== GENERATION_KEY && event.key !== null) return; if (event.key !== GENERATION_KEY && event.key !== null) return;
browserSession.invalidate(); // Fence old responses, but leave this page alone until the next page access.
browserSession.invalidate(true);
try { try {
if ( if (
event.key === null || event.key === null ||
JSON.parse(event.newValue ?? "null")?.reason === "logout" JSON.parse(event.newValue ?? "null")?.reason === "logout"
) clearReturnTo(); ) clearReturnTo();
} catch { /* 不明な通知も権限ではなく再照会だけ。 */ } } catch { /* 不明な通知でも次のページアクセス時に確認する。 */ }
refresh();
}); });
} }
+2 -10
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
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 { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.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 RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte'; import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
@@ -9,15 +8,8 @@
const auth = new BrowserAuth(); const auth = new BrowserAuth();
onMount(() => { onMount(() => {
const refresh = () => { void auth.refresh(); }; void auth.refresh();
refresh(); return () => auth.dispose();
window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => {
auth.dispose();
window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
};
}); });
</script> </script>
@@ -19,7 +19,7 @@
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout'); const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto('/', { replaceState: true }); if (confirmed && !auth.actor) await goto('/', { replaceState: true });
} }
onMount(() => { void refresh(); return () => auth.dispose(); }); onMount(() => { void auth.ensure(); return () => auth.dispose(); });
</script> </script>
<svelte:head><title>Account · Yoi</title></svelte:head> <svelte:head><title>Account · Yoi</title></svelte:head>
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
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 { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts'; import { loginUrl } from '#lib/workspace/auth/routes.ts';
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte'; import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
import WorkspaceCatalog from './WorkspaceCatalog.svelte'; import WorkspaceCatalog from './WorkspaceCatalog.svelte';
@@ -23,15 +22,10 @@
}); });
onMount(() => { onMount(() => {
mounted = true; mounted = true;
const refresh = () => { void auth.refresh(); }; void auth.ensure();
refresh();
window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => { return () => {
mounted = false; mounted = false;
auth.dispose(); auth.dispose();
window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
}; };
}); });
</script> </script>
+20 -18
View File
@@ -181,7 +181,7 @@ for (const admin of [false, true]) {
else {expect(screen.queryByRole("link", { name: "Hosting settings" })) else {expect(screen.queryByRole("link", { name: "Hosting settings" }))
.toBeNull();} .toBeNull();}
state.signedIn = false; state.signedIn = false;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByRole("link", { name: "Log in" }); await screen.findByRole("link", { name: "Log in" });
expect(screen.queryByRole("link", { name: "Hosting settings" })).toBeNull(); expect(screen.queryByRole("link", { name: "Hosting settings" })).toBeNull();
}); });
@@ -233,7 +233,7 @@ test("dashboard removes private content on session change and ignores old catalo
render(Dashboard); render(Dashboard);
await screen.findByText("Loading Workspaces…"); await screen.findByText("Loading Workspaces…");
state.signedIn = false; state.signedIn = false;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByText("Log in to select or create a Workspace."); await screen.findByText("Log in to select or create a Workspace.");
state.catalogGate.resolve(Response.json([createdWorkspace])); state.catalogGate.resolve(Response.json([createdWorkspace]));
expect(screen.queryByText("New Workspace")).toBeNull(); expect(screen.queryByText("New Workspace")).toBeNull();
@@ -246,6 +246,7 @@ test("dashboard does not contact catalog for anonymous or unknown session", asyn
expect(workspaceCalls()).toEqual([]); expect(workspaceCalls()).toEqual([]);
view.unmount(); view.unmount();
state.whoamiStatus = 503; state.whoamiStatus = 503;
browserSession.invalidate();
render(Dashboard); render(Dashboard);
await screen.findByRole("alert"); await screen.findByRole("alert");
expect(workspaceCalls()).toEqual([]); expect(workspaceCalls()).toEqual([]);
@@ -268,7 +269,7 @@ const creationBodies = (fetcher: ReturnType<typeof boundary>["fetcher"]) =>
[, init], [, init],
) => JSON.parse(String(init!.body))); ) => JSON.parse(String(init!.body)));
test("same actor focus retains creation disclosure, repository fields, errors and exact retry key through unknown session", async () => { test("focus retains creation inputs, errors and retry key without a session request", async () => {
const { state, fetcher } = boundary(); const { state, fetcher } = boundary();
state.signedIn = true; state.signedIn = true;
state.createStatus = 503; state.createStatus = 503;
@@ -288,16 +289,15 @@ test("same actor focus retains creation disclosure, repository fields, errors an
); );
await submitCreation(); await submitCreation();
await screen.findByRole("alert"); await screen.findByRole("alert");
state.sessionGate = deferred<Response>(); const sessionCalls = () =>
fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami").length;
const count = sessionCalls();
await fireEvent(window, new Event("focus")); await fireEvent(window, new Event("focus"));
await screen.findByText("Loading session…"); await fireEvent(window, new Event("pageshow"));
expect(screen.queryByLabelText("Workspace display name")).toBeNull(); await fireEvent(document, new Event("visibilitychange"));
state.sessionGate.resolve( await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
Response.json({ expect(sessionCalls()).toBe(count);
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, expect(screen.queryByText("Loading session…")).toBeNull();
actor: { ...authUser, auth_method: "browser_session" },
}),
);
expect( expect(
(await screen.findByLabelText("Workspace display name") as HTMLInputElement) (await screen.findByLabelText("Workspace display name") as HTMLInputElement)
.value, .value,
@@ -315,7 +315,7 @@ test("same actor focus retains creation disclosure, repository fields, errors an
expect(creationBodies(fetcher)[0]).toEqual(creationBodies(fetcher)[1]); expect(creationBodies(fetcher)[0]).toEqual(creationBodies(fetcher)[1]);
}); });
test("pending create remains single across focus and a success during unknown session becomes an explicit confirmed-result link", async () => { test("pending create remains single during an explicit authentication change and retains its result", async () => {
const { state, fetcher } = boundary(); const { state, fetcher } = boundary();
state.signedIn = true; state.signedIn = true;
state.creationGate = deferred<Response>(); state.creationGate = deferred<Response>();
@@ -324,7 +324,8 @@ test("pending create remains single across focus and a success during unknown se
await submitCreation(); await submitCreation();
await screen.findByRole("button", { name: "Creating…" }); await screen.findByRole("button", { name: "Creating…" });
state.sessionGate = deferred<Response>(); state.sessionGate = deferred<Response>();
await fireEvent(window, new Event("focus")); browserSession.invalidate();
const checking = browserSession.refresh();
await screen.findByText("Loading session…"); await screen.findByText("Loading session…");
const response = Response.json(creationResponse()); const response = Response.json(creationResponse());
state.creationGate.resolve(response); state.creationGate.resolve(response);
@@ -336,6 +337,7 @@ test("pending create remains single across focus and a success during unknown se
actor: { ...authUser, auth_method: "browser_session" }, actor: { ...authUser, auth_method: "browser_session" },
}), }),
); );
await checking;
expect( expect(
(await screen.findByRole("link", { name: "Open created Workspace" })) (await screen.findByRole("link", { name: "Open created Workspace" }))
.getAttribute("href"), .getAttribute("href"),
@@ -351,7 +353,7 @@ test("same actor session error retains draft privately until retry; confirmed di
render(Dashboard); render(Dashboard);
await openCreation(); await openCreation();
state.whoamiStatus = 503; state.whoamiStatus = 503;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByRole("alert"); await screen.findByRole("alert");
expect(screen.queryByLabelText("Workspace display name")).toBeNull(); expect(screen.queryByLabelText("Workspace display name")).toBeNull();
state.whoamiStatus = 200; state.whoamiStatus = 200;
@@ -361,7 +363,7 @@ test("same actor session error retains draft privately until retry; confirmed di
.value, .value,
).toBe("Retained draft"); ).toBe("Retained draft");
state.accountId = "different-account"; state.accountId = "different-account";
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByText("No accessible Workspaces"); await screen.findByText("No accessible Workspaces");
await fireEvent.click( await fireEvent.click(
screen.getByText("Create Workspace", { selector: "summary" }), screen.getByText("Create Workspace", { selector: "summary" }),
@@ -380,14 +382,14 @@ test("late creation result after confirmed logout or actor change never navigate
await submitCreation(); await submitCreation();
await screen.findByRole("button", { name: "Creating…" }); await screen.findByRole("button", { name: "Creating…" });
state.signedIn = false; state.signedIn = false;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByRole("link", { name: "Log in" }); await screen.findByRole("link", { name: "Log in" });
const response = Response.json(creationResponse()); const response = Response.json(creationResponse());
state.creationGate.resolve(response); state.creationGate.resolve(response);
await waitFor(() => expect(response.bodyUsed).toBe(true)); await waitFor(() => expect(response.bodyUsed).toBe(true));
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
state.signedIn = true; state.signedIn = true;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); await browserSession.refresh();
await screen.findByText("No accessible Workspaces"); await screen.findByText("No accessible Workspaces");
expect(screen.queryByRole("link", { name: "Open created Workspace" })) expect(screen.queryByRole("link", { name: "Open created Workspace" }))
.toBeNull(); .toBeNull();
@@ -19,7 +19,10 @@ import Layout from "./+layout.svelte";
vi.mock( vi.mock(
"$app/state", "$app/state",
() => ({ page: { url: new URL("https://example.test/w/example") } }), () => ({
page: { url: new URL("https://example.test/w/example") },
navigating: { to: null },
}),
); );
const storageKey = "yoi.sidebar.mode.v1"; const storageKey = "yoi.sidebar.mode.v1";
const legacyKey = "yoi.sidebar.folded.v1"; const legacyKey = "yoi.sidebar.folded.v1";
@@ -12,6 +12,27 @@ 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("link", { name, exact: true }).waitFor();
async function focusWithoutSessionCheck(page: Page) {
let checks = 0;
const observe = (request: import("playwright").Request) => {
if (new URL(request.url()).pathname === "/api/auth/whoami") checks++;
};
page.on("request", observe);
try {
await page.evaluate(async () => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("pageshow"));
document.dispatchEvent(new Event("visibilitychange"));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
});
assertEquals(checks, 0);
assertEquals(await page.getByText("Loading session…").count(), 0);
} finally {
page.off("request", observe);
}
}
Deno.test("public entry isolates Workspace authority and authenticated dashboard supports explicit selection", async (t) => { Deno.test("public entry isolates Workspace authority and authenticated dashboard supports explicit selection", async (t) => {
const processes = await startOwnedProcesses( const processes = await startOwnedProcesses(
[ [
@@ -299,23 +320,17 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
); );
assertEquals(retainedCounts, [0, 0, 0, 0]); assertEquals(retainedCounts, [0, 0, 0, 0]);
assertEquals(await page.locator(".workspace-alert").count(), 0); assertEquals(await page.locator(".workspace-alert").count(), 0);
let release!: () => void; await page.route(
const recheck = new Promise<void>((resolve) => { "**/api/auth/whoami",
release = resolve; (route) =>
}); route.fulfill({
await page.route("**/api/auth/whoami", async (route) => { status: 503,
await recheck; body: "synthetic unavailable session",
await route.fulfill({ }),
status: 503,
body: "synthetic unavailable session",
});
});
await page.evaluate(() =>
window.dispatchEvent(new Event("focus"))
); );
await page.getByText("Loading session…").waitFor(); await focusWithoutSessionCheck(page);
assertEquals(await page.locator(".workspace-alert").count(), 0); assertEquals(await page.locator(".workspace-alert").count(), 0);
release(); await page.reload();
await page.getByText(/Login status is unknown/).waitFor(); await page.getByText(/Login status is unknown/).waitFor();
assertEquals(await page.locator(".workspace-alert").count(), 0); assertEquals(await page.locator(".workspace-alert").count(), 0);
assertEquals( assertEquals(
@@ -355,7 +370,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
); );
} }
await step( await step(
"session retry and focus/session change remove stale actor/admin links", "session retry and page access refresh actor/admin links",
async (page) => { async (page) => {
await page.goto(`${origin}/?fixture=session-retry`); await page.goto(`${origin}/?fixture=session-retry`);
await page.getByRole("button", { name: "Retry session" }).click(); await page.getByRole("button", { name: "Retry session" }).click();
@@ -364,9 +379,8 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await ready(page, "Hosting settings"); await ready(page, "Hosting settings");
await ready(page, "Open dashboard"); await ready(page, "Open dashboard");
await page.request.post(`${origin}/api/auth/logout`); await page.request.post(`${origin}/api/auth/logout`);
await page.evaluate(() => await focusWithoutSessionCheck(page);
window.dispatchEvent(new Event("yoi:browser-session-changed")) await page.reload();
);
await ready(page, "Log in"); await ready(page, "Log in");
assertEquals( assertEquals(
await page.getByRole("link", { name: "Hosting settings" }) await page.getByRole("link", { name: "Hosting settings" })
@@ -513,13 +527,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true, exact: true,
}).click(); }).click();
await submitted; await submitted;
const checked = page.waitForResponse((response) => await focusWithoutSessionCheck(page);
new URL(response.url()).pathname === "/api/auth/whoami"
);
await page.evaluate(() =>
window.dispatchEvent(new Event("focus"))
);
await checked;
await page.getByRole("button", { name: "Creating…", exact: true }) await page.getByRole("button", { name: "Creating…", exact: true })
.waitFor(); .waitFor();
assertEquals( assertEquals(
@@ -541,150 +549,55 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
}, },
); );
await step( await step(
"success during unknown session is retained for explicit same-actor recovery", "SPA page access checks once without hiding the admitted page",
async (page) => { async (page) => {
let releaseCreate!: () => void; let checks = 0;
const createGate = new Promise<void>((resolve) => { page.on("request", (request) => {
releaseCreate = resolve; if (new URL(request.url()).pathname === "/api/auth/whoami") {
checks++;
}
}); });
await page.goto(origin + "/dashboard?fixture=account");
await ready(page, "Account");
assertEquals(checks, 1);
let release!: () => void;
let started!: () => void; let started!: () => void;
const submitted = new Promise<void>((resolve) => { const held = new Promise<void>((resolve) => {
release = resolve;
});
const requested = new Promise<void>((resolve) => {
started = resolve; started = resolve;
}); });
await page.route("**/api/workspaces", async (route) => {
if (route.request().method() !== "POST") {
return route.continue();
}
started();
await createGate;
await route.fulfill({ json: creationResponse() });
});
const document = await page.goto(
`${origin}/dashboard?fixture=account#workspace-create-title`,
);
await page.getByLabel("Workspace display name").fill(
"Confirmed result during recheck",
);
await page.getByRole("button", {
name: "Create Workspace",
exact: true,
}).click();
await submitted;
let releaseSession!: () => void;
const sessionGate = new Promise<void>((resolve) => {
releaseSession = resolve;
});
await page.route("**/api/auth/whoami", async (route) => { await page.route("**/api/auth/whoami", async (route) => {
await sessionGate; started();
await route.fulfill({ await held;
json: { await route.continue();
actor: {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
},
session: {
kind: "authenticated",
expires_at: "2099-01-01T00:00:00Z",
},
},
});
}); });
await page.evaluate(() => try {
window.dispatchEvent(new Event("focus")) await page.getByRole("link", { name: "Account", exact: true })
); .click();
await page.getByRole("status").filter({ await requested;
hasText: "Loading session…", assertEquals(
}) await page.getByRole("heading", {
.waitFor(); name: "Dashboard",
const completed = page.waitForResponse((response) => exact: true,
response.request().method() === "POST" && }).isVisible(),
new URL(response.url()).pathname === "/api/workspaces" true,
); );
releaseCreate(); assertEquals(
await (await completed).finished(); await page.getByText("Loading session…").count(),
// HTTP完了後のbrowser task/render境界まで進め、session不明中には遷移しないことを観測する。 0,
await page.evaluate(() => );
new Promise<void>((resolve) => release();
requestAnimationFrame(() => resolve())
)
);
assertEquals(new URL(page.url()).pathname, "/dashboard");
// 確認中のsubtreeは副作用の結果を保持したままhidden/inertになる。
assertEquals(
await page.getByLabel("Workspace display name").isVisible(),
false,
);
releaseSession();
const link = page.getByRole("link", {
name: "Open created Workspace",
exact: true,
});
await link.waitFor();
assertEquals(await link.getAttribute("href"), "/w/home-empty");
assertEquals(
await page.getByRole("button", { await page.getByRole("button", {
name: "Create Workspace", name: "Add passkey to alice",
exact: true, exact: true,
}).count(), }).waitFor();
0, assertEquals(new URL(page.url()).pathname, "/account");
); assertEquals(checks, 2);
const folder = } finally {
`${root}/target/web-ux/t767-validation/entry-browser/confirmed-${colorScheme}-${width}`; release();
await Deno.mkdir(folder, { recursive: true }); }
const bytes = await page.screenshot({
path: folder + "/viewport.png",
fullPage: true,
});
const hash = Array.from(
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,
args: ["rev-parse", "HEAD"],
stdout: "piped",
}).output();
const dirty = await new Deno.Command("git", {
cwd: root,
args: ["status", "--porcelain"],
stdout: "piped",
}).output();
await Deno.writeTextFile(
folder + "/accessibility.txt",
await page.locator("body").ariaSnapshot(),
);
await Deno.writeTextFile(
folder + "/review-context.json",
JSON.stringify(
{
scenario:
"production-browser confirmed result after session recheck",
boundary: "synthetic HTTP; not real Server authentication",
source: {
commitSha: new TextDecoder().decode(source.stdout).trim(),
dirty:
new TextDecoder().decode(dirty.stdout).trim() !== "",
},
document: {
path: new URL(page.url()).pathname,
status: document?.status(),
},
colorScheme,
viewport: { width, height: 900 },
screenshot: { path: "viewport.png", sha256: hash },
visibleAlerts: await page.getByRole("alert")
.allTextContents(),
accessibility: "accessibility.txt",
expectedHttpErrors: [],
},
null,
2,
),
);
}, },
); );
for (const mode of ["creation-denied", "creation-error"]) { for (const mode of ["creation-denied", "creation-error"]) {
@@ -708,13 +621,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true, exact: true,
}).click(); }).click();
await page.getByRole("alert").waitFor(); await page.getByRole("alert").waitFor();
const rechecked = page.waitForResponse((response) => await focusWithoutSessionCheck(page);
new URL(response.url()).pathname === "/api/auth/whoami"
);
await page.evaluate(() =>
window.dispatchEvent(new Event("focus"))
);
await rechecked;
await page.getByRole("button", { await page.getByRole("button", {
name: "Retry creation", name: "Retry creation",
exact: true, exact: true,
@@ -230,7 +230,7 @@ export async function sessionNavigation(
for (const active of [false, true]) { for (const active of [false, true]) {
await step( await step(
`real HTTP Max-Age cookie disappears and ${ `real HTTP Max-Age cookie disappears and ${
active ? "active expiry" : "reload" active ? "next page access after expiry" : "reload"
} returns through login`, } returns through login`,
async ({ page, go }) => { async ({ page, go }) => {
const target = "/account?tab=keys#after-cookie-expiry"; const target = "/account?tab=keys#after-cookie-expiry";
@@ -247,11 +247,14 @@ export async function sessionNavigation(
assert(cookie?.httpOnly); assert(cookie?.httpOnly);
assert(cookie.expires > Date.now() / 1000); assert(cookie.expires > Date.now() / 1000);
if (active) { if (active) {
// whoamiのexpires_atを製品timerへ渡す。テスト側clockの前進やcookie削除はしない。 // 明示的な操作で期限を取得する。期限の経過だけでは再確認しない。
const refreshed = page.waitForResponse((r) => const refreshed = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/whoami" new URL(r.url()).pathname === "/api/auth/whoami"
); );
await focus(page); await page.getByRole("button", {
name: "Refresh session",
exact: true,
}).click();
const wire = await (await refreshed).json(); const wire = await (await refreshed).json();
assertEquals(wire.session.kind, "authenticated"); assertEquals(wire.session.kind, "authenticated");
assertEquals( assertEquals(
@@ -283,7 +286,17 @@ export async function sessionNavigation(
c.name === "auth-fixture" c.name === "auth-fixture"
), ),
); );
if (!active) await page.reload(); if (active) {
await at(page, target);
assertEquals(await page.getByText("Loading session…").count(), 0);
await page.getByRole("link", { name: "Dashboard / Workspaces" })
.click();
await at(page, loginTarget("/dashboard"));
await login(page);
await at(page, "/dashboard");
return;
}
await page.reload();
await at(page, loginTarget(target)); await at(page, loginTarget(target));
await page.reload(); await page.reload();
await login(page); await login(page);
@@ -296,7 +309,7 @@ export async function sessionNavigation(
); );
} }
await step( await step(
"concurrent API 401 and repeated session checks cannot overwrite the original expired return URL", "concurrent API 401 leaves the page alone; the next page access detects expiry",
async ({ page, go }) => { async ({ page, go }) => {
const target = "/w/private/tickets?view=thread#concurrent-expiry"; const target = "/w/private/tickets?view=thread#concurrent-expiry";
let release!: () => void; let release!: () => void;
@@ -322,16 +335,15 @@ export async function sessionNavigation(
await requests; await requests;
await control(page, "expire"); await control(page, "expire");
release(); release();
// focus等の外部契機なしに、同時401がwhoami再照会へ接続すること。 // API failure stays on this page; only the next page access checks whoami.
await at(page, loginTarget(target)); await page.getByRole("alert").filter({
hasText: /request failed with HTTP 401/,
}).waitFor();
assert(statuses.length >= 2); assert(statuses.length >= 2);
for (let i = 0; i < 2; i++) { await at(page, target);
const response = page.waitForResponse((r) => await focus(page);
new URL(r.url()).pathname === "/api/auth/whoami" assertEquals(await page.getByText("Loading session…").count(), 0);
); await page.reload();
await focus(page);
await (await response).finished();
}
await at(page, loginTarget(target)); await at(page, loginTarget(target));
await page.unroute("**/api/w/private/**"); await page.unroute("**/api/w/private/**");
await login(page); await login(page);
@@ -375,7 +387,8 @@ export async function sessionNavigation(
const observed = page.waitForRequest((r) => const observed = page.waitForRequest((r) =>
new URL(r.url()).pathname === "/api/auth/whoami" new URL(r.url()).pathname === "/api/auth/whoami"
); );
await focus(page); await page.getByRole("button", { name: "Refresh session", exact: true })
.click();
await observed; await observed;
await requested; await requested;
// 他tabの本物の製品logoutでgeneration/storage eventも通す。 // 他tabの本物の製品logoutでgeneration/storage eventも通す。
@@ -462,9 +475,9 @@ export async function sessionNavigation(
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false); assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
}, },
); );
for (const event of ["pageshow", "visibilitychange"] as const) { for (const event of ["focus", "pageshow", "visibilitychange"] as const) {
await step( await step(
`${event} rechecks active session and preserves expired return URL`, `${event} leaves the page visible; reload detects expiry`,
async ({ page, go }) => { async ({ page, go }) => {
const target = "/account?tab=keys#lifecycle"; const target = "/account?tab=keys#lifecycle";
await go(target, "account"); await go(target, "account");
@@ -473,6 +486,10 @@ export async function sessionNavigation(
exact: true, exact: true,
}).waitFor(); }).waitFor();
await control(page, "expire"); await control(page, "expire");
let checks = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/auth/whoami") checks++;
});
await page.evaluate((event) => { await page.evaluate((event) => {
if (event === "visibilitychange") { if (event === "visibilitychange") {
if (document.hidden) { if (document.hidden) {
@@ -481,6 +498,15 @@ export async function sessionNavigation(
document.dispatchEvent(new Event(event)); document.dispatchEvent(new Event(event));
} else window.dispatchEvent(new Event(event)); } else window.dispatchEvent(new Event(event));
}, event); }, event);
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
assertEquals(checks, 0);
await at(page, target);
assertEquals(await page.getByText("Loading session…").count(), 0);
await page.reload();
await at(page, loginTarget(target)); await at(page, loginTarget(target));
}, },
); );
@@ -557,7 +583,7 @@ export async function sessionNavigation(
} }
}, },
); );
for (const status of [403, 404]) { for (const status of [401, 403, 404]) {
await step( await step(
`post-login resource ${status} remains a resource failure, never a relogin loop`, `post-login resource ${status} remains a resource failure, never a relogin loop`,
async ({ page, go }) => { async ({ page, go }) => {