diff --git a/apps/gpuix/tests/_shared/frame-pacing-native.tsx b/apps/gpuix/tests/_shared/frame-pacing-native.tsx index 52256a6c..4e6309db 100644 --- a/apps/gpuix/tests/_shared/frame-pacing-native.tsx +++ b/apps/gpuix/tests/_shared/frame-pacing-native.tsx @@ -62,6 +62,8 @@ const pump = async (): Promise => { }; try { + await pump(); + renderer.activateWindow(); await pump(); assert.ok(scrollId, "scroll surface must be mounted"); @@ -77,7 +79,7 @@ try { bounds.x + 100, bounds.y + 80, 0, - commitEditor ? 8 : -8 + commitEditor && event % 2 === 0 ? 8 : -8 ); } @@ -99,12 +101,10 @@ try { for (let burst = 0; burst < 4; burst++) { const frames = renderer.getDebugFrameOverlayStats().frames; - renderer.simulateScrollWheel( - bounds.x + 100, - bounds.y + 80, - 0, - commitEditor ? 8 : -8 - ); + // A textarea can start at either scroll boundary after following its caret. + for (const delta of commitEditor ? [8, -8] : [-8]) + renderer.simulateScrollWheel(bounds.x + 100, bounds.y + 80, 0, delta); + await pump(); assert.ok( renderer.getDebugFrameOverlayStats().frames > frames, diff --git a/apps/gpuix/tests/_shared/session-exit.tsx b/apps/gpuix/tests/_shared/session-exit.tsx index 31a42e53..dedbeeb2 100644 --- a/apps/gpuix/tests/_shared/session-exit.tsx +++ b/apps/gpuix/tests/_shared/session-exit.tsx @@ -1,4 +1,5 @@ import { createTestRoot } from "@gpuix/react/testing"; +import { TestGpuixRenderer } from "@gpuix/native"; import { App } from "../../src/app"; import { WorkspaceSessionStore } from "../../src/feature/workspace-tabs/lib/workspace-session-store"; import { repositorySnapshot } from "./repository-snapshot"; @@ -29,5 +30,12 @@ if (!message) throw new Error("Missing draft editor."); root.renderer.focusElement(message.id); root.renderer.simulateKeystrokes("cmd-a e x i t"); root.renderer.flush(); +const dispose: unknown = Reflect.get(TestGpuixRenderer, "disposeCurrent"); + +if (typeof dispose !== "function") + throw new Error("Missing native test-session teardown patch."); + +// Dispose the test window after the app's exit autosave, before Rust TLS teardown. +process.once("exit", () => dispose()); // GPUIX's normal window-close path calls process.exit; deliberately bypass the autosave timer and unmount. process.exit(0); diff --git a/apps/gpuix/tests/_shared/vibrancy-native.tsx b/apps/gpuix/tests/_shared/vibrancy-native.tsx index b60d8f9a..a3496697 100644 --- a/apps/gpuix/tests/_shared/vibrancy-native.tsx +++ b/apps/gpuix/tests/_shared/vibrancy-native.tsx @@ -82,6 +82,21 @@ const applicationAppearance = send( ); send(app, "setAppearance:", applicationAppearance); +const setWindowAppearance: unknown = Reflect.get( + renderer, + "setWindowAppearance" +); + +assert.ok( + typeof setWindowAppearance === "function", + "native appearance patch must be present" +); +assert.throws( + () => Reflect.apply(setWindowAppearance, renderer, ["invalid"]), + /light or dark/ +); +// App normally sets the window theme; this probe renders AppShell directly. +Reflect.apply(setWindowAppearance, renderer, [process.argv[2]]); render( Reflect.apply(setWindowAppearance, renderer, ["invalid"]), - /light or dark/ + "the native backdrop must inherit the selected window appearance" ); for (const choice of ["dark", "light", "dark"] as const) { diff --git a/apps/gpuix/tests/packaging/launch.integration.test.ts b/apps/gpuix/tests/packaging/launch.integration.test.ts index 2a34b5d2..3250b7fd 100644 --- a/apps/gpuix/tests/packaging/launch.integration.test.ts +++ b/apps/gpuix/tests/packaging/launch.integration.test.ts @@ -1,6 +1,6 @@ /** * Covers: archive/helper integrity and foreground launch through macOS Launch Services. - * Real: extracted signed app, native frames, and GPUIX's stdio automation protocol. + * Real: extracted signed app, native tree/image, and GPUIX's stdio automation protocol. * Doubles: fresh isolated session/accounts; no hosting or AI provider requests. * Requires: Apple Silicon macOS graphics access; run root test:package:macos. */ @@ -15,6 +15,7 @@ import { closeSync, constants, openSync, writeSync } from "node:fs"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; +import sharp from "sharp"; import { createSseDecoder, encodeSse, @@ -215,21 +216,37 @@ it("opens the extracted ZIP without a terminal or checkout-dependent resources", expect(pid).toBeGreaterThan(1); const deadline = Date.now() + 10000; - let painted: string[] = []; + let text: string[] = []; - while (!painted.includes("Welcome to 1git") && Date.now() < deadline) { - painted = methods.getPaintedText.result.parse( - await request("getPaintedText", {}) + while (!text.includes("Welcome to 1git") && Date.now() < deadline) { + text = methods.getAllText.result.parse( + await request("getAllText", {}) ).text; await Bun.sleep(50); } expect( - painted, + text, await Bun.file(resolve(directory, "stderr.log")).text() ).toContain("Welcome to 1git"); - expect(painted).toContain("Continue"); - expect(painted).not.toContain("Uncaught runtime errors:"); + expect(text).toContain("Continue"); + expect(text).not.toContain("Uncaught runtime errors:"); + // The last-frame text log can be empty when GPUI reuses cached drawing. + const screenshot = resolve(directory, "launch.png"); + let rendered = false; + + do { + await request("screenshot", { path: screenshot }); + const pixels = await sharp(screenshot).stats(); + + rendered = pixels.channels.slice(0, 3).some(({ stdev }) => stdev > 2); + + if (!rendered) await Bun.sleep(50); + } while (!rendered && Date.now() < deadline); + + expect(rendered, "The native window must render beyond a blank frame").toBe( + true + ); } finally { // If initialization failed, locate only this test's unique extracted executable. if (pid === undefined) { diff --git a/apps/gpuix/tests/repository-file-actions/discard.integration.test.ts b/apps/gpuix/tests/repository-file-actions/discard.integration.test.ts index 0d506da1..393c639f 100644 --- a/apps/gpuix/tests/repository-file-actions/discard.integration.test.ts +++ b/apps/gpuix/tests/repository-file-actions/discard.integration.test.ts @@ -179,7 +179,7 @@ it("leaves the index and working files untouched if a streamed backup cannot be await git("add", "a.txt"); await writeFile(join(root, "a.txt"), "working\n"); const originalIndex = await readFile(join(root, ".git/index")); - const filesystem = await import("node:fs/promises"); + const filesystem = await import("@1git/git/runtime"); const originalOpen = filesystem.open; spyOn(filesystem, "open").mockImplementation(async (...args) => { @@ -194,6 +194,11 @@ it("leaves the index and working files untouched if a streamed backup cannot be }); const result = await discard("a.txt"); + expect(filesystem.open).toHaveBeenCalledWith( + join(root, ".git/recovery/working.patch"), + "wx", + 0o600 + ); expect(result.ok).toBe(false); expect(await readFile(join(root, ".git/index"))).toEqual(originalIndex); expect(await readFile(join(root, "a.txt"), "utf8")).toBe("working\n"); diff --git a/apps/gpuix/tests/repository-pull-request/preview.component.test.tsx b/apps/gpuix/tests/repository-pull-request/preview.component.test.tsx index a6792557..bebedd0e 100644 --- a/apps/gpuix/tests/repository-pull-request/preview.component.test.tsx +++ b/apps/gpuix/tests/repository-pull-request/preview.component.test.tsx @@ -371,7 +371,9 @@ it.each(["publish", "push"] as const)( expectedHead: head, expectedBranch: "feature/ui", }), - expect.any(AbortSignal) + expect.any(AbortSignal), + undefined, + expect.any(Function) ); expect(browser).not.toHaveBeenCalled(); expect(root.renderer.getPaintedText().join(" ")).toContain( diff --git a/apps/gpuix/tests/repository-setup/setup.component.test.tsx b/apps/gpuix/tests/repository-setup/setup.component.test.tsx index e704d85e..2332b877 100644 --- a/apps/gpuix/tests/repository-setup/setup.component.test.tsx +++ b/apps/gpuix/tests/repository-setup/setup.component.test.tsx @@ -183,6 +183,9 @@ for (const appearance of ["light", "dark"] as const) { await settle(); click("welcome-view-list"); click("welcome-folder-other"); + click("welcome-search"); + root.renderer.nativeSimulateMouseMove(0, 0); + root.renderer.flush(); const action = () => root.renderer.findByTestId("welcome-forget-/test/project"); @@ -650,9 +653,12 @@ it("searches remembered repositories after closing all tabs and reopens without root.renderer.flush(); click("welcome-view-list"); click("welcome-folder-other"); + await settle(); + const readsBeforeOpening = reader.mock.calls.length; + click("welcome-repository-/test/project"); await settle(); - expect(reader).toHaveBeenCalledTimes(2); + expect(reader).toHaveBeenCalledTimes(readsBeforeOpening + 1); expect(picker).not.toHaveBeenCalled(); expect(root.renderer.findByTestId("commit-composer")).toBeDefined(); }); diff --git a/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx b/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx index 554c9521..e0fdee82 100644 --- a/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx +++ b/apps/gpuix/tests/repository-workspace/repository-workspace.component.test.tsx @@ -57,7 +57,7 @@ it("decorates a clean working tree without disguising refresh errors as an empty render("Refresh failed"); expect(root.renderer.findByTestId("empty-list-placeholder")).toBeUndefined(); expect(root.renderer.findByTestId("comparison-art-empty")).toBeUndefined(); - expect(root.renderer.getAllText()).toContain("Could not refresh repository"); + expect(root.renderer.getAllText()).toContain("Could not read repository"); }); beforeEach(() => { @@ -266,7 +266,7 @@ it("pins the composer below the scrolling Changes list and hides it in revision ).toBeLessThanOrEqual( bounds("repository-navigation").y + bounds("repository-navigation").height ); - expect(element("commit-message").style.height).toBe(100); + expect(bounds("commit-message").height).toBeGreaterThanOrEqual(100); expect(root.renderer.getPaintedText()).toContain("Commit message…"); key("entry-src/app.tsx", "end"); expect(scrollY()).toBeLessThan(0); @@ -497,7 +497,9 @@ const selectedView = () => (node) => node.aria?.role === "Tab" && node.aria.selected && - ["Changes", "History", "Stashes"].includes(node.aria.label ?? "") + ["Changes", "History", "Stashes", "Graph"].includes( + node.aria.label ?? "" + ) ) .map((node) => node.aria?.label); @@ -509,8 +511,8 @@ it("stacks compact borderless rail tabs outside the white workspace", () => { expect(history.x).toBe(changes.x); expect(stashes.x).toBe(changes.x); - expect(history.y).toBe(changes.y + changes.height + 6); - expect(stashes.y).toBe(history.y + history.height + 6); + expect(history.y).toBe(changes.y + changes.height + 4); + expect(stashes.y).toBe(history.y + history.height + 4); expect(changes.x + changes.width).toBeLessThan(workspace.x); for (const view of ["changes", "history", "stashes"]) { @@ -561,12 +563,12 @@ it("tightens sidebar headers without shrinking rail buttons, file rows or checkb it("wraps vertical rail arrows, supports Home/End and Enter/Space, and ignores modified navigation", () => { key("view-changes", "up"); - expect(selectedView()).toEqual(["Stashes"]); - key("view-stashes", "down"); + expect(selectedView()).toEqual(["Graph"]); + key("view-graph", "down"); expect(selectedView()).toEqual(["Changes"]); key("view-changes", "end"); - expect(selectedView()).toEqual(["Stashes"]); - key("view-stashes", "home"); + expect(selectedView()).toEqual(["Graph"]); + key("view-graph", "home"); expect(selectedView()).toEqual(["Changes"]); key("view-changes", "shift-right"); expect(selectedView()).toEqual(["Changes"]); @@ -819,6 +821,11 @@ it("supports arrow-key rail selection and entry/exit with Tab", () => { root.renderer.simulateKeystrokes("tab"); root.renderer.flush(); notifyListFocus(); + expect(root.renderer.getFocusedElementId()).toBe( + element("repository-list-scrollbar-vertical").id + ); + root.renderer.simulateKeystrokes("tab"); + root.renderer.flush(); expect(root.renderer.getFocusedElementId()).toBe( element("workspace-home").id ); diff --git a/apps/gpuix/tests/window-chrome/vibrancy.integration.test.ts b/apps/gpuix/tests/window-chrome/vibrancy.integration.test.ts index 00a1e73c..765aecc1 100644 --- a/apps/gpuix/tests/window-chrome/vibrancy.integration.test.ts +++ b/apps/gpuix/tests/window-chrome/vibrancy.integration.test.ts @@ -14,7 +14,7 @@ it.each([ ["light", "#a478ef"], ["dark", "#a478ef"], ] as const)( - "keeps native blur over %s application appearance with repo color %s", + "keeps native blur over %s window appearance with repo color %s", (appearance, color) => { const output = execFileSync( process.execPath, diff --git a/apps/gpuix/tests/workspace-session/restore.component.test.tsx b/apps/gpuix/tests/workspace-session/restore.component.test.tsx index 3eb1a63f..84a150b1 100644 --- a/apps/gpuix/tests/workspace-session/restore.component.test.tsx +++ b/apps/gpuix/tests/workspace-session/restore.component.test.tsx @@ -207,8 +207,8 @@ it("keeps an unavailable repository and editable draft, then retries its saved p ); await settle(); - expect(root.renderer.getAllText()).toContain("Repository unavailable"); - expect(root.renderer.getAllText()).toContain("/test/project"); + expect(root.renderer.getAllText()).toContain("Could not read repository"); + expect(reader).toHaveBeenCalledWith("/test/project", expect.any(AbortSignal)); expect(node("commit-message").customProps?.value).toBe("Main draft"); await Bun.sleep(240); root.renderer.flush(); @@ -224,7 +224,7 @@ it("keeps an unavailable repository and editable draft, then retries its saved p await settle(); expect(reader.mock.calls[1]?.[0]).toBe("/test/project"); expect(node("commit-message").customProps?.value).toBe("recovered"); - expect(root.renderer.getAllText()).not.toContain("Repository unavailable"); + expect(root.renderer.getAllText()).not.toContain("Could not read repository"); root.render(
); expect(save.mock.calls.at(-1)?.[0].tabs[0]).toMatchObject({ path: "/test/project", diff --git a/apps/gpuix/tests/workspace-tabs/home.component.test.tsx b/apps/gpuix/tests/workspace-tabs/home.component.test.tsx index cc8614e5..0a739074 100644 --- a/apps/gpuix/tests/workspace-tabs/home.component.test.tsx +++ b/apps/gpuix/tests/workspace-tabs/home.component.test.tsx @@ -106,9 +106,14 @@ function tabCount() { return Object.values(root.renderer.getA11yTree().nodes ?? {}).filter( (item) => item.aria?.role === "Tab" && - !["Changes", "History", "Stashes", "Tile view", "List view"].includes( - item.aria.label ?? "" - ) + ![ + "Changes", + "History", + "Stashes", + "Graph", + "Tile view", + "List view", + ].includes(item.aria.label ?? "") ).length; } @@ -284,7 +289,9 @@ it("preserves tabs, drafts, checked files and selection and reuses an already-op expect(node("include-a.ts").customProps?.["aria-checked"]).toBe(false); expect(node("entry-a.ts").customProps?.["aria-selected"]).toBe(true); expect(tabCount()).toBe(2); - expect(read).toHaveBeenCalledTimes(2); + expect( + read.mock.calls.filter(([path]) => path === "/test/project") + ).toHaveLength(1); click("workspace-home"); click("welcome-repository-/test/third"); await settle();