mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Reduce test-suite bottlenecks and redundant assertions
This commit is contained in:
parent
dd280f74e7
commit
b8a085b4bf
12 changed files with 358 additions and 634 deletions
|
|
@ -1,21 +1,31 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const generatedDir = path.resolve("src/generated/app-server");
|
||||
const generatedRelativeDir = "src/generated/app-server";
|
||||
const generatedHeader = "// GENERATED CODE! DO NOT MODIFY BY HAND!";
|
||||
const normalizationNotice = "// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.";
|
||||
|
||||
await cleanGeneratedTypes();
|
||||
run("codex", ["app-server", "generate-ts", "--experimental", "--out", "src/generated/app-server"]);
|
||||
await normalizeGeneratedTypes();
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await generateAppServerTypes();
|
||||
}
|
||||
|
||||
async function cleanGeneratedTypes() {
|
||||
export async function generateAppServerTypes(options = {}) {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const generatedDir = path.resolve(cwd, generatedRelativeDir);
|
||||
const runCommand = options.runCommand ?? run;
|
||||
await cleanGeneratedTypes(generatedDir);
|
||||
await runCommand("codex", ["app-server", "generate-ts", "--experimental", "--out", generatedRelativeDir], { cwd });
|
||||
await normalizeGeneratedTypes(generatedDir);
|
||||
}
|
||||
|
||||
async function cleanGeneratedTypes(generatedDir) {
|
||||
await rm(generatedDir, { recursive: true, force: true });
|
||||
await mkdir(generatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function normalizeGeneratedTypes() {
|
||||
async function normalizeGeneratedTypes(generatedDir) {
|
||||
const files = await listTypeScriptFiles(generatedDir);
|
||||
|
||||
await Promise.all(
|
||||
|
|
@ -55,8 +65,9 @@ function addNormalizationNotice(source) {
|
|||
return source.replace(generatedHeader, `${generatedHeader}\n${normalizationNotice}`);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ describe("thread helpers", () => {
|
|||
expect(threadRenameDraftTitle(uuidNamed)).toBe("Useful preview");
|
||||
expect(threadArchiveTitle(uuidNamed)).toBe("Useful preview");
|
||||
expect(threadArchiveDisplayTitle(uuidNamed)).toBe("Useful preview");
|
||||
expect(threadArchiveDisplayTitle(thread({ preview: "A title\nwith extra\tspace" }))).toBe("A title with extra space");
|
||||
expect(threadArchiveDisplayTitle(thread({ preview: "x".repeat(120) }))).toMatch(/^x{93}\.\.\.$/);
|
||||
});
|
||||
|
||||
it("builds window titles from loaded threads, restored titles, then short ids", () => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
type ChatMessageScrollController,
|
||||
createChatMessageScrollController,
|
||||
} from "../../../../../src/features/chat/panel/surface/message-stream-scroll";
|
||||
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events";
|
||||
import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer.obsidian";
|
||||
import { MessageStreamViewport } from "../../../../../src/features/chat/ui/message-stream/stream-blocks";
|
||||
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root.dom";
|
||||
|
|
@ -225,39 +224,6 @@ describe("MessageStreamPresenter scroll pinning", () => {
|
|||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can repin the current scroll container after composer growth shrinks the viewport", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateMessageStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "Streaming message",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter, scrollController } = messageStreamPresenter(state);
|
||||
|
||||
renderMessageStreamPresenter(parent, presenter, state);
|
||||
const messages = messageViewport(parent);
|
||||
Object.defineProperty(messages, "scrollHeight", { value: ESTIMATED_MESSAGE_BLOCK_HEIGHT, configurable: true });
|
||||
installMessageViewportMetrics(messages, { clientHeight: 160 });
|
||||
messages.scrollTop = 1000;
|
||||
await settleMessageRender(messages);
|
||||
|
||||
installMessageViewportMetrics(messages, { clientHeight: 100 });
|
||||
messages.scrollTop = 940;
|
||||
|
||||
scrollController.showLatest();
|
||||
await settleMessageRender(messages);
|
||||
|
||||
expect(messages.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("repins after composer growth has changed the scroll viewport height", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
|
|
@ -414,45 +380,6 @@ describe("MessageStreamPresenter scroll pinning", () => {
|
|||
expect(messages.scrollTop).toBe(900);
|
||||
});
|
||||
|
||||
it("keeps bottom pinning after markdown content changes message height", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
state = withChatStateMessageStreamItems(state, [
|
||||
{
|
||||
id: "message",
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: "**Rendered** message",
|
||||
turnId: "turn",
|
||||
messageKind: "assistantResponse",
|
||||
messageState: "completed",
|
||||
},
|
||||
]);
|
||||
const parent = document.createElement("div");
|
||||
const { presenter, scrollController } = messageStreamPresenter(state);
|
||||
|
||||
renderMessageStreamPresenter(parent, presenter, state);
|
||||
const messages = messageViewport(parent);
|
||||
installMessageViewportMetrics(messages, { clientHeight: 100 });
|
||||
let scrollHeight = 1000;
|
||||
Object.defineProperty(messages, "scrollHeight", {
|
||||
get: () => scrollHeight,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
scrollController.showLatest();
|
||||
await settleMessageRender(messages);
|
||||
expect(messages.scrollTop).toBe(900);
|
||||
|
||||
scrollHeight = 1200;
|
||||
const content = parent.querySelector<HTMLElement>(".codex-panel__message-content");
|
||||
if (!content) throw new Error("Expected rendered message content.");
|
||||
content.dispatchEvent(new Event(MESSAGE_CONTENT_RENDERED_EVENT, { bubbles: true }));
|
||||
await settleMessageRender(messages);
|
||||
|
||||
expect(messages.scrollTop).toBe(1100);
|
||||
});
|
||||
|
||||
it("does not force the bottom into view when the user is reading older messages", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, { activeThread: { id: "thread" } });
|
||||
|
|
|
|||
|
|
@ -87,29 +87,6 @@ function composerCallbacks() {
|
|||
}
|
||||
|
||||
describe("ComposerShell decisions", () => {
|
||||
it("uses the provided composer placeholder for normal input", () => {
|
||||
const parent = document.createElement("div");
|
||||
const callbacks = composerCallbacks();
|
||||
const { composer } = mountComposerShell(
|
||||
parent,
|
||||
"view",
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
"Ask Codex to work on “Refactor terminal streaming”...",
|
||||
[],
|
||||
0,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Refactor terminal streaming”...");
|
||||
expect(composer.getAttribute("aria-label")).toBeNull();
|
||||
|
||||
mountComposerShell(parent, "view", "", false, false, "Ask Codex to work on “Renamed thread”...", [], 0, callbacks);
|
||||
|
||||
expect(composer.getAttribute("placeholder")).toBe("Ask Codex to work on “Renamed thread”...");
|
||||
});
|
||||
|
||||
it("renders composer meta as interactive context and runtime text without changing normal text", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
|
|
|
|||
|
|
@ -24,25 +24,6 @@ describe("GoalPanel", () => {
|
|||
expect(parent.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("opens an empty goal editor when editing is requested without a saved goal", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const callbacks = actions();
|
||||
|
||||
await act(async () => {
|
||||
renderGoal(parent, null, callbacks, "enter", { editing: true, objectiveDraft: "", tokenBudgetDraft: null });
|
||||
});
|
||||
|
||||
expect(parent.textContent).toContain("Goal");
|
||||
expect(document.activeElement).toBe(parent.querySelector("textarea"));
|
||||
expect(parent.querySelector("textarea")?.getAttribute("aria-label")).toBeNull();
|
||||
await input(parent, "textarea", "New objective");
|
||||
await click(parent, '[aria-label="Save goal"]');
|
||||
|
||||
expect(callbacks.onSave).toHaveBeenCalledWith("New objective", null);
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("renders active goal details and actions", async () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
|
|
|
|||
|
|
@ -41,23 +41,14 @@ describe("Toolbar decisions", () => {
|
|||
expect(parent.querySelector(".codex-panel__runtime-model")).toBeNull();
|
||||
const newChatButton = parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat");
|
||||
expect(newChatButton?.getAttribute("aria-label")).toBe("Show chat actions");
|
||||
expect(newChatButton?.dataset["icon"]).toBe("messages-square");
|
||||
expect(newChatButton?.classList.contains("nav-action-button")).toBe(true);
|
||||
expect(newChatButton?.disabled).toBe(false);
|
||||
newChatButton?.click();
|
||||
expect(toggleChatActions).toHaveBeenCalled();
|
||||
expect(startNewThread).not.toHaveBeenCalled();
|
||||
const statusButton = parent.querySelector(".codex-panel__status-menu-toggle");
|
||||
expect(statusButton?.tagName).toBe("BUTTON");
|
||||
expect(statusButton?.getAttribute("role")).toBeNull();
|
||||
expect(statusButton?.getAttribute("aria-label")).toBe("Show status");
|
||||
expect((statusButton as HTMLElement | null)?.dataset["icon"]).toBe("waypoints");
|
||||
expect(statusButton?.classList.contains("nav-action-button")).toBe(true);
|
||||
expect(statusButton?.classList.contains("clickable-icon")).toBe(true);
|
||||
const historyButton = parent.querySelector<HTMLButtonElement>(".codex-panel__history-toggle");
|
||||
expect(historyButton?.getAttribute("aria-label")).toBe("Show thread list");
|
||||
expect(historyButton?.classList.contains("nav-action-button")).toBe(true);
|
||||
expect(historyButton?.classList.contains("clickable-icon")).toBe(true);
|
||||
historyButton?.click();
|
||||
expect(toggleHistory).toHaveBeenCalled();
|
||||
parent.empty();
|
||||
|
|
@ -236,7 +227,6 @@ describe("Toolbar decisions", () => {
|
|||
);
|
||||
|
||||
const renameButton = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]'));
|
||||
expect(renameButton.classList.contains("nav-action-button")).toBe(false);
|
||||
renameButton.click();
|
||||
expect(startRenameThread).toHaveBeenCalledWith("thread");
|
||||
const threadRow = parent.querySelector(".codex-panel__thread-row");
|
||||
|
|
@ -245,7 +235,6 @@ describe("Toolbar decisions", () => {
|
|||
|
||||
const input = parent.querySelector<HTMLInputElement>(".codex-panel__thread-rename-input");
|
||||
if (!input) throw new Error("Missing thread rename input");
|
||||
expect(input.closest(".codex-panel__thread-rename")?.querySelector(".codex-panel__toolbar-panel-check")).toBeNull();
|
||||
expect(input.value).toBe("Draft title");
|
||||
changeInputValue(input, "New title");
|
||||
expect(updateRenameDraft).toHaveBeenCalledWith("editing", "New title");
|
||||
|
|
@ -275,10 +264,7 @@ describe("Toolbar decisions", () => {
|
|||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
expect(cancelRenameThread).toHaveBeenCalledWith("editing");
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')).toBeNull();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel rename"]')).toBeNull();
|
||||
parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
|
||||
expect(parent.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.classList.contains("nav-action-button")).toBe(false);
|
||||
expect(autoNameThread).toHaveBeenCalledWith("editing");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ServerNotification } from "../../../src/app-server/connection/rpc-messages";
|
||||
import { modelMetadataFromCatalogModels } from "../../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../../src/app-server/protocol/thread";
|
||||
|
|
@ -20,6 +20,7 @@ import { installObsidianDomShims } from "../../support/dom";
|
|||
|
||||
const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96;
|
||||
type TestCodexChatHost = CodexChatHost;
|
||||
let CodexChatView: typeof import("../../../src/features/chat/host/view")["CodexChatView"];
|
||||
|
||||
const connectionMock = vi.hoisted(() => {
|
||||
const state = {
|
||||
|
|
@ -110,6 +111,10 @@ installObsidianDomShims();
|
|||
describe("CodexChatView connection lifecycle", () => {
|
||||
let restoreDefaultMessageViewportMetrics: (() => void) | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ CodexChatView } = await import("../../../src/features/chat/host/view"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
notices.length = 0;
|
||||
|
|
@ -764,56 +769,6 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses the active explicit thread name in the composer placeholder", async () => {
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Restored thread”...");
|
||||
});
|
||||
|
||||
it("does not use preview-only thread text in the composer placeholder", async () => {
|
||||
const client = connectedClient({
|
||||
resumeThread: vi.fn().mockResolvedValue({
|
||||
...resumedThread("thread-1"),
|
||||
thread: { ...resumedThread("thread-1").thread, name: null, preview: "Restored preview" },
|
||||
}),
|
||||
});
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
});
|
||||
|
||||
it("updates the composer placeholder from shared rename notifications", async () => {
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
const host = chatHost();
|
||||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("thread-1");
|
||||
host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-1", name: "Renamed thread" })] });
|
||||
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”...");
|
||||
});
|
||||
|
||||
host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-1", name: null })] });
|
||||
view.surface.applyThreadRenamed("thread-1", null);
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps composer draft and selection while updating the placeholder", async () => {
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
|
|
@ -1322,7 +1277,7 @@ async function submitComposerByEnter(view: { containerEl: HTMLElement }): Promis
|
|||
}
|
||||
|
||||
async function flushAsyncTicks(): Promise<void> {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1517,7 +1472,6 @@ async function chatView(
|
|||
options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; host?: CodexChatHost; requestSaveLayout?: () => void } = {},
|
||||
) {
|
||||
const host = options.host ?? chatHost();
|
||||
const { CodexChatView } = await import("../../../src/features/chat/host/view");
|
||||
const containerEl = document.createElement("div");
|
||||
document.body.appendChild(containerEl);
|
||||
containerEl.createDiv();
|
||||
|
|
|
|||
|
|
@ -239,31 +239,34 @@ describe("selection rewrite lifecycle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "editing-prompt", event: previewUpdatedEvent() },
|
||||
{ status: "editing-prompt", event: generationSucceededEvent() },
|
||||
{ status: "editing-prompt", event: generationFailedEvent() },
|
||||
{ status: "preview", event: previewUpdatedEvent() },
|
||||
{ status: "preview", event: generationSucceededEvent() },
|
||||
{ status: "preview", event: generationFailedEvent() },
|
||||
{ status: "failed", event: previewUpdatedEvent() },
|
||||
{ status: "failed", event: generationSucceededEvent() },
|
||||
{ status: "failed", event: generationFailedEvent() },
|
||||
{ status: "cancelled", event: { type: "generation-started", instruction: "late" } },
|
||||
{ status: "cancelled", event: previewUpdatedEvent() },
|
||||
{ status: "cancelled", event: generationSucceededEvent() },
|
||||
{ status: "cancelled", event: generationFailedEvent() },
|
||||
{ status: "applied", event: { type: "generation-started", instruction: "late" } },
|
||||
{ status: "applied", event: previewUpdatedEvent() },
|
||||
{ status: "applied", event: generationSucceededEvent() },
|
||||
{ status: "applied", event: generationFailedEvent() },
|
||||
] satisfies {
|
||||
status: SelectionRewriteState["status"];
|
||||
event: SelectionRewriteLifecycleEvent;
|
||||
}[])("preserves state identity for ignored transition from $status via $event.type", ({ status, event }) => {
|
||||
const state = selectionRewriteStateWithStatus(status);
|
||||
it("preserves state identity for ignored transitions", () => {
|
||||
const ignoredTransitions = [
|
||||
{ status: "editing-prompt", event: previewUpdatedEvent() },
|
||||
{ status: "editing-prompt", event: generationSucceededEvent() },
|
||||
{ status: "editing-prompt", event: generationFailedEvent() },
|
||||
{ status: "preview", event: previewUpdatedEvent() },
|
||||
{ status: "preview", event: generationSucceededEvent() },
|
||||
{ status: "preview", event: generationFailedEvent() },
|
||||
{ status: "failed", event: previewUpdatedEvent() },
|
||||
{ status: "failed", event: generationSucceededEvent() },
|
||||
{ status: "failed", event: generationFailedEvent() },
|
||||
{ status: "cancelled", event: { type: "generation-started", instruction: "late" } },
|
||||
{ status: "cancelled", event: previewUpdatedEvent() },
|
||||
{ status: "cancelled", event: generationSucceededEvent() },
|
||||
{ status: "cancelled", event: generationFailedEvent() },
|
||||
{ status: "applied", event: { type: "generation-started", instruction: "late" } },
|
||||
{ status: "applied", event: previewUpdatedEvent() },
|
||||
{ status: "applied", event: generationSucceededEvent() },
|
||||
{ status: "applied", event: generationFailedEvent() },
|
||||
] satisfies {
|
||||
status: SelectionRewriteState["status"];
|
||||
event: SelectionRewriteLifecycleEvent;
|
||||
}[];
|
||||
|
||||
expect(transitionSelectionRewriteState(state, event)).toBe(state);
|
||||
for (const { status, event } of ignoredTransitions) {
|
||||
const state = selectionRewriteStateWithStatus(status);
|
||||
expect(transitionSelectionRewriteState(state, event), `${status} via ${event.type}`).toBe(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,12 +81,6 @@ describe("CodexThreadsView", () => {
|
|||
namingMock.generateThreadTitleWithCodex.mockReset();
|
||||
});
|
||||
|
||||
it("uses a distinct thread view icon", async () => {
|
||||
const view = await threadsView();
|
||||
|
||||
expect(view.getIcon()).toBe("list-video");
|
||||
});
|
||||
|
||||
it("renders thread list from app-server history", async () => {
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|||
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
|
@ -18,30 +19,28 @@ describe("development scripts", () => {
|
|||
|
||||
it("passes the expected codex generate-ts arguments", async () => {
|
||||
const cwd = await tempWorkspace();
|
||||
const binDir = path.join(cwd, "bin");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await mkdir(path.join(cwd, "src", "generated", "app-server"), { recursive: true });
|
||||
const codexBin = path.join(binDir, "codex");
|
||||
await writeFile(
|
||||
codexBin,
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';",
|
||||
"appendFileSync('codex-args.txt', process.argv.slice(2).join('\\n') + '\\n');",
|
||||
"mkdirSync('src/generated/app-server/v2', { recursive: true });",
|
||||
"writeFileSync('src/generated/app-server/v2/Example.ts', '// GENERATED CODE! DO NOT MODIFY BY HAND!\\nexport type Example = string | null | null;\\n');",
|
||||
].join("\n"),
|
||||
);
|
||||
await chmod(codexBin, 0o755);
|
||||
const calls: { command: string; args: string[]; cwd: string }[] = [];
|
||||
const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);
|
||||
|
||||
const result = runNodeScript("scripts/generate-app-server-types.mjs", [], cwd, {
|
||||
PATH: `${binDir}${path.delimiter}${process.env["PATH"] ?? ""}`,
|
||||
await generateAppServerTypes({
|
||||
cwd,
|
||||
async runCommand(command: string, args: string[], options: { cwd: string }) {
|
||||
calls.push({ command, args, cwd: options.cwd });
|
||||
await mkdir(path.join(options.cwd, "src", "generated", "app-server", "v2"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(options.cwd, "src", "generated", "app-server", "v2", "Example.ts"),
|
||||
"// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
await expect(readFile(path.join(cwd, "codex-args.txt"), "utf8")).resolves.toBe(
|
||||
"app-server\ngenerate-ts\n--experimental\n--out\nsrc/generated/app-server\n",
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "codex",
|
||||
args: ["app-server", "generate-ts", "--experimental", "--out", "src/generated/app-server"],
|
||||
cwd,
|
||||
},
|
||||
]);
|
||||
await expect(readFile(path.join(cwd, "src", "generated", "app-server", "v2", "Example.ts"), "utf8")).resolves.toContain(
|
||||
"export type Example = string | null;",
|
||||
);
|
||||
|
|
@ -167,20 +166,6 @@ describe("development scripts", () => {
|
|||
expect(result.stderr).toContain("codex-panel__task-step--");
|
||||
});
|
||||
|
||||
it("does not let dynamic CSS prefixes hide unconfigured class candidates", async () => {
|
||||
const cwd = await cssUsageFixture({
|
||||
"src/styles/10-component.css": ".codex-panel__task-step--stale { display: block; }\n",
|
||||
"src/component.ts": "export const className = `codex-panel__task-step--$" + "{status}`;\n",
|
||||
});
|
||||
|
||||
const result = runNodeScript("scripts/lint/check-css-usage.mjs", [], cwd);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain("CSS usage check failed.");
|
||||
expect(result.stderr).toContain("codex-panel__task-step--stale");
|
||||
});
|
||||
|
||||
it("fails release prepare before changing version files when release notes already exist", async () => {
|
||||
const cwd = await tempWorkspace();
|
||||
await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ const biomeBin = path.join(repoRoot, "node_modules", ".bin", "biome");
|
|||
const workspaceByPlugins = new Map();
|
||||
|
||||
describe("GritQL source policy", () => {
|
||||
it("can report hand-written re-export barrels as Biome plugin diagnostics", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-handwritten-reexports.grit"]);
|
||||
it("keeps project-wide source-shape policies enforceable as Biome plugin diagnostics", async () => {
|
||||
const cwd = await tempBiomeWorkspace([
|
||||
"no-handwritten-reexports.grit",
|
||||
"no-self-referential-initializer-callback.grit",
|
||||
"no-unsafe-iterator-value.grit",
|
||||
"no-uncontrolled-preact-form-state.grit",
|
||||
]);
|
||||
await writeFile(
|
||||
path.join(cwd, "reexports.ts"),
|
||||
`
|
||||
|
|
@ -21,17 +26,111 @@ const local = 1;
|
|||
export { local };
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/shared/iterator.ts"),
|
||||
`
|
||||
export function first<T>(iterator: Iterator<T>): T | undefined {
|
||||
return iterator.next().value;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/form-state.tsx"),
|
||||
`
|
||||
export function Composer(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<input defaultValue="draft" />
|
||||
<input defaultChecked={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/controlled-form-state.tsx"),
|
||||
`
|
||||
export function Composer(): JSX.Element {
|
||||
return <input value="draft" />;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
const report = biomeLint(["reexports.ts"], cwd);
|
||||
const runner = new Runner(() => runner.stop());
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime-function.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
const runner = new Runner(function() {
|
||||
return runner.stop();
|
||||
});
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime-declared.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
let runner: Runner;
|
||||
runner = new Runner(() => runner.stop());
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
[
|
||||
"reexports.ts",
|
||||
"src/shared/iterator.ts",
|
||||
"src/features/chat/ui/form-state.tsx",
|
||||
"src/features/chat/ui/controlled-form-state.tsx",
|
||||
"src/plugin-runtime.ts",
|
||||
"src/plugin-runtime-function.ts",
|
||||
"src/plugin-runtime-declared.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginDiagnostics(report, "reexports.ts")).toEqual([
|
||||
{ line: 1, column: 8, endLine: 1, endColumn: 33 },
|
||||
{ line: 2, column: 8, endLine: 2, endColumn: 26 },
|
||||
]);
|
||||
expect(pluginMessages(report, "src/shared/iterator.ts")).toEqual([
|
||||
"Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/ui/form-state.tsx")).toEqual([
|
||||
"Keep Preact form state explicit with controlled value or checked props.",
|
||||
"Keep Preact form state explicit with controlled value or checked props.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/ui/controlled-form-state.tsx")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/plugin-runtime.ts")).toEqual([
|
||||
"Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/plugin-runtime-function.ts")).toEqual([
|
||||
"Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/plugin-runtime-declared.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps Preact signals behind the chat shell-state adapter", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-chat-signal-imports.grit"]);
|
||||
it("keeps chat architecture policies behind their intended boundaries", async () => {
|
||||
const cwd = await tempBiomeWorkspace([
|
||||
"no-chat-domain-outer-layer-imports.grit",
|
||||
"no-chat-signal-imports.grit",
|
||||
"no-implicit-dom-bridges.grit",
|
||||
"no-pure-chat-state-side-effects.grit",
|
||||
"no-ui-root-imports.grit",
|
||||
]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/panel/shell-state.tsx"),
|
||||
`
|
||||
|
|
@ -62,26 +161,6 @@ const signals = require("@preact/signals");
|
|||
export const loadedSignals = signals;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
["src/features/chat/panel/shell-state.tsx", "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginDiagnostics(report, "src/features/chat/panel/shell-state.tsx")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/shared/ui/components.tsx")).toEqual([
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/shared/ui/signal-escapes.tsx")).toEqual([
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps chat domain independent from outer chat layers", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-chat-domain-outer-layer-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/domain/message-stream/selectors.ts"),
|
||||
`
|
||||
|
|
@ -112,18 +191,113 @@ export async function loadComposer() {
|
|||
|
||||
const host = require("../../host/session");
|
||||
export const outerHost = host;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/dom-bridge-escape.tsx"),
|
||||
`
|
||||
export function render(container: HTMLElement): void {
|
||||
container.createDiv();
|
||||
container.addEventListener("click", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/dom-bridge.dom.tsx"),
|
||||
`
|
||||
export function render(container: HTMLElement): void {
|
||||
container.createDiv();
|
||||
container.addEventListener("click", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/services/abortable-operation.ts"),
|
||||
`
|
||||
export function onAbort(signal: AbortSignal): void {
|
||||
signal.addEventListener("abort", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/root-import.tsx"),
|
||||
`
|
||||
import { renderUiRoot } from '../../../shared/ui/ui-root.dom';
|
||||
|
||||
export const render = renderUiRoot;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/panel/shell.dom.tsx"),
|
||||
`
|
||||
import { renderUiRoot } from "../../../shared/ui/ui-root.dom";
|
||||
|
||||
export const render = renderUiRoot;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/root-escapes.tsx"),
|
||||
`
|
||||
export { renderUiRoot } from "../../../shared/ui/ui-root.dom";
|
||||
export type RootRenderer = import("../../../shared/ui/ui-root.dom").RootRenderer;
|
||||
|
||||
export async function loadRoot() {
|
||||
return import("../../../shared/ui/ui-root.dom");
|
||||
}
|
||||
|
||||
const root = require("../../../shared/ui/ui-root.dom");
|
||||
export const loadedRoot = root;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/application/state/message-stream.ts"),
|
||||
`
|
||||
export function timestamp(): number {
|
||||
setTimeout(() => undefined, 1);
|
||||
return Date.now();
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/application/threads/resume-actions.ts"),
|
||||
`
|
||||
export function timestamp(): number {
|
||||
setTimeout(() => undefined, 1);
|
||||
return Date.now();
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
[
|
||||
"src/features/chat/panel/shell-state.tsx",
|
||||
"src/shared/ui/components.tsx",
|
||||
"src/shared/ui/signal-escapes.tsx",
|
||||
"src/features/chat/domain/message-stream/selectors.ts",
|
||||
"src/features/chat/domain/message-stream/items.ts",
|
||||
"src/features/chat/domain/message-stream/outer-shapes.ts",
|
||||
"src/features/chat/ui/dom-bridge-escape.tsx",
|
||||
"src/features/chat/ui/dom-bridge.dom.tsx",
|
||||
"src/app-server/services/abortable-operation.ts",
|
||||
"src/features/chat/ui/root-import.tsx",
|
||||
"src/features/chat/panel/shell.dom.tsx",
|
||||
"src/features/chat/ui/root-escapes.tsx",
|
||||
"src/features/chat/application/state/message-stream.ts",
|
||||
"src/features/chat/application/threads/resume-actions.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginDiagnostics(report, "src/features/chat/panel/shell-state.tsx")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/shared/ui/components.tsx")).toEqual([
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/shared/ui/signal-escapes.tsx")).toEqual([
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/domain/message-stream/selectors.ts")).toEqual([
|
||||
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
|
||||
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
|
||||
|
|
@ -135,9 +309,30 @@ export const outerHost = host;
|
|||
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
|
||||
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/ui/dom-bridge-escape.tsx")).toEqual([
|
||||
"Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.",
|
||||
"Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/ui/dom-bridge.dom.tsx")).toEqual([]);
|
||||
expect(pluginDiagnostics(report, "src/app-server/services/abortable-operation.ts")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/features/chat/ui/root-import.tsx")).toEqual([
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/panel/shell.dom.tsx")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/features/chat/ui/root-escapes.tsx")).toEqual([
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/application/state/message-stream.ts")).toEqual([
|
||||
"Keep chat state transforms deterministic and free of app-server, Obsidian, scheduling, and browser side effects.",
|
||||
"Keep chat state transforms deterministic and free of app-server, Obsidian, scheduling, and browser side effects.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/application/threads/resume-actions.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks generated app-server imports outside app-server boundaries", async () => {
|
||||
it("keeps generated app-server imports behind explicit app-server boundaries", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-generated-app-server-boundary-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/domain/generated-thread.ts"),
|
||||
|
|
@ -163,6 +358,40 @@ export type GeneratedThread = import("../../generated/app-server/v2/Thread").Thr
|
|||
path.join(cwd, "tests/app-server/generated-thread.test.ts"),
|
||||
`
|
||||
export type GeneratedThread = import("../../src/generated/app-server/v2/Thread").Thread;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/request-input.ts"),
|
||||
`
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
|
||||
export type Input = UserInput;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/services/runtime-overrides.ts"),
|
||||
`
|
||||
import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse";
|
||||
|
||||
export interface RuntimeOverrideModelClient {
|
||||
listModels(includeHidden: boolean): Promise<ModelListResponse>;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/turn.ts"),
|
||||
`
|
||||
import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem";
|
||||
|
||||
export type TurnItem = GeneratedTurnItem;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/server-requests.ts"),
|
||||
`
|
||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
|
||||
export type Request = ServerRequest;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
|
|
@ -172,6 +401,10 @@ export type GeneratedThread = import("../../src/generated/app-server/v2/Thread")
|
|||
"src/features/chat/domain/generated-thread-import.ts",
|
||||
"src/app-server/connection/generated-thread.ts",
|
||||
"tests/app-server/generated-thread.test.ts",
|
||||
"src/app-server/protocol/request-input.ts",
|
||||
"src/app-server/services/runtime-overrides.ts",
|
||||
"src/app-server/protocol/turn.ts",
|
||||
"src/app-server/protocol/server-requests.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
|
@ -184,6 +417,16 @@ export type GeneratedThread = import("../../src/generated/app-server/v2/Thread")
|
|||
]);
|
||||
expect(pluginDiagnostics(report, "src/app-server/connection/generated-thread.ts")).toEqual([]);
|
||||
expect(pluginDiagnostics(report, "tests/app-server/generated-thread.test.ts")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/app-server/protocol/request-input.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/app-server/services/runtime-overrides.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps app-server protocol modules behind app-server and chat ingestion boundaries", async () => {
|
||||
|
|
@ -222,32 +465,6 @@ import type { TurnItem } from "../../../../../app-server/protocol/turn";
|
|||
export type Item = TurnItem;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
[
|
||||
"src/features/chat/application/pending-requests/pending-request-actions.ts",
|
||||
"src/features/chat/application/threads/history-controller.ts",
|
||||
"src/features/chat/app-server/inbound/notification-plan.ts",
|
||||
"src/features/chat/app-server/mappers/message-stream/turn-items.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/application/pending-requests/pending-request-actions.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/application/threads/history-controller.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/notification-plan.ts")).toEqual([
|
||||
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
|
||||
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/app-server/mappers/message-stream/turn-items.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps server request protocol imports in chat request boundaries only", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-app-server-protocol-boundary-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/panel/surface/message-stream-presenter.ts"),
|
||||
`
|
||||
|
|
@ -283,6 +500,10 @@ export const response = appServerUserInputResponse;
|
|||
|
||||
const report = biomeLint(
|
||||
[
|
||||
"src/features/chat/application/pending-requests/pending-request-actions.ts",
|
||||
"src/features/chat/application/threads/history-controller.ts",
|
||||
"src/features/chat/app-server/inbound/notification-plan.ts",
|
||||
"src/features/chat/app-server/mappers/message-stream/turn-items.ts",
|
||||
"src/features/chat/panel/surface/message-stream-presenter.ts",
|
||||
"src/features/chat/app-server/inbound/app-server-logs.ts",
|
||||
"src/features/chat/app-server/inbound/handler.ts",
|
||||
|
|
@ -291,6 +512,17 @@ export const response = appServerUserInputResponse;
|
|||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/application/pending-requests/pending-request-actions.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/application/threads/history-controller.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/features/chat/app-server/inbound/notification-plan.ts")).toEqual([
|
||||
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
|
||||
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/app-server/mappers/message-stream/turn-items.ts")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/features/chat/panel/surface/message-stream-presenter.ts")).toEqual([
|
||||
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
|
||||
]);
|
||||
|
|
@ -303,65 +535,6 @@ export const response = appServerUserInputResponse;
|
|||
expect(pluginDiagnostics(report, "src/features/chat/app-server/inbound/routing.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps generated app-server bindings behind explicit exceptions", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-generated-app-server-boundary-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/request-input.ts"),
|
||||
`
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
|
||||
export type Input = UserInput;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/services/runtime-overrides.ts"),
|
||||
`
|
||||
import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse";
|
||||
|
||||
export interface RuntimeOverrideModelClient {
|
||||
listModels(includeHidden: boolean): Promise<ModelListResponse>;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/turn.ts"),
|
||||
`
|
||||
import type { ThreadItem as GeneratedTurnItem } from "../../generated/app-server/v2/ThreadItem";
|
||||
|
||||
export type TurnItem = GeneratedTurnItem;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/protocol/server-requests.ts"),
|
||||
`
|
||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
|
||||
export type Request = ServerRequest;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
[
|
||||
"src/app-server/protocol/request-input.ts",
|
||||
"src/app-server/services/runtime-overrides.ts",
|
||||
"src/app-server/protocol/turn.ts",
|
||||
"src/app-server/protocol/server-requests.ts",
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/app-server/protocol/request-input.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/app-server/services/runtime-overrides.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([
|
||||
"Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/app-server/protocol/server-requests.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps lower-level source independent from connection and feature layers", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-lower-level-boundary-imports.grit"]);
|
||||
await writeFile(
|
||||
|
|
@ -568,229 +741,6 @@ export type AppServerThreadResumeClient = Pick<AppServerClient, "resumeThread">;
|
|||
expect(pluginDiagnostics(report, "src/features/chat/host/connection-bundle.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps imperative DOM bridges behind filename suffixes", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-implicit-dom-bridges.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/composer.tsx"),
|
||||
`
|
||||
export function render(container: HTMLElement): void {
|
||||
container.createDiv();
|
||||
container.addEventListener("click", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/composer.dom.tsx"),
|
||||
`
|
||||
export function render(container: HTMLElement): void {
|
||||
container.createDiv();
|
||||
container.addEventListener("click", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/app-server/services/abortable-operation.ts"),
|
||||
`
|
||||
export function onAbort(signal: AbortSignal): void {
|
||||
signal.addEventListener("abort", () => undefined);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
["src/features/chat/ui/composer.tsx", "src/features/chat/ui/composer.dom.tsx", "src/app-server/services/abortable-operation.ts"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/ui/composer.tsx")).toEqual([
|
||||
"Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.",
|
||||
"Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/ui/composer.dom.tsx")).toEqual([]);
|
||||
expect(pluginDiagnostics(report, "src/app-server/services/abortable-operation.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the Preact root adapter in explicit root bridge files", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-ui-root-imports.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/composer.tsx"),
|
||||
`
|
||||
import { renderUiRoot } from '../../../shared/ui/ui-root.dom';
|
||||
|
||||
export const render = renderUiRoot;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/panel/shell.dom.tsx"),
|
||||
`
|
||||
import { renderUiRoot } from "../../../shared/ui/ui-root.dom";
|
||||
|
||||
export const render = renderUiRoot;
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/root-escapes.tsx"),
|
||||
`
|
||||
export { renderUiRoot } from "../../../shared/ui/ui-root.dom";
|
||||
export type RootRenderer = import("../../../shared/ui/ui-root.dom").RootRenderer;
|
||||
|
||||
export async function loadRoot() {
|
||||
return import("../../../shared/ui/ui-root.dom");
|
||||
}
|
||||
|
||||
const root = require("../../../shared/ui/ui-root.dom");
|
||||
export const loadedRoot = root;
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
["src/features/chat/ui/composer.tsx", "src/features/chat/panel/shell.dom.tsx", "src/features/chat/ui/root-escapes.tsx"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/ui/composer.tsx")).toEqual([
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/panel/shell.dom.tsx")).toEqual([]);
|
||||
expect(pluginMessages(report, "src/features/chat/ui/root-escapes.tsx")).toEqual([
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
"Import the Preact root adapter only from explicit root bridge files.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps chat state transforms pure and catches global scheduling calls", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-pure-chat-state-side-effects.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/application/state/message-stream.ts"),
|
||||
`
|
||||
export function timestamp(): number {
|
||||
setTimeout(() => undefined, 1);
|
||||
return Date.now();
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/application/threads/resume-actions.ts"),
|
||||
`
|
||||
export function timestamp(): number {
|
||||
setTimeout(() => undefined, 1);
|
||||
return Date.now();
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(
|
||||
["src/features/chat/application/state/message-stream.ts", "src/features/chat/application/threads/resume-actions.ts"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/application/state/message-stream.ts")).toEqual([
|
||||
"Keep chat state transforms deterministic and free of app-server, Obsidian, scheduling, and browser side effects.",
|
||||
"Keep chat state transforms deterministic and free of app-server, Obsidian, scheduling, and browser side effects.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/application/threads/resume-actions.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports unsafe iterator value reads", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-unsafe-iterator-value.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/shared/iterator.ts"),
|
||||
`
|
||||
export function first<T>(iterator: Iterator<T>): T | undefined {
|
||||
return iterator.next().value;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(["src/shared/iterator.ts"], cwd);
|
||||
|
||||
expect(pluginMessages(report, "src/shared/iterator.ts")).toEqual([
|
||||
"Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports uncontrolled Preact form state attributes", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-uncontrolled-preact-form-state.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/composer.tsx"),
|
||||
`
|
||||
export function Composer(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<input defaultValue="draft" />
|
||||
<input defaultChecked={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/features/chat/ui/controlled-composer.tsx"),
|
||||
`
|
||||
export function Composer(): JSX.Element {
|
||||
return <input value="draft" />;
|
||||
}
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(["src/features/chat/ui/composer.tsx", "src/features/chat/ui/controlled-composer.tsx"], cwd);
|
||||
|
||||
expect(pluginMessages(report, "src/features/chat/ui/composer.tsx")).toEqual([
|
||||
"Keep Preact form state explicit with controlled value or checked props.",
|
||||
"Keep Preact form state explicit with controlled value or checked props.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/features/chat/ui/controlled-composer.tsx")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps initializer callbacks from capturing their own variable", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-self-referential-initializer-callback.grit"]);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
const runner = new Runner(() => runner.stop());
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime-function.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
const runner = new Runner(function() {
|
||||
return runner.stop();
|
||||
});
|
||||
`.trimStart(),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, "src/plugin-runtime-declared.ts"),
|
||||
`
|
||||
class Runner {
|
||||
constructor(readonly stop: () => void) {}
|
||||
}
|
||||
|
||||
let runner: Runner;
|
||||
runner = new Runner(() => runner.stop());
|
||||
`.trimStart(),
|
||||
);
|
||||
|
||||
const report = biomeLint(["src/plugin-runtime.ts", "src/plugin-runtime-function.ts", "src/plugin-runtime-declared.ts"], cwd);
|
||||
|
||||
expect(pluginMessages(report, "src/plugin-runtime.ts")).toEqual([
|
||||
"Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.",
|
||||
]);
|
||||
expect(pluginMessages(report, "src/plugin-runtime-function.ts")).toEqual([
|
||||
"Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.",
|
||||
]);
|
||||
expect(pluginDiagnostics(report, "src/plugin-runtime-declared.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps CSS on design tokens and scoped selectors", async () => {
|
||||
const cwd = await tempBiomeWorkspace(["no-restricted-css-policy.grit"]);
|
||||
await writeFile(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
|||
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
|
||||
import type { ObservedDataResult } from "../../src/domain/observed-data";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { threadArchiveDisplayTitle } from "../../src/domain/threads/title";
|
||||
import { SettingsDynamicDataController, type SettingsDynamicDataSnapshot } from "../../src/settings/dynamic-data-controller";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
|
||||
|
|
@ -32,22 +31,6 @@ describe("settings tab", () => {
|
|||
notices.length = 0;
|
||||
});
|
||||
|
||||
it("uses a placeholder for threads without a useful title", () => {
|
||||
expect(threadArchiveDisplayTitle(panelThread({ name: null, preview: "" }))).toBe("Untitled thread");
|
||||
expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "" }))).toBe("Untitled thread");
|
||||
expect(threadArchiveDisplayTitle(panelThread({ name: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", preview: "Preview title" }))).toBe(
|
||||
"Preview title",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes and truncates archived thread titles", () => {
|
||||
expect(threadArchiveDisplayTitle(panelThread({ preview: "A title\nwith extra\tspace" }))).toBe("A title with extra space");
|
||||
|
||||
const title = threadArchiveDisplayTitle(panelThread({ preview: "x".repeat(120) }));
|
||||
expect(title).toHaveLength(96);
|
||||
expect(title.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-loads settings data once and keeps one global refresh button", async () => {
|
||||
const client = settingsClient();
|
||||
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
|
||||
|
|
@ -73,34 +56,6 @@ describe("settings tab", () => {
|
|||
expect(buttonTexts(tab)).not.toContain("Load models");
|
||||
expect(buttonTexts(tab)).not.toContain("Load hooks");
|
||||
expect(buttonTexts(tab)).not.toContain("Load archive list");
|
||||
expect(
|
||||
tab.containerEl.querySelector(".codex-panel-settings__header.setting-item-heading .setting-item-description")?.textContent,
|
||||
).toContain("Codex Panel stores panel preferences only");
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__header button")?.getAttribute("data-icon")).toBe("refresh-cw");
|
||||
expect(tab.containerEl.querySelector("h2")).toBeNull();
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__general-section.setting-group > .setting-items")?.children).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__composer-section.setting-group > .setting-items")?.children).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__helper-section.setting-group > .setting-items")?.children).toHaveLength(2);
|
||||
expect(
|
||||
tab.containerEl.querySelector(".codex-panel-settings__archived-section > .setting-item-heading .setting-item-description")
|
||||
?.textContent,
|
||||
).toContain("Set the default archive action");
|
||||
expect(
|
||||
tab.containerEl.querySelector(".codex-panel-settings__archived-threads-section > .setting-item-heading .setting-item-description")
|
||||
?.textContent,
|
||||
).toContain("Restore or permanently delete");
|
||||
expect(
|
||||
tab.containerEl.querySelector(".codex-panel-settings__hook-section > .setting-item-heading .setting-item-description")?.textContent,
|
||||
).toContain("Trust, enable, or disable");
|
||||
expect(
|
||||
tab.containerEl.querySelector(
|
||||
".codex-panel-settings__archived-section.setting-group > .setting-items:not(.codex-panel-settings__dynamic-list)",
|
||||
)?.children,
|
||||
).toHaveLength(4);
|
||||
expect(settingNames(tab)).toEqual([
|
||||
"Codex executable",
|
||||
"Show chat toolbar",
|
||||
|
|
@ -767,7 +722,6 @@ describe("settings tab", () => {
|
|||
|
||||
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
|
||||
expect(tab.containerEl.textContent).toContain("Permanently delete this archived thread? This cannot be undone.");
|
||||
expect(tab.containerEl.querySelector("[aria-label='Delete thread']")?.getAttribute("data-icon")).toBe("check");
|
||||
|
||||
tab.containerEl.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue