chore: merge approved optional workspace creation into develop

This commit is contained in:
2026-10-08 19:58:04 +09:00
38 changed files with 2407 additions and 354 deletions
+1
View File
@@ -8,6 +8,7 @@
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -34,7 +34,7 @@ export type WorkspaceRepositoryRecord = {
export type WorkspaceCreateResponse = {
workspace: WorkspaceSummary;
repository: WorkspaceRepositoryRecord;
repository: WorkspaceRepositoryRecord | null;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:ab4b109d99077a8b5af98923b77d8fc1b707f7ee9ad08304e26d497ccb3799f6)
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -0,0 +1,33 @@
export const createdWorkspace = {
workspace_id: "home-empty",
owner_account_id: "fixture-owner",
display_name: "New Workspace",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
export const currentInitialRepository = {
workspace_id: createdWorkspace.workspace_id,
repository_key: "platform",
kind: "git",
provider: "git",
source: { kind: "https", uri: "https://example.test/current.git" },
default_ref: "main",
source_revision: 2,
source_fingerprint: "sha256:current",
observed_status: "unverified",
observed_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
export function creationResponse(repository: unknown = null, replayed = false) {
return {
workspace: createdWorkspace,
repository,
config_revision: 1,
request_fingerprint: "sha256:creation",
replayed,
};
}
@@ -17,11 +17,11 @@ export type CreateWorkspaceResponse = WorkspaceCreateResponse;
export type CreateWorkspaceRequest = {
operation_key: string;
display_name: string;
repository: {
repository?: {
repository_key: string;
uri: string;
default_ref: string | null;
};
} | null;
};
export class WorkspaceCatalogError extends Error {
@@ -569,7 +569,7 @@ export function parseWorkspaceCreateResponse(
response.workspace,
"workspace create response.workspace",
),
repository: workspaceRepositoryRecord(
repository: response.repository === null ? null : workspaceRepositoryRecord(
response.repository,
"workspace create response.repository",
),
@@ -0,0 +1,39 @@
import type { WorkerLaunchOptionsResponse } from "./types.ts";
export function emptyLaunchOptions(
workspaceId: string,
): WorkerLaunchOptionsResponse {
return {
workspace_id: workspaceId,
runtimes: [
{
runtime_id: "embedded",
display_name: "Embedded",
status: "active",
worker_creation_available: true,
built_in: true,
working_directory_required: false,
diagnostics: [],
},
{
runtime_id: "remote",
display_name: "Remote",
status: "active",
worker_creation_available: true,
built_in: false,
working_directory_required: true,
diagnostics: [],
},
],
default_profile: "builtin:companion",
profiles: [{
id: "builtin:companion",
label: "Companion",
description: "Conversation",
feature_connections: { subjektiv: false },
}],
repositories: [],
working_directories: [],
diagnostics: [],
};
}
@@ -41,7 +41,7 @@
.workspace-catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
gap: 1rem;
margin-top: 1rem;
}
@@ -109,7 +109,7 @@
.workspace-create-panel {
padding: 1.5rem;
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
gap: 2rem;
}
@@ -136,6 +136,32 @@
font: inherit;
}
.workspace-create-panel .workspace-repository-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
}
.workspace-repository-fields {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.workspace-create-panel input:not([type="checkbox"]) {
width: 100%;
}
.workspace-create-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
}
.workspace-create-panel label {
min-width: 0;
}
.workspace-create-row > label {
flex: 1;
}
+33 -29
View File
@@ -17,6 +17,7 @@
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("");
@@ -46,23 +47,17 @@
event.preventDefault();
if (creating) return;
const normalized = {
displayName: displayName.trim(),
repositoryUri: repositoryUri.trim(),
repositoryKey: repositoryKey.trim(),
defaultRef: defaultRef.trim(),
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(),
display_name: normalized.displayName,
repository: {
repository_key: normalized.repositoryKey,
uri: normalized.repositoryUri,
default_ref: normalized.defaultRef || null,
},
};
: { operation_key: createOperationKey(), ...normalized };
lastSubmission = { signature, request };
creating = true;
creationError = null;
@@ -154,29 +149,38 @@
<div>
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<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>
<p>You can add repositories later in Workspace Settings.</p>
</div>
<form onsubmit={submitCreation}>
<label>
Workspace display name
<input bind:value={displayName} required autocomplete="off" />
</label>
<label>
Initial repository absolute path or URI
<input bind:value={repositoryUri} required autocomplete="off" />
<label class="workspace-repository-toggle">
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
Add an initial repository (optional)
</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" />
</label>
<label>
Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
</label>
</div>
{#if 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" />
</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" />
</label>
<label>
Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
</label>
</div>
</div>
{/if}
{#if creationError}
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
{/if}
@@ -383,6 +383,53 @@ test("deletion retry reuses the persisted operation id and confirmation, then re
.toBeNull();
});
test("zero-repository Workspace deletion follows authoritative preflight and exact-name confirmation", async () => {
const resources = {
workers: 0,
workdirs: 0,
repositories: 0,
runtime_bindings: 0,
secrets: 0,
artifacts: 0,
};
const api = mockApi(async (request) => {
if (request.url.includes("/workspace-deletions/")) {
return Response.json({
...operation("succeeded", request.url.split("/").pop()),
resources,
});
}
if (request.url.endsWith("/deletion")) {
return request.method === "POST"
? Response.json({
...operation("succeeded", (await request.json()).operation_id),
resources,
})
: Response.json({
...deletionFixture(),
resources,
can_delete: true,
blockers: [],
});
}
return null;
});
mount();
const confirmation = await openDeletion();
const remove = screen.getByRole("button", { name: "Delete Workspace" });
expect((remove as HTMLButtonElement).disabled).toBe(true);
await fireEvent.input(confirmation, {
target: { value: metadataFixture().display_name },
});
await fireEvent.click(remove);
await waitFor(() => expect(goto).toHaveBeenCalledWith("/"));
expect(api.mock.calls.filter(([, init]) => init?.method === "POST"))
.toHaveLength(1);
expect(
api.mock.calls.some(([path]) => String(path).includes("/repositories")),
).toBe(false);
});
test("reload resumes persisted deletion without sending another POST", async () => {
sessionStorage.setItem(
"yoi:workspace-deletion:home-owner",
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import NewWorker from "./+page.svelte";
function mount() {
const data = {
workspaceId: "empty",
repositories: {
workspace_id: "empty",
items: [],
source: "fixture",
diagnostics: [],
},
ticketContext: null,
} as unknown as ComponentProps<typeof NewWorker>["data"];
return render(NewWorker, {
props: { data, params: { workspaceId: "empty" } },
});
}
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
test("zero-repository Workspace launches a normal embedded Worker request without Workdir or Ticket assignment", async () => {
const api = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
String(input).endsWith("/launch-options")
? Response.json(emptyLaunchOptions("empty"))
: Response.json({
error: "unavailable",
message: "Retry later",
diagnostics: [],
}, { status: 503 })
);
vi.stubGlobal("fetch", api);
mount();
const start = await screen.findByRole("button", { name: "Start Worker" });
expect((start as HTMLButtonElement).disabled).toBe(false);
expect(
screen.getByText(
"No filesystem tools or Bash will be available without a Workdir.",
),
).toBeTruthy();
expect(screen.queryByLabelText("Attachment 1 Workdir")).toBeNull();
await fireEvent.click(start);
await waitFor(() => expect(api).toHaveBeenCalledTimes(2));
const body = JSON.parse(String(api.mock.calls[1][1]?.body));
expect(body).toMatchObject({
runtime_id: "embedded",
profile: "builtin:companion",
workdir_attachments: [],
ticket_assignment: null,
});
expect(body).not.toHaveProperty("repository_key");
});
test("zero-repository Workspace does not bypass a remote Runtime Workdir requirement", async () => {
const options = emptyLaunchOptions("empty");
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ ...options, runtimes: [...options.runtimes].reverse() })
),
);
mount();
await screen.findByRole("button", { name: "Start Worker" });
await screen.findByLabelText("Attachment 1 Workdir");
expect(
(screen.getByRole("button", { name: "Start Worker" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { goto } from "$app/navigation";
import Catalog from "./+page.svelte";
import {
createdWorkspace,
creationResponse,
} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function mount() {
return render(Catalog, {
props: {
data: {
accessibleWorkspaces: [createdWorkspace],
workspaceCatalogError: null,
workspaces: [{ ...createdWorkspace, repositories: [] }],
catalogError: null,
},
},
});
}
function submit() {
return fireEvent.submit(
screen.getByRole("button", { name: /Create Workspace|Retry creation/ })
.closest("form")!,
);
}
test("blank repository form creates and opens a Workspace using a null repository response", async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
Response.json(creationResponse()),
);
vi.stubGlobal("fetch", fetcher);
mount();
expect(
screen.getByRole("link", { name: /New Workspace.*No repositories/ })
.getAttribute("href"),
).toBe("/w/home-empty");
expect(screen.queryByLabelText("Repository key")).toBeNull();
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: " New Workspace " },
});
await submit();
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({
display_name: "New Workspace",
repository: null,
});
});
test("opting into a repository requires an explicit key and source and submits normalized values", async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
Response.json(creationResponse()),
);
vi.stubGlobal("fetch", fetcher);
mount();
await fireEvent.click(
screen.getByRole("checkbox", {
name: "Add an initial repository (optional)",
}),
);
const key = screen.getByLabelText("Repository key") as HTMLInputElement;
const uri = screen.getByLabelText(
"Initial repository absolute path or URI",
) as HTMLInputElement;
expect(key.required && uri.required).toBe(true);
expect(key.value).toBe("");
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: "New Workspace" },
});
await fireEvent.input(key, { target: { value: "platform" } });
await fireEvent.input(uri, { target: { value: " /srv/platform " } });
await fireEvent.input(screen.getByLabelText("Default ref"), {
target: { value: " main " },
});
await submit();
await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).repository).toEqual(
{ repository_key: "platform", uri: "/srv/platform", default_ref: "main" },
);
});
test("unchanged empty creation retries reuse the operation key and accept deleted-repository replay", async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({ message: "Temporary failure" }, { status: 503 }),
)
.mockResolvedValueOnce(Response.json(creationResponse(null, true)));
vi.stubGlobal("fetch", fetcher);
mount();
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: "New Workspace" },
});
await submit();
expect(await screen.findByRole("alert")).toBeTruthy();
await submit();
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
expect(
fetcher.mock.calls.map(([, init]) => JSON.parse(String(init?.body)))[0],
).toEqual(JSON.parse(String(fetcher.mock.calls[1][1]?.body)));
});
test("opting out ignores retained repository inputs and changes the creation operation", async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
Response.json({ message: "Temporary failure" }, { status: 503 })
);
vi.stubGlobal("fetch", fetcher);
mount();
const toggle = screen.getByRole("checkbox", {
name: "Add an initial repository (optional)",
});
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: "New Workspace" },
});
await fireEvent.click(toggle);
await fireEvent.input(screen.getByLabelText("Repository key"), {
target: { value: "platform" },
});
await fireEvent.input(
screen.getByLabelText("Initial repository absolute path or URI"),
{ target: { value: "/srv/platform" } },
);
await submit();
await screen.findByRole("alert");
await fireEvent.click(toggle);
await submit();
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
const bodies = fetcher.mock.calls.map(([, init]) =>
JSON.parse(String(init?.body))
);
expect(bodies[1].repository).toBeNull();
expect(bodies[1].operation_key).not.toBe(bodies[0].operation_key);
});
@@ -0,0 +1,93 @@
# Optional initial repository validation (T-717 Web portion)
These scenarios use the production static Workspace build and the existing
`tools/web-ux` capture/process harness. The loopback proxy only extends the shared
Home fixture's API responses for empty Worker lists and launch options; it does
not replace the application shell, styles, or components. No real Workspace,
account, repository, Runtime, or Worker is created.
## Run
From `web/workspace`, with the repository's pinned Chromium available:
```sh
deno task build
deno task test:browser:empty-workspace
```
The browser test covers light/dark at 1440, 768, 390, and 320px: optional Git
native validation, opting out after entering repository values, null creation
response/navigation, zero-repository catalog reopening, normal embedded Worker
submission without Workdir or Ticket assignment, and remote Runtime Workdir
requirements. It uses dynamically allocated loopback ports and the existing
owned-process cleanup harness. Screenshots and logs are ignored artifacts under
`.svelte-kit/t717/browser`.
From `tools/web-ux`:
```sh
deno task web-ux capture \
--scenario ../../web/workspace/test/visual-review/optional-repository.json \
--output ../../web/workspace/.svelte-kit/t717/visual --run-id empty-workspace
deno task web-ux capture \
--scenario ../../web/workspace/test/visual-review/optional-repository-interactions.json \
--output ../../web/workspace/.svelte-kit/t717/visual --run-id creation-interactions
```
The interaction scenario intentionally rejects creation with HTTP400. Capture
exit status 2 and the recorded request/console/visible validation errors are
expected for `validation-error`, not evidence of a successful creation. No other
capture error is expected. To repeat dark captures, use a scenario copy with
`colorScheme: "dark"`, preserving the process cwd paths.
## Implementer-owned visual review
- Baseline revision: `e939cd4e4b1f6b63b81411fd7766062f2d69ad0d`.
- Before: `.svelte-kit/t717/visual/before-final-light` and `before-dark`.
These use an isolated build under `.svelte-kit/t717/baseline-source`, with the
changed production form, catalog API, model, and CSS restored from baseline
Git source. The original pre-edit light capture is also retained as
`.svelte-kit/t717/visual/before-valid`.
- After: `.svelte-kit/t717/visual/final-light`, `final-dark`,
`final-interactions-light`, and `final-interactions-dark`.
- Comparisons: `.svelte-kit/t717/visual/final-comparison-light` and
`final-comparison-dark`.
- Personas: synthetic owner and non-owner permission projections; catalog
creation/reopening uses the same synthetic account.
- Viewports: 1440, 768, 390px; additional browser screenshots/checks at 320px.
- Themes: light and dark.
- States: zero repositories, empty Home/Sidebar, representative member work
records, long catalog names, unchecked/checked optional repository form,
populated inputs, and intentional Backend validation failure.
- Reviewed by the implementer using `ViewImage`: before/after contact sheets,
individual narrow/mobile populated-form screenshots, and the final mobile
dark validation-error screenshot. Reviewed context bundles and accessibility
snapshots for document status, control names/state, screenshot hashes,
console/page/request errors, and capture points.
- Fixed: catalog/form clipping at narrow content widths; repository field
disclosure/required-state handling. Chromium tests also found and fixed the
existing repository-key HTML pattern's invalid modern-regexp character class.
- Result: pass. All baseline and final main captures have document status 200
and no retained browser/UI errors. Only the intentional HTTP400 interaction
errors remain. Home, member Sidebar, and Worker launch comparisons show zero
changed pixels; catalog changes are confined to the intended surface.
## Validation and limitations
- `deno task check`: 0 errors, 0 warnings.
- Targeted model/catalog Deno tests: 18 passed.
- `deno task test:component`: 29 files, 268 tests passed.
- `deno task test:browser:empty-workspace`: passed (8 theme/width combinations).
- `deno task build`: passed.
- Web-scoped `git diff --check HEAD -- .`: passed.
- `deno task test`: 522 passed, 1 pre-existing failure in
`src/lib/workspace/console/worker-console.ui.test.ts`,
“workspace Tickets surface provides Kanban and lifecycle controls”. It fails
the unchanged Ticket detail source assertion at line 429. The same failure
was reproduced against the isolated baseline source. Evidence:
`.svelte-kit/t717/unit.log` and `baseline-ticket-failure.log`.
- Canonical OpenAPI and generated TypeScript were regenerated together.
The Web parser uses the generated nullable creation-response type directly.
Backend persistence/idempotency is covered separately by Store/catalog and
real HTTP boundary tests in `yoi-workspace-server`.
@@ -0,0 +1,65 @@
// Extends the existing Web UX fixture at the API boundary, never the production shell/CSS.
// Both loopback servers are owned and cleaned up by the Web UX capture harness.
const port = Number(Deno.args[0]);
const upstream = Deno.args[1];
if (!Number.isInteger(port) || !upstream) {
throw new Error("usage: server <port> <upstream-url>");
}
import { emptyLaunchOptions } from "../../src/lib/workspace/sidebar/worker-launch.test-fixtures.ts";
import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fixtures.ts";
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/api/workspaces" && request.method === "POST") {
return Response.json({
message:
"Workspace display name was rejected by Backend validation. Choose another display name and retry; no Workspace was created.",
}, { status: 400 });
}
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
if (match) {
const [, id, path] = match;
// The shared catalog includes a deliberate failure persona, which can be hover-preloaded.
// This scenario tests zero repositories, not that separate failure contract.
if (id === "home-error" && path === "/merge-requests") {
return Response.json(
dashboardFixture(path, url.searchParams, "home-empty"),
);
}
if (path === "/workers") {
return Response.json({
workspace_id: id,
limit: 100,
items: [],
source: "fixture",
diagnostics: [],
});
}
if (path === "/workers/launch-options") {
return Response.json(emptyLaunchOptions(id));
}
}
// Forward HTTP and bridge the existing workspace subscription fixture.
const target = new URL(url.pathname + url.search, upstream);
if (request.headers.get("upgrade") === "websocket") {
const { socket, response } = Deno.upgradeWebSocket(request);
target.protocol = "ws:";
const peer = new WebSocket(target);
const pending: string[] = [];
socket.onmessage = (event) =>
peer.readyState === WebSocket.OPEN
? peer.send(event.data)
: pending.push(String(event.data));
peer.onopen = () => {
for (const frame of pending) peer.send(frame);
};
peer.onmessage = (event) => {
if (socket.readyState === WebSocket.OPEN) socket.send(event.data);
};
socket.onclose = () => peer.close();
peer.onclose = () => socket.close();
return response;
}
return await fetch(new Request(target, request));
});
@@ -0,0 +1,213 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { chromium } from "npm:playwright@1.59.1";
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
// Browser-only contract over the production static build and loopback API fixtures;
// no actual Backend, Runtime, Worker, account or repository is created.
Deno.test("production empty Workspace supports optional Git validation, reopening, and Workdir-less Worker submission at all review widths", async () => {
const scenarioPath =
new URL("./optional-repository.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
async function freePort() {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
const upstreamPort = await freePort();
let proxyPort = await freePort();
while (proxyPort === upstreamPort) proxyPort = await freePort();
scenario.baseUrl = `http://127.0.0.1:${proxyPort}`;
scenario.processes![0].args![4] = String(upstreamPort);
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstreamPort}/health`;
scenario.processes![1].args![3] = String(proxyPort);
scenario.processes![1].args![4] = `http://127.0.0.1:${upstreamPort}`;
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
const output =
new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
`${output}/process-logs`,
[],
);
try {
const browser = await chromium.launch({ headless: true });
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) {
errors.push(`${response.status()} ${response.url()}`);
}
});
const creationBodies: Record<string, unknown>[] = [];
await page.route("**/api/workspaces", async (route) => {
if (route.request().method() !== "POST") {
return await route.continue();
}
creationBodies.push(route.request().postDataJSON());
await route.fulfill({ json: creationResponse() });
});
await page.goto(scenario.baseUrl!);
const name = page.getByLabel("Workspace display name");
await name.fill("New Workspace");
const toggle = page.getByRole("checkbox", {
name: "Add an initial repository (optional)",
});
await toggle.check();
await page.getByRole("button", {
name: "Create Workspace",
exact: true,
}).click();
assertEquals(creationBodies.length, 0); // Native required fields stop partial repository submission.
await page.getByLabel("Repository key", { exact: true }).fill(
"Invalid key",
);
await page.getByLabel("Initial repository absolute path or URI")
.fill("/srv/platform");
assertEquals(
await page.getByLabel("Repository key", { exact: true }).evaluate(
(input) =>
(input as unknown as { checkValidity(): boolean })
.checkValidity(),
),
false,
);
await page.getByLabel("Repository key", { exact: true }).fill(
"platform",
);
assertEquals(
await page.locator("form").evaluate((form) =>
(form as unknown as { checkValidity(): boolean })
.checkValidity()
),
true,
);
await Deno.mkdir(output, { recursive: true });
await page.screenshot({
path: `${output}/${colorScheme}-${width}-repository.png`,
fullPage: true,
});
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
`${colorScheme} ${width}px`,
);
await toggle.uncheck();
await page.getByRole("button", {
name: "Create Workspace",
exact: true,
}).click();
await page.waitForURL("**/w/home-empty");
assertEquals(creationBodies.length, 1);
assertEquals(creationBodies[0].repository, null);
await page.locator("[data-feed-ready=home-active]").waitFor();
assertEquals(await page.locator("main [role=alert]").count(), 0);
// Reopening is based on Workspace identity, not a repository record.
await page.goto(scenario.baseUrl!);
await page.locator('a.workspace-catalog-card[href="/w/home-empty"]')
.click();
await page.waitForURL("**/w/home-empty");
await page.goto(`${scenario.baseUrl}/w/home-empty/workers/new`);
await page.locator(".worker-launch-form").waitFor();
assert(
await page.getByRole("button", { name: "Start Worker" })
.isEnabled(),
);
await page.getByRole("combobox", { name: "Runtime", exact: true })
.selectOption("remote");
assert(
await page.getByRole("button", { name: "Start Worker" })
.isDisabled(),
);
await page.getByLabel("Attachment 1 Workdir").selectOption(
"__new_working_directory__",
);
assert(
await page.getByRole("button", {
name: "Create Workdir",
exact: true,
}).isDisabled(),
);
await page.getByRole("combobox", { name: "Runtime", exact: true })
.selectOption("embedded");
const submitted = page.waitForRequest((request) =>
request.url().endsWith("/api/w/home-empty/workers") &&
request.method() === "POST"
);
await page.route("**/api/w/home-empty/workers", async (route) => {
if (route.request().method() !== "POST") {
return await route.continue();
}
// Successful, synthetic launch projection; the test only exercises browser/API wiring.
await route.fulfill({
json: {
workspace_id: "home-empty",
runtime_id: "embedded",
worker_id: "fixture-worker",
console_href: "/w/home-empty",
diagnostics: [],
worker: {
runtime_id: "embedded",
worker_id: "fixture-worker",
host_id: "fixture-host",
display_name: "Worker",
label: "Worker",
profile: "builtin:companion",
singleton_key: null,
tags: [],
workspace: {
visibility: "workspace",
identity: "home-empty",
workspace_id: "home-empty",
},
state: "idle",
last_seen_at: null,
pinned: false,
retention_state: "active",
implementation: { kind: "yoi", display_hint: "Yoi" },
workdir_attachments: [],
diagnostics: [],
},
},
});
});
await page.getByRole("button", { name: "Start Worker" }).click();
const body = (await submitted).postDataJSON();
assertEquals(body.workdir_attachments, []);
assertEquals(body.ticket_assignment, null);
assertEquals(body.runtime_id, "embedded");
await page.waitForURL("**/w/home-empty");
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
}
} finally {
assertEquals(await stopOwnedProcesses(processes), []);
}
});
@@ -0,0 +1,113 @@
{
"schemaVersion": 1,
"id": "optional-repository-interactions",
"title": "Optional repository disclosure and validation failure",
"baseUrl": "http://127.0.0.1:15281",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic account; permissions vary by Workspace",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "catalog",
"label": "Create Workspace without a repository",
"path": "/",
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
"dataState": "Opt-in Git fields followed by intentional HTTP400 creation validation failure; no actual resources are created.",
"ready": {
"kind": "selector",
"selector": ".workspace-create-panel input"
},
"capturePoints": [
{
"id": "repository-fields",
"label": "Opt-in Git fields",
"fullPage": true,
"interaction": [
{
"action": "click",
"selector": ".workspace-repository-toggle input"
}
]
},
{
"id": "validation-error",
"label": "Intentional Backend validation failure",
"fullPage": true,
"interaction": [
{
"action": "click",
"selector": ".workspace-repository-toggle input"
},
{
"action": "fill",
"selector": ".workspace-create-panel input[required]",
"value": "Rejected Workspace"
},
{
"action": "click",
"selector": ".workspace-create-panel button[type=submit]"
}
],
"ready": {
"kind": "selector",
"selector": ".workspace-create-panel [role=alert]"
}
}
]
}
],
"processes": [
{
"id": "workspace-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15282",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15282/health"
},
{
"id": "empty-workspace-api",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/empty-workspace-fixture-server.ts",
"15281",
"http://127.0.0.1:15282"
],
"readyUrl": "http://127.0.0.1:15281/health"
}
]
}
@@ -0,0 +1,135 @@
{
"schemaVersion": 1,
"id": "optional-initial-repository",
"title": "Optional initial repository and empty Workspace",
"baseUrl": "http://127.0.0.1:15281",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic account; permissions vary by Workspace",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "catalog",
"label": "Create Workspace without a repository",
"path": "/",
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
"dataState": "Accessible Workspaces with no repositories and blank creation form.",
"ready": {
"kind": "selector",
"selector": ".workspace-create-panel input"
},
"capturePoints": [
{
"id": "initial",
"label": "Blank creation form",
"fullPage": true
}
]
},
{
"id": "empty-owner",
"label": "Empty Workspace owner",
"path": "/w/home-empty",
"goal": "Use Home and Sidebar without any repositories or work records.",
"dataState": "Owner permissions; zero repositories, Workers, tickets, Objectives, and Merge Requests.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [
{
"id": "initial",
"label": "Empty Home and Sidebar"
}
]
},
{
"id": "member",
"label": "Zero-repository member",
"path": "/w/home-member",
"goal": "Navigate authorized work without owner-only repository management actions.",
"dataState": "Non-owner permissions; zero repositories, representative work records.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [
{
"id": "initial",
"label": "Member Home and Sidebar"
}
]
},
{
"id": "worker",
"label": "New Worker without repositories",
"path": "/w/home-empty/workers/new",
"goal": "Start a normal conversation Worker without a Workdir on embedded Runtime.",
"dataState": "No repositories or Workdirs, embedded and remote Runtime candidates.",
"ready": {
"kind": "selector",
"selector": ".worker-launch-form"
},
"capturePoints": [
{
"id": "initial",
"label": "Embedded Worker launch",
"fullPage": true
}
]
}
],
"processes": [
{
"id": "workspace-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15282",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15282/health"
},
{
"id": "empty-workspace-api",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/empty-workspace-fixture-server.ts",
"15281",
"http://127.0.0.1:15282"
],
"readyUrl": "http://127.0.0.1:15281/health"
}
]
}
+20 -29
View File
@@ -1,12 +1,7 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFile(path: URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
@@ -35,6 +30,26 @@ import {
loadWorkspaceCatalog,
WorkspaceCatalogError,
} from "../src/lib/workspace/api/workspace-catalog.ts";
import { creationResponse } from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
Deno.test("workspace creation accepts omitted and null repository requests without fabricating a repository", async () => {
for (const repository of [undefined, null]) {
const request = {
operation_key: "empty-create",
display_name: "New Workspace",
...(repository === null ? { repository } : {}),
};
const fetcher = async (url: string | URL | Request, init?: RequestInit) => {
assertEquals(url, "/api/workspaces");
assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request);
return Response.json(creationResponse());
};
const response = await createWorkspace(fetcher as typeof fetch, request);
assertEquals(response.repository, null);
assertEquals(response.workspace.workspace_id, "home-empty");
}
});
Deno.test("workspace catalog enriches each visible workspace without dropping siblings", async () => {
const fetcher = (input: string | URL | Request) => {
@@ -118,27 +133,3 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
);
assertEquals(bodies, [request, request]);
});
Deno.test("workspace creation form starts with an explicit required Repository key", async () => {
const source = await Deno.readTextFile(
new URL("../src/routes/+page.svelte", import.meta.url),
);
assert(
source.includes('let repositoryKey = $state("");'),
"the form must start without an implicit Repository key",
);
assert(
source.includes("<input bind:value={repositoryKey} required"),
"the Browser must require creator input for the Repository key",
);
assert(
source.includes("repository_key: normalized.repositoryKey"),
"the Browser must submit the creator-provided Repository key",
);
assert(
!source.includes('repositoryKey || "main"') &&
!source.includes("repositoryKey || 'main'"),
"the Browser must not fall back to main",
);
});
@@ -8,12 +8,57 @@ import {
parseRepositoryApiError,
parseRepositoryListApiResult,
parseRepositoryListResponse,
parseWorkspaceCreateResponse,
parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse,
parseWorkspaceResponse,
REPOSITORY_API_LIMITS,
} from "../src/lib/workspace/api/workspace-model.ts";
import {
creationResponse,
currentInitialRepository,
} from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
Deno.test("workspace creation decodes absent and deleted initial repositories as explicit null", () => {
for (const replayed of [false, true]) {
const parsed = parseWorkspaceCreateResponse(
JSON.parse(JSON.stringify(creationResponse(null, replayed))),
);
if (parsed.repository !== null || parsed.replayed !== replayed) {
throw new Error("nullable creation response was not preserved");
}
}
});
Deno.test("workspace creation replay preserves the returned current repository record", () => {
const parsed = parseWorkspaceCreateResponse(
creationResponse(currentInitialRepository, true),
);
if (
JSON.stringify(parsed.repository) !==
JSON.stringify(currentInitialRepository)
) throw new Error("current repository record was replaced or lost");
});
Deno.test("workspace creation rejects missing and malformed repository response fields", () => {
for (
const value of [undefined, false, {}, {
...currentInitialRepository,
source_revision: -1,
}]
) {
assertThrows(
() =>
parseWorkspaceCreateResponse({
...creationResponse(),
repository: value,
}),
"workspace create response.repository",
);
}
});
function assertThrows(operation: () => unknown, expected: string): void {
try {
operation();