Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop

This commit is contained in:
2026-09-03 09:47:53 +09:00
41 changed files with 2218 additions and 379 deletions
+201
View File
@@ -0,0 +1,201 @@
import type { Segment } from "../src/lib/generated/protocol.ts";
import {
COMPOSER_HISTORY_LIMIT,
ComposerHistory,
type ComposerHistoryEntry,
composerHistoryStorageKey,
loadComposerHistory,
saveComposerHistory,
shouldBrowseComposerHistory,
} from "../src/lib/workspace/console/composer-history.ts";
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
function entry(content: string): ComposerHistoryEntry {
return {
segments: [{ kind: "text", content }],
preserveExactText: false,
};
}
function text(entryValue: ComposerHistoryEntry | null): string | null {
const segment = entryValue?.segments[0];
return segment?.kind === "text" ? segment.content : null;
}
function memoryStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem(key: string): string | null {
return values.get(key) ?? null;
},
setItem(key: string, value: string): void {
values.set(key, value);
},
value(key: string): string | null {
return values.get(key) ?? null;
},
};
}
Deno.test("Composer history skips blank and consecutive duplicate entries", () => {
const history = new ComposerHistory();
assert(!history.record(entry(" \n")));
assert(history.record(entry("first")));
assert(!history.record(entry("first")));
assert(history.record(entry("second")));
assertEquals(history.entries.map(text), ["first", "second"]);
});
Deno.test("Composer history keeps the newest 30 entries", () => {
const history = new ComposerHistory();
for (let index = 0; index < COMPOSER_HISTORY_LIMIT + 4; index += 1) {
history.record(entry(`message-${index}`));
}
assertEquals(history.entries.length, COMPOSER_HISTORY_LIMIT);
assertEquals(text(history.entries[0] ?? null), "message-4");
assertEquals(text(history.entries.at(-1) ?? null), "message-33");
});
Deno.test("Composer history uses only the multiline input boundaries", () => {
const base = {
lineCount: 3,
selectionEmpty: true,
readOnly: false,
composing: false,
};
assert(
shouldBrowseComposerHistory({ ...base, direction: "older", cursorLine: 1 }),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 2,
}),
);
assert(
shouldBrowseComposerHistory({ ...base, direction: "newer", cursorLine: 3 }),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "newer",
cursorLine: 2,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 1,
selectionEmpty: false,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "newer",
cursorLine: 3,
readOnly: true,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 1,
composing: true,
}),
);
});
Deno.test("Composer history navigates older and restores the draft after newer", () => {
const history = new ComposerHistory([entry("first"), entry("second")]);
assertEquals(text(history.previous(entry("unsent draft"))), "second");
assertEquals(text(history.previous(entry("ignored draft"))), "first");
assertEquals(text(history.previous(entry("ignored draft"))), "first");
assertEquals(text(history.next()), "second");
assertEquals(text(history.next()), "unsent draft");
assert(!history.browsing);
assertEquals(history.next(), null);
});
Deno.test("editing cancels Composer history navigation", () => {
const history = new ComposerHistory([entry("sent")]);
history.previous(entry("draft"));
assert(history.browsing);
history.cancelNavigation();
assert(!history.browsing);
assertEquals(history.next(), null);
});
Deno.test("Composer history persists segments by workspace and ignores corrupt storage", () => {
const storage = memoryStorage();
const workspaceId = "workspace / one";
const history = new ComposerHistory();
const paste = {
kind: "paste",
id: 7,
content: "large paste",
chars: 11,
lines: 1,
} satisfies Segment;
history.record({ segments: [paste], preserveExactText: true });
saveComposerHistory(storage, workspaceId, history);
const restored = loadComposerHistory(storage, workspaceId);
assertEquals(restored.entries, history.entries);
assertEquals(
composerHistoryStorageKey(workspaceId),
"yoi.composer-history.v1.workspace.workspace%20%2F%20one",
);
const corrupt = memoryStorage({
[composerHistoryStorageKey(workspaceId)]: "not-json",
});
assertEquals(loadComposerHistory(corrupt, workspaceId).entries, []);
});
Deno.test("Composer input uses boundary-aware Up and Down history navigation", async () => {
const inputSource = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/ComposerInput.svelte",
import.meta.url,
),
);
const consoleSource = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
assert(inputSource.includes('key: "ArrowUp"'));
assert(inputSource.includes('key: "ArrowDown"'));
assert(inputSource.includes("shouldBrowseComposerHistory({"));
assert(inputSource.includes("lineCount: currentView.state.doc.lines"));
assert(inputSource.includes("composerHistory.cancelNavigation()"));
assert(consoleSource.includes("historyScope={workspaceId}"));
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
});
@@ -1,3 +1,6 @@
import denoConfig from "../../deno.json" with { type: "json" };
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>;
@@ -7,6 +10,30 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
for (
const packageName of [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
]
) {
assert(
CODEMIRROR_VITE_DEDUPE.includes(packageName),
`Vite must deduplicate ${packageName}`,
);
}
for (const packageName of CODEMIRROR_VITE_DEDUPE) {
assert(
packageName in (denoConfig.imports ?? {}),
`${packageName} must be a direct dependency so Vite can deduplicate it`,
);
}
});
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile(
new URL(
@@ -72,6 +99,17 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist",
);
assert(
source.includes("keymap.of(completionKeymapWithoutEnter)") &&
source.includes("binding.key !== 'Enter'") &&
source.includes("activateOnTyping: false") &&
source.includes("shouldStartCompletionAfterTyping(insertedText)") &&
source.includes("startCompletion(editor)") &&
!source.includes("EditorView.domEventHandlers") &&
!source.includes("update.selectionSet") &&
source.includes("completionStatus(editor.state) === null"),
"completion should start only after non-whitespace typing, without using focus, cursor movement, Space, or Enter",
);
assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") &&
@@ -3,7 +3,10 @@ declare const Deno: {
readTextFile(path: URL): Promise<string>;
};
import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts";
import {
shouldStartCompletionAfterTyping,
toCodeMirrorCompletion,
} from "../../src/lib/workspace/config-source/completion.ts";
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
function assert(condition: unknown, message: string): asserts condition {
@@ -67,10 +70,28 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
);
});
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
const source = "let 名 = tru";
const result = toCodeMirrorCompletion(source, {
from: new TextEncoder().encode("let 名 = ").length,
Deno.test("completion starts only after non-whitespace typing", () => {
assert(
!shouldStartCompletionAfterTyping(" "),
"Space should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\n"),
"Enter should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\t"),
"other whitespace should not start completion",
);
assert(
shouldStartCompletionAfterTyping("p"),
"non-whitespace typing should start completion",
);
});
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
const result = toCodeMirrorCompletion({
from: "let 名 = ".length,
items: [
{
label: "true",
@@ -84,7 +105,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
assert(result !== null, "WASM completion should produce a CodeMirror result");
assert(
result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets",
"WASM UTF-16 offsets should be preserved for CodeMirror",
);
assert(
result.options.length === 1,
@@ -299,6 +299,45 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
assertEquals(result.items[0].kind, "file");
});
Deno.test("generated WASM completes blank nested schema positions after Unicode", () => {
const source =
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
const cursor = source.indexOf("{ }") + 2;
set_snapshot({
...snapshot,
entries: {
...snapshot.entries,
"workspace.dcdl": {
...snapshot.entries["workspace.dcdl"],
content: source,
},
},
});
set_schema_bundle({
contributions: [],
source: "{ profile = { default_profile = String; }; prompts = {}; }",
fingerprint: "sha256:test-schema",
});
const result = complete_current(
"workspace.dcdl",
source,
cursor,
true,
) as {
from: number;
items: Array<{ label: string; kind: string }>;
};
assertEquals(result.from, cursor);
assertEquals(
result.items.some((item) => item.label === "default_profile"),
true,
);
assertEquals(result.items.some((item) => item.label === "profile"), false);
assertEquals(result.items.some((item) => item.label === "prompts"), false);
});
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
const bareSource = "{ pro }";
const source = "{ pro } as WorkspaceConfigSchema";
@@ -0,0 +1,180 @@
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
async function assertRejects(
operation: () => Promise<unknown>,
message: string,
): Promise<void> {
try {
await operation();
} catch (cause) {
assert(cause instanceof Error, "expected an Error");
assert(
cause.message.includes(message),
`expected error containing ${message}`,
);
return;
}
throw new Error("expected operation to reject");
}
const worker = {
runtime_id: "runtime /",
worker_id: "worker /",
state: "running",
capabilities: { can_stop: true },
} as Worker;
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
Deno.test("sidebar Stop uses the workspace-scoped Worker lifecycle endpoint", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({ state: "accepted", diagnostics: [] });
}) as typeof fetch;
await stopSidebarWorker("team space", worker, fetchFn);
assertEquals(requests.length, 1);
assertEquals(
requests[0]?.url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/stop",
);
assertEquals(requests[0]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[0]?.init?.body)), {
reason: "stopped from Workspace sidebar",
});
});
Deno.test("sidebar Stop rejects non-accepted lifecycle responses", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
state: "rejected",
diagnostics: [{ severity: "error", message: "Worker cannot stop" }],
}),
)) as typeof fetch;
await assertRejects(
() => stopSidebarWorker("workspace", worker, fetchFn),
"Worker cannot stop",
);
});
Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
if (requests.length === 1) {
return jsonResponse({
revision: 7,
digest: "digest-7",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: null,
}],
workdirs: [],
diagnostics: [],
});
}
return jsonResponse({
results: [{
target_id: "worker-target",
status: "deleted",
message: null,
}],
diagnostics: [],
});
}) as typeof fetch;
await deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn);
assertEquals(requests.map((request) => request.url), [
"/api/w/team/runtimes/runtime%20%2F/cleanup-plan",
"/api/w/team/runtimes/runtime%20%2F/cleanup-executions",
]);
assertEquals(requests[1]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
expected_plan_revision: 7,
expected_plan_digest: "digest-7",
worker_target_ids: ["worker-target"],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
});
});
Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
revision: 8,
digest: "digest-8",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: "Worker is pinned",
}],
workdirs: [],
diagnostics: [],
}),
)) as typeof fetch;
await assertRejects(
() => deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn),
"Worker is pinned",
);
});
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
assert(!canDeleteSidebarWorker(worker));
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
});
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/sidebar/WorkersNavSection.svelte",
import.meta.url,
),
);
const styles = await Deno.readTextFile(
new URL("../../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});