fix: stop background session checks and loading flicker
This commit is contained in:
@@ -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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user