diff --git a/scripts/generate-app-server-types.mjs b/scripts/generate-app-server-types.mjs index c1cd0704..55ed9d9e 100644 --- a/scripts/generate-app-server-types.mjs +++ b/scripts/generate-app-server-types.mjs @@ -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, }); diff --git a/tests/domain/threads/threads.test.ts b/tests/domain/threads/threads.test.ts index da504855..8de753f1 100644 --- a/tests/domain/threads/threads.test.ts +++ b/tests/domain/threads/threads.test.ts @@ -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", () => { diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 00dc680a..259a64e3 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -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(".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" } }); diff --git a/tests/features/chat/ui/renderers/composer.test.ts b/tests/features/chat/ui/renderers/composer.test.ts index 18f9a42d..e177fbd3 100644 --- a/tests/features/chat/ui/renderers/composer.test.ts +++ b/tests/features/chat/ui/renderers/composer.test.ts @@ -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"); diff --git a/tests/features/chat/ui/renderers/goal.test.tsx b/tests/features/chat/ui/renderers/goal.test.tsx index 42e7b384..409c5f2e 100644 --- a/tests/features/chat/ui/renderers/goal.test.tsx +++ b/tests/features/chat/ui/renderers/goal.test.tsx @@ -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"); diff --git a/tests/features/chat/ui/renderers/toolbar.test.ts b/tests/features/chat/ui/renderers/toolbar.test.ts index 7305b565..a39a79e2 100644 --- a/tests/features/chat/ui/renderers/toolbar.test.ts +++ b/tests/features/chat/ui/renderers/toolbar.test.ts @@ -41,23 +41,14 @@ describe("Toolbar decisions", () => { expect(parent.querySelector(".codex-panel__runtime-model")).toBeNull(); const newChatButton = parent.querySelector(".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(".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('[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(".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('[aria-label="Save thread name"]')).toBeNull(); - expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); parent.querySelector('[aria-label="Auto-name thread"]')?.click(); - expect(parent.querySelector('[aria-label="Auto-name thread"]')?.classList.contains("nav-action-button")).toBe(false); expect(autoNameThread).toHaveBeenCalledWith("editing"); }); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 0ff15c54..b640a15c 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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 { - 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(); diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index ea77f779..74242b0a 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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); + } }); }); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 1ff9c340..06af2f2e 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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" })] }), diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index c3b86729..6fee9ee2 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -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 }); diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 43aac7e8..5c059061 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -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(iterator: Iterator): 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 ( + <> + + + + ); +} +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/ui/controlled-form-state.tsx"), + ` +export function Composer(): JSX.Element { + return ; +} +`.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; +} +`.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; -} -`.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; 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(iterator: Iterator): 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 ( - <> - - - - ); -} -`.trimStart(), - ); - await writeFile( - path.join(cwd, "src/features/chat/ui/controlled-composer.tsx"), - ` -export function Composer(): JSX.Element { - return ; -} -`.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( diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 454d555c..3ad09007 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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 }));