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 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 {
/* 作成成功は確定済み。遷移失敗でも新しい作成要求には変えない。 */
}
}
}
}
+150 -1
View File
@@ -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,