fix: retain workspace creation state across session rechecks

This commit is contained in:
2026-10-16 11:08:27 +09:00
parent 24c6f772f7
commit d74660d1d1
6 changed files with 470 additions and 59 deletions
@@ -5,15 +5,30 @@
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';
import { WorkspaceCreation } from './workspace-creation.svelte.ts';
import '#lib/workspace/auth/auth-page.css'; import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth(); 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(() => { onMount(() => {
mounted = true;
const refresh = () => { void auth.refresh(); }; const refresh = () => { void auth.refresh(); };
refresh(); refresh();
window.addEventListener('focus', refresh); window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh); window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => { return () => {
mounted = false;
auth.dispose(); auth.dispose();
window.removeEventListener('focus', refresh); window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh); window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
@@ -33,7 +48,9 @@
{:else if auth.actor} {:else if auth.actor}
{#key auth.actor.account_id} {#key auth.actor.account_id}
<AccountNavigation /> <AccountNavigation />
<WorkspaceCatalog /> {#if creation?.accountId === auth.actor.account_id}
<WorkspaceCatalog {creation} />
{/if}
{/key} {/key}
{:else} {:else}
<p>Log in to select or create a Workspace.</p> <p>Log in to select or create a Workspace.</p>
@@ -1,36 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { import {
createOperationKey,
createWorkspace,
creationErrorMessage,
loadWorkspaceCatalog, loadWorkspaceCatalog,
type CreateWorkspaceRequest,
type WorkspaceCatalogItem, type WorkspaceCatalogItem,
} from "#lib/workspace/api/workspace-catalog.ts"; } from "#lib/workspace/api/workspace-catalog.ts";
import type { WorkspaceCreation } from "./workspace-creation.svelte.ts";
let { creation }: { creation: WorkspaceCreation } = $props();
let disposed = false; let disposed = false;
let request = 0; let request = 0;
let createOpen = $state(false);
let workspaces = $state<WorkspaceCatalogItem[]>([]); let workspaces = $state<WorkspaceCatalogItem[]>([]);
let catalogError = $state<string | null>(null); let catalogError = $state<string | null>(null);
let refreshing = $state(false); 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(() => { onMount(() => {
createOpen = window.location.hash === "#workspace-create-title"; if (window.location.hash === "#workspace-create-title") creation.open = true;
const hashChanged = () => { if (window.location.hash === "#workspace-create-title") createOpen = true; }; const hashChanged = () => { if (window.location.hash === "#workspace-create-title") creation.open = true; };
window.addEventListener("hashchange", hashChanged); window.addEventListener("hashchange", hashChanged);
void refreshCatalog(); void refreshCatalog();
return () => { disposed = true; ++request; window.removeEventListener("hashchange", hashChanged); }; return () => { disposed = true; ++request; window.removeEventListener("hashchange", hashChanged); };
@@ -51,33 +36,9 @@
} }
} }
async function submitCreation(event: SubmitEvent) { function submitCreation(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (creating || disposed) return; void creation.submit();
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;
}
} }
function formatUpdated(value: string): string { function formatUpdated(value: string): string {
@@ -151,49 +112,54 @@
{/if} {/if}
</section> </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> <summary id="workspace-create-title">Create Workspace</summary>
<div class="workspace-create-content"> <div class="workspace-create-content">
<div> <div>
<p>You can add repositories later in Workspace Settings.</p> <p>You can add repositories later in Workspace Settings.</p>
</div> </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}> <form onsubmit={submitCreation}>
<label> <label>
Workspace display name Workspace display name
<input bind:value={displayName} required autocomplete="off" /> <input bind:value={creation.displayName} required autocomplete="off" />
</label> </label>
<label class="workspace-repository-toggle"> <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) Add an initial repository (optional)
</label> </label>
{#if includeRepository} {#if creation.includeRepository}
<div id="initial-repository-fields" class="workspace-repository-fields"> <div id="initial-repository-fields" class="workspace-repository-fields">
<p> <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. 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> </p>
<label> <label>
Initial repository absolute path or URI Initial repository absolute path or URI
<input bind:value={repositoryUri} required autocomplete="off" /> <input bind:value={creation.repositoryUri} required autocomplete="off" />
</label> </label>
<div class="workspace-create-row"> <div class="workspace-create-row">
<label> <label>
Repository key 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>
<label> <label>
Default ref Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" /> <input bind:value={creation.defaultRef} placeholder="repository default" autocomplete="off" />
</label> </label>
</div> </div>
</div> </div>
{/if} {/if}
{#if creationError} {#if creation.error}
<div class="workspace-catalog-alert" role="alert">{creationError}</div> <div class="workspace-catalog-alert" role="alert">{creation.error}</div>
{/if} {/if}
<button class="workspace-primary-action" type="submit" disabled={creating}> <button class="workspace-primary-action" type="submit" disabled={creation.creating}>
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"} {creation.creating ? "Creating…" : creation.error ? "Retry creation" : "Create Workspace"}
</button> </button>
</form> </form>
{/if}
</div> </div>
</details> </details>
</div> </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 {
/* 作成成功は確定済み。遷移失敗でも新しい作成要求には変えない。 */
}
}
}
}
+150 -1
View File
@@ -33,6 +33,8 @@ function deferred<T>() {
function boundary() { function boundary() {
const state = { const state = {
signedIn: false, signedIn: false,
accountId: authUser.account_id,
creationGate: null as ReturnType<typeof deferred<Response>> | null,
whoamiStatus: 200, whoamiStatus: 200,
policy: true as boolean | "error" | "loading", policy: true as boolean | "error" | "loading",
admin: false, admin: false,
@@ -50,7 +52,11 @@ function boundary() {
return state.whoamiStatus === 200 return state.whoamiStatus === 200
? Response.json({ ? Response.json({
actor: state.signedIn actor: state.signedIn
? { ...authUser, auth_method: "browser_session" } ? {
...authUser,
account_id: state.accountId,
auth_method: "browser_session",
}
: null, : null,
}) })
: new Response(null, { status: state.whoamiStatus }); : new Response(null, { status: state.whoamiStatus });
@@ -65,6 +71,7 @@ function boundary() {
return Response.json({ is_admin: state.admin }); return Response.json({ is_admin: state.admin });
} }
if (init?.method === "POST") { if (init?.method === "POST") {
if (state.creationGate) return state.creationGate.promise;
return state.createStatus === 200 return state.createStatus === 200
? Response.json(creationResponse()) ? Response.json(creationResponse())
: new Response(null, { status: state.createStatus }); : 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"); await screen.findByRole("alert");
expect(workspaceCalls()).toEqual([]); 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 { afterEach, expect, test, vi } from "vitest";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import Catalog from "./dashboard/WorkspaceCatalog.svelte"; import Catalog from "./dashboard/WorkspaceCatalog.svelte";
import { WorkspaceCreation } from "./dashboard/workspace-creation.svelte.ts";
import { import {
createdWorkspace, createdWorkspace,
creationResponse, 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 screen.findByRole("link", { name: /New Workspace.*No repositories/ });
await fireEvent.click( await fireEvent.click(
screen.getByText("Create Workspace", { selector: "summary" }), screen.getByText("Create Workspace", { selector: "summary" }),
@@ -3,6 +3,7 @@
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
import { resolve } from "@std/path"; import { resolve } from "@std/path";
import { chromium, type Page } from "playwright"; import { chromium, type Page } from "playwright";
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
import { import {
startOwnedProcesses, startOwnedProcesses,
stopOwnedProcesses, 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"]) { for (const mode of ["creation-denied", "creation-error"]) {
await step( await step(
`explicit creation ${mode} retains input and exact retry operation`, `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, exact: true,
}).click(); }).click();
await page.getByRole("alert").waitFor(); 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", { await page.getByRole("button", {
name: "Retry creation", name: "Retry creation",
exact: true, exact: true,