Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop
This commit is contained in:
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user