fix: retain workspace creation state across session rechecks
This commit is contained in:
@@ -5,15 +5,30 @@
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
||||
import WorkspaceCatalog from './WorkspaceCatalog.svelte';
|
||||
import { WorkspaceCreation } from './workspace-creation.svelte.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
const auth = new BrowserAuth();
|
||||
let creation = $state<WorkspaceCreation | null>(null);
|
||||
let mounted = false;
|
||||
$effect(() => {
|
||||
if (auth.session.kind !== 'ready') return;
|
||||
const actor = auth.session.actor;
|
||||
if (!actor) { creation = null; return; }
|
||||
if (creation?.accountId !== actor.account_id) {
|
||||
const draft: WorkspaceCreation = new WorkspaceCreation(actor.account_id, () =>
|
||||
mounted && creation === draft && auth.actor?.account_id === draft.accountId);
|
||||
creation = draft;
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const refresh = () => { void auth.refresh(); };
|
||||
refresh();
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
mounted = false;
|
||||
auth.dispose();
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
@@ -33,7 +48,9 @@
|
||||
{:else if auth.actor}
|
||||
{#key auth.actor.account_id}
|
||||
<AccountNavigation />
|
||||
<WorkspaceCatalog />
|
||||
{#if creation?.accountId === auth.actor.account_id}
|
||||
<WorkspaceCatalog {creation} />
|
||||
{/if}
|
||||
{/key}
|
||||
{:else}
|
||||
<p>Log in to select or create a Workspace.</p>
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
createOperationKey,
|
||||
createWorkspace,
|
||||
creationErrorMessage,
|
||||
loadWorkspaceCatalog,
|
||||
type CreateWorkspaceRequest,
|
||||
type WorkspaceCatalogItem,
|
||||
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||
|
||||
import type { WorkspaceCreation } from "./workspace-creation.svelte.ts";
|
||||
let { creation }: { creation: WorkspaceCreation } = $props();
|
||||
let disposed = false;
|
||||
let request = 0;
|
||||
let createOpen = $state(false);
|
||||
let workspaces = $state<WorkspaceCatalogItem[]>([]);
|
||||
let catalogError = $state<string | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let creating = $state(false);
|
||||
let creationError = $state<string | null>(null);
|
||||
let displayName = $state("");
|
||||
let includeRepository = $state(false);
|
||||
let repositoryKey = $state("");
|
||||
let repositoryUri = $state("");
|
||||
let defaultRef = $state("");
|
||||
let lastSubmission = $state<{
|
||||
signature: string;
|
||||
request: CreateWorkspaceRequest;
|
||||
} | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
createOpen = window.location.hash === "#workspace-create-title";
|
||||
const hashChanged = () => { if (window.location.hash === "#workspace-create-title") createOpen = true; };
|
||||
if (window.location.hash === "#workspace-create-title") creation.open = true;
|
||||
const hashChanged = () => { if (window.location.hash === "#workspace-create-title") creation.open = true; };
|
||||
window.addEventListener("hashchange", hashChanged);
|
||||
void refreshCatalog();
|
||||
return () => { disposed = true; ++request; window.removeEventListener("hashchange", hashChanged); };
|
||||
@@ -51,33 +36,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreation(event: SubmitEvent) {
|
||||
function submitCreation(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (creating || disposed) return;
|
||||
const normalized = {
|
||||
display_name: displayName.trim(),
|
||||
repository: includeRepository ? {
|
||||
repository_key: repositoryKey.trim(),
|
||||
uri: repositoryUri.trim(),
|
||||
default_ref: defaultRef.trim() || null,
|
||||
} : null,
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = lastSubmission?.signature === signature
|
||||
? lastSubmission.request
|
||||
: { operation_key: createOperationKey(), ...normalized };
|
||||
lastSubmission = { signature, request };
|
||||
creating = true;
|
||||
creationError = null;
|
||||
try {
|
||||
const response = await createWorkspace(fetch, request);
|
||||
if (disposed) return;
|
||||
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
|
||||
} catch (error) {
|
||||
if (!disposed) creationError = creationErrorMessage(error);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
void creation.submit();
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
@@ -151,49 +112,54 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<details class="workspace-create-panel" bind:open={createOpen}>
|
||||
<details class="workspace-create-panel" bind:open={creation.open}>
|
||||
<summary id="workspace-create-title">Create Workspace</summary>
|
||||
<div class="workspace-create-content">
|
||||
<div>
|
||||
<p>You can add repositories later in Workspace Settings.</p>
|
||||
</div>
|
||||
{#if creation.createdWorkspaceId}
|
||||
<p role="status">Workspace creation confirmed.</p>
|
||||
<a href={`/w/${encodeURIComponent(creation.createdWorkspaceId)}`}>Open created Workspace</a>
|
||||
{:else}
|
||||
<form onsubmit={submitCreation}>
|
||||
<label>
|
||||
Workspace display name
|
||||
<input bind:value={displayName} required autocomplete="off" />
|
||||
<input bind:value={creation.displayName} required autocomplete="off" />
|
||||
</label>
|
||||
<label class="workspace-repository-toggle">
|
||||
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
|
||||
<input type="checkbox" bind:checked={creation.includeRepository} aria-controls={creation.includeRepository ? "initial-repository-fields" : undefined} />
|
||||
Add an initial repository (optional)
|
||||
</label>
|
||||
{#if includeRepository}
|
||||
{#if creation.includeRepository}
|
||||
<div id="initial-repository-fields" class="workspace-repository-fields">
|
||||
<p>
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||
<input bind:value={creation.repositoryUri} required autocomplete="off" />
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository key
|
||||
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
<input bind:value={creation.repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
<input bind:value={creation.defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if creationError}
|
||||
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||
{#if creation.error}
|
||||
<div class="workspace-catalog-alert" role="alert">{creation.error}</div>
|
||||
{/if}
|
||||
<button class="workspace-primary-action" type="submit" disabled={creating}>
|
||||
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
|
||||
<button class="workspace-primary-action" type="submit" disabled={creation.creating}>
|
||||
{creation.creating ? "Creating…" : creation.error ? "Retry creation" : "Create Workspace"}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
createOperationKey,
|
||||
createWorkspace,
|
||||
type CreateWorkspaceRequest,
|
||||
creationErrorMessage,
|
||||
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||
|
||||
// 同一actorのsession再確認はformの寿命ではない。これは権限のcacheではなく、
|
||||
// dashboard内だけに保持する下書き・要求・確定結果。表示/操作/遷移は毎回live sessionで遮断する。
|
||||
export class WorkspaceCreation {
|
||||
open = $state(false);
|
||||
displayName = $state("");
|
||||
includeRepository = $state(false);
|
||||
repositoryKey = $state("");
|
||||
repositoryUri = $state("");
|
||||
defaultRef = $state("");
|
||||
creating = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
createdWorkspaceId = $state<string | null>(null);
|
||||
private lastSubmission:
|
||||
| { signature: string; request: CreateWorkspaceRequest }
|
||||
| null = null;
|
||||
|
||||
constructor(readonly accountId: string, private canOperate: () => boolean) {}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (this.creating || this.createdWorkspaceId || !this.canOperate()) return;
|
||||
const normalized = {
|
||||
display_name: this.displayName.trim(),
|
||||
repository: this.includeRepository
|
||||
? {
|
||||
repository_key: this.repositoryKey.trim(),
|
||||
uri: this.repositoryUri.trim(),
|
||||
default_ref: this.defaultRef.trim() || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = this.lastSubmission?.signature === signature
|
||||
? this.lastSubmission.request
|
||||
: { operation_key: createOperationKey(), ...normalized };
|
||||
this.lastSubmission = { signature, request };
|
||||
this.creating = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await createWorkspace(fetch, request);
|
||||
this.createdWorkspaceId = response.workspace.workspace_id;
|
||||
} catch (error) {
|
||||
this.error = creationErrorMessage(error);
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
// 再確認中の成功を匿名/別actorの遷移にしない。同一actor復帰後は確定結果への明示linkを出す。
|
||||
if (this.createdWorkspaceId && this.canOperate()) {
|
||||
try {
|
||||
await goto(`/w/${encodeURIComponent(this.createdWorkspaceId)}`);
|
||||
} catch {
|
||||
/* 作成成功は確定済み。遷移失敗でも新しい作成要求には変えない。 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ function deferred<T>() {
|
||||
function boundary() {
|
||||
const state = {
|
||||
signedIn: false,
|
||||
accountId: authUser.account_id,
|
||||
creationGate: null as ReturnType<typeof deferred<Response>> | null,
|
||||
whoamiStatus: 200,
|
||||
policy: true as boolean | "error" | "loading",
|
||||
admin: false,
|
||||
@@ -50,7 +52,11 @@ function boundary() {
|
||||
return state.whoamiStatus === 200
|
||||
? Response.json({
|
||||
actor: state.signedIn
|
||||
? { ...authUser, auth_method: "browser_session" }
|
||||
? {
|
||||
...authUser,
|
||||
account_id: state.accountId,
|
||||
auth_method: "browser_session",
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: new Response(null, { status: state.whoamiStatus });
|
||||
@@ -65,6 +71,7 @@ function boundary() {
|
||||
return Response.json({ is_admin: state.admin });
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
if (state.creationGate) return state.creationGate.promise;
|
||||
return state.createStatus === 200
|
||||
? Response.json(creationResponse())
|
||||
: new Response(null, { status: state.createStatus });
|
||||
@@ -238,3 +245,145 @@ test("dashboard does not contact catalog for anonymous or unknown session", asyn
|
||||
await screen.findByRole("alert");
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
async function openCreation() {
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "Retained draft" },
|
||||
});
|
||||
}
|
||||
const submitCreation = async (name = "Create Workspace") => {
|
||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
||||
};
|
||||
const creationBodies = (fetcher: ReturnType<typeof boundary>["fetcher"]) =>
|
||||
fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
|
||||
[, init],
|
||||
) => JSON.parse(String(init!.body)));
|
||||
|
||||
test("same actor focus retains creation disclosure, repository fields, errors and exact retry key through unknown session", async () => {
|
||||
const { state, fetcher } = boundary();
|
||||
state.signedIn = true;
|
||||
state.createStatus = 503;
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
}),
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Repository key"), {
|
||||
target: { value: "main" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("Initial repository absolute path or URI"),
|
||||
{ target: { value: "/srv/main" } },
|
||||
);
|
||||
await submitCreation();
|
||||
await screen.findByRole("alert");
|
||||
state.sessionGate = deferred<Response>();
|
||||
await fireEvent(window, new Event("focus"));
|
||||
await screen.findByText("Loading session…");
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Retained draft");
|
||||
expect((screen.getByLabelText("Repository key") as HTMLInputElement).value)
|
||||
.toBe("main");
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"Initial repository absolute path or URI",
|
||||
) as HTMLInputElement).value,
|
||||
).toBe("/srv/main");
|
||||
await submitCreation("Retry creation");
|
||||
await screen.findByRole("alert");
|
||||
expect(creationBodies(fetcher)).toHaveLength(2);
|
||||
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 () => {
|
||||
const { state, fetcher } = boundary();
|
||||
state.signedIn = true;
|
||||
state.creationGate = deferred<Response>();
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await submitCreation();
|
||||
await screen.findByRole("button", { name: "Creating…" });
|
||||
state.sessionGate = deferred<Response>();
|
||||
await fireEvent(window, new Event("focus"));
|
||||
await screen.findByText("Loading session…");
|
||||
const response = Response.json(creationResponse());
|
||||
state.creationGate.resolve(response);
|
||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Open created Workspace" }))
|
||||
.getAttribute("href"),
|
||||
).toBe("/w/home-empty");
|
||||
expect(screen.queryByRole("button", { name: "Create Workspace" })).toBeNull();
|
||||
expect(creationBodies(fetcher)).toHaveLength(1);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("same actor session error retains draft privately until retry; confirmed different actor gets a fresh draft", async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
state.whoamiStatus = 503;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
state.whoamiStatus = 200;
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
|
||||
expect(
|
||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Retained draft");
|
||||
state.accountId = "different-account";
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("Workspace display name") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("late creation result after confirmed logout or actor change never navigates another actor", async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.creationGate = deferred<Response>();
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await submitCreation();
|
||||
await screen.findByRole("button", { name: "Creating…" });
|
||||
state.signedIn = false;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByRole("link", { name: "Log in" });
|
||||
const response = Response.json(creationResponse());
|
||||
state.creationGate.resolve(response);
|
||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.signedIn = true;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
expect(screen.queryByRole("link", { name: "Open created Workspace" }))
|
||||
.toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("Workspace display name") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { goto } from "$app/navigation";
|
||||
import Catalog from "./dashboard/WorkspaceCatalog.svelte";
|
||||
import { WorkspaceCreation } from "./dashboard/workspace-creation.svelte.ts";
|
||||
import {
|
||||
createdWorkspace,
|
||||
creationResponse,
|
||||
@@ -33,7 +34,9 @@ async function mount() {
|
||||
},
|
||||
);
|
||||
});
|
||||
const view = render(Catalog);
|
||||
const view = render(Catalog, {
|
||||
creation: new WorkspaceCreation("fixture-owner", () => true),
|
||||
});
|
||||
await screen.findByRole("link", { name: /New Workspace.*No repositories/ });
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
@@ -300,6 +301,203 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"pending creation survives same-actor focus without a second POST",
|
||||
async (page) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const submitted = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let posts = 0;
|
||||
await page.route("**/api/workspaces", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return route.continue();
|
||||
}
|
||||
posts++;
|
||||
started();
|
||||
await held;
|
||||
await route.fulfill({ json: creationResponse() });
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/dashboard?fixture=account#workspace-create-title`,
|
||||
);
|
||||
await page.getByLabel("Workspace display name").fill(
|
||||
"Pending draft",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await submitted;
|
||||
const checked = page.waitForResponse((response) =>
|
||||
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 })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Creating…",
|
||||
exact: true,
|
||||
}).isDisabled(),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").inputValue(),
|
||||
"Pending draft",
|
||||
);
|
||||
assertEquals(posts, 1);
|
||||
release();
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
await page.locator(".workspace-home").waitFor();
|
||||
assertEquals(posts, 1);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"success during unknown session is retained for explicit same-actor recovery",
|
||||
async (page) => {
|
||||
let releaseCreate!: () => void;
|
||||
const createGate = new Promise<void>((resolve) => {
|
||||
releaseCreate = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const submitted = new Promise<void>((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 sessionGate;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
actor: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
.waitFor();
|
||||
const completed = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === "/api/workspaces"
|
||||
);
|
||||
releaseCreate();
|
||||
await (await completed).finished();
|
||||
// HTTP完了後のbrowser task/render境界まで進め、session不明中には遷移しないことを観測する。
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").count(),
|
||||
0,
|
||||
);
|
||||
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", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
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"]) {
|
||||
await step(
|
||||
`explicit creation ${mode} retains input and exact retry operation`,
|
||||
@@ -321,6 +519,21 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
const rechecked = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await rechecked;
|
||||
await page.getByRole("button", {
|
||||
name: "Retry creation",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").inputValue(),
|
||||
"New Workspace",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Retry creation",
|
||||
exact: true,
|
||||
|
||||
Reference in New Issue
Block a user