Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
import { createElement } from "react";
import { I18nextProvider } from "react-i18next";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WebSnapshot } from "../../web/protocol/types.ts";
import type {
WebSnapshot,
WebWorkspaceChanges,
} from "../../web/protocol/types.ts";
import { App } from "../../web/ui/src/app/App.tsx";
import { Providers } from "../../web/ui/src/app/providers.tsx";
import { Markdown } from "../../web/ui/src/components/Markdown.tsx";
Expand All @@ -19,6 +22,7 @@ import { ActivityBar } from "../../web/ui/src/features/activity/ActivityBar.tsx"
import { Composer } from "../../web/ui/src/features/composer/Composer.tsx";
import { SessionSidebar } from "../../web/ui/src/features/sessions/SessionSidebar.tsx";
import { Transcript } from "../../web/ui/src/features/transcript/Transcript.tsx";
import { WorkspaceChanges } from "../../web/ui/src/features/workspace/WorkspaceChanges.tsx";
import { i18n } from "../../web/ui/src/i18n.ts";
import { WebClient } from "../../web/ui/src/protocol/client.ts";
import { createWebStore, webStore } from "../../web/ui/src/store/web-store.ts";
Expand Down Expand Up @@ -88,6 +92,65 @@ it("keeps a workspace draft separate from the old Session UI and retains text af
}
});

it("renders bounded workspace changes and switches the inspected file", async () => {
const changes: WebWorkspaceChanges = {
sessionId: "session-1",
cwd: "/tmp/ws",
repositoryRoot: "/tmp/ws",
checkedAt: "2026-09-12T09:00:00Z",
status: "changed",
baseline: { kind: "head", commit: "abc123" },
files: [
{
path: "src/app.ts",
status: "modified",
additions: 2,
deletions: 1,
binary: false,
diff: "@@ -1 +1 @@\n-before\n+after",
diffStatus: "text",
truncated: false,
},
{
path: "assets/logo.bin",
status: "untracked",
additions: null,
deletions: null,
binary: true,
diff: "",
diffStatus: "binary",
truncated: false,
},
],
truncation: {
truncated: false,
filesOmitted: 0,
statusTruncated: false,
diffsTruncated: 0,
maxFiles: 250,
maxDiffBytes: 2 * 1024 * 1024,
},
};
const request = vi
.spyOn(WebClient.prototype, "workspaceChanges")
.mockResolvedValue(changes);
const view = renderWithI18n(
createElement(WorkspaceChanges, { sessionId: "session-1", cwd: "/tmp/ws" }),
);
try {
expect(await screen.findAllByText("src/app.ts")).toHaveLength(2);
expect(screen.getByText(/\+after/u)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /assets\/logo\.bin/u }));
expect(
screen.getByText("Binary file; no textual diff is available."),
).toBeTruthy();
expect(request).toHaveBeenCalledWith("session-1", expect.any(AbortSignal));
} finally {
view.unmount();
request.mockRestore();
}
});

const truncation = {
bytes: 0,
maxBytes: 4 * 1024 * 1024,
Expand Down
60 changes: 60 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,66 @@ async function startTestHost(runtime: WebRuntimeController) {
return { host, launched, headers };
}

test("serves Session-bound workspace changes and rejects stale requests", async () => {
const cwd = await mkdtemp(join(tmpdir(), "openpi-web-workspace-changes-"));
const runtime = testRuntime(cwd);
const { host, launched, headers } = await startTestHost(runtime);
const sessionId = runtime.sessionManager.getSessionId();
try {
const response = await fetch(
`${launched.origin}/api/workspace-changes?sessionId=${sessionId}`,
{ headers },
);
assert.equal(response.status, 200);
const body = (await response.json()) as {
checkedAt: string;
[key: string]: unknown;
};
const { checkedAt, ...stableBody } = body;
assert.match(checkedAt, /^\d{4}-\d{2}-\d{2}T/u);
assert.deepEqual(stableBody, {
sessionId,
cwd,
status: "not-repository",
files: [],
truncation: {
truncated: false,
filesOmitted: 0,
statusTruncated: false,
diffsTruncated: 0,
maxFiles: 250,
maxDiffBytes: 2 * 1024 * 1024,
},
});
assert.equal(
(await fetch(`${launched.origin}/api/workspace-changes`, { headers }))
.status,
400,
);
assert.equal(
(
await fetch(
`${launched.origin}/api/workspace-changes?sessionId=stale`,
{ headers },
)
).status,
409,
);
assert.equal(
(
await fetch(`${launched.origin}/api/workspace-changes`, {
method: "POST",
headers,
})
).status,
405,
);
} finally {
await host.stop();
await rm(cwd, { recursive: true, force: true });
}
});

test("serves Session-bound command discovery with fail-closed request validation", async () => {
const cwd = await mkdtemp(join(tmpdir(), "openpi-web-commands-"));
const runtime = testRuntime(cwd);
Expand Down
99 changes: 99 additions & 0 deletions tests/web/workspace-changes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import test from "node:test";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadWorkspaceChanges } from "../../web/host/workspace-changes.ts";

const execFileAsync = promisify(execFile);

async function git(cwd: string, args: string[]) {
await execFileAsync("git", args, {
cwd,
encoding: "utf8",
windowsHide: true,
});
}

async function commit(cwd: string, message: string) {
await git(cwd, [
"-c",
"user.name=OpenPI test",
"-c",
"user.email=openpi-test@example.invalid",
"commit",
"-m",
message,
]);
}

test("reports current Git changes against an explicit baseline", async (t) => {
const cwd = await mkdtemp(join(tmpdir(), "openpi-workspace-changes-"));
t.after(() => rm(cwd, { recursive: true, force: true }));

await git(cwd, ["init"]);
await writeFile(join(cwd, "modified.txt"), "before\n");
await writeFile(join(cwd, "deleted.txt"), "remove me\n");
await writeFile(join(cwd, "binary.bin"), Buffer.from([0, 1, 2]));
await writeFile(join(cwd, "large.txt"), "small\n");
await git(cwd, ["add", "."]);
await commit(cwd, "initial");

await writeFile(join(cwd, "modified.txt"), "before\nafter\n");
await unlink(join(cwd, "deleted.txt"));
await writeFile(join(cwd, "new.txt"), "new file\n");
await writeFile(join(cwd, "empty.txt"), "");
await writeFile(join(cwd, "binary.bin"), Buffer.from([0, 3, 2]));
await writeFile(join(cwd, "large.txt"), "x\n".repeat(300_000));

const result = await loadWorkspaceChanges(cwd, "session-1");
assert.equal(result.sessionId, "session-1");
assert.equal(result.cwd, cwd);
assert.equal(result.status, "changed");
assert.equal(result.baseline?.kind, "head");
assert.equal(result.files.length, 6);

const files = new Map(result.files.map((file) => [file.path, file]));
assert.equal(files.get("modified.txt")?.status, "modified");
assert.match(files.get("modified.txt")?.diff ?? "", /\+after/u);
assert.equal(files.get("deleted.txt")?.status, "deleted");
assert.equal(files.get("new.txt")?.status, "untracked");
assert.match(files.get("new.txt")?.diff ?? "", /\+new file/u);
assert.equal(files.get("empty.txt")?.binary, false);
assert.equal(files.get("empty.txt")?.diffStatus, "text");
assert.equal(files.get("binary.bin")?.binary, true);
assert.equal(files.get("binary.bin")?.diffStatus, "binary");
assert.equal(files.get("large.txt")?.truncated, true);
assert.equal(files.get("large.txt")?.diffStatus, "text");

await writeFile(join(cwd, "modified.txt"), "before\nafter again\n");
const refreshed = await loadWorkspaceChanges(cwd, "session-1");
assert.match(
refreshed.files.find((file) => file.path === "modified.txt")?.diff ?? "",
/\+after again/u,
);
});

test("distinguishes a clean repository with no HEAD from a non-repository", async (t) => {
const emptyRepository = await mkdtemp(
join(tmpdir(), "openpi-empty-repository-"),
);
const nonRepository = await mkdtemp(join(tmpdir(), "openpi-non-repository-"));
t.after(async () => {
await Promise.all([
rm(emptyRepository, { recursive: true, force: true }),
rm(nonRepository, { recursive: true, force: true }),
]);
});

await git(emptyRepository, ["init"]);
const empty = await loadWorkspaceChanges(emptyRepository, "session-empty");
assert.equal(empty.status, "clean");
assert.equal(empty.baseline?.kind, "empty-tree");

const outside = await loadWorkspaceChanges(nonRepository, "session-none");
assert.equal(outside.status, "not-repository");
assert.equal(outside.baseline, undefined);
});
14 changes: 7 additions & 7 deletions web/dist/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/dist/styles.css

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { elapsed, traceWeb } from "../trace.ts";
import { reduceLiveTools } from "../protocol/live-tools.ts";
import type { LiveToolEvidence } from "../protocol/evidence.ts";
import { ArtifactError, ArtifactReader } from "./artifacts.ts";
import { loadWorkspaceChanges } from "./workspace-changes.ts";

const HOST = "127.0.0.1";
const UI_ROOT = new URL("../dist/", import.meta.url);
Expand Down Expand Up @@ -487,6 +488,51 @@ export class WebHost {
response.end(result.bytes);
return;
}
if (url.pathname === "/api/workspace-changes") {
if (request.method !== "GET")
return this.json(response, 405, {
error: "workspace changes accept GET only",
});
const sessionId = url.searchParams.get("sessionId");
if (
sessionId === null ||
sessionId.length === 0 ||
sessionId.length > 128 ||
url.searchParams.getAll("sessionId").length !== 1 ||
[...url.searchParams.keys()].some((key) => key !== "sessionId")
) {
return this.json(response, 400, {
code: "INVALID_WORKSPACE_CHANGES_REQUEST",
error: "the active Session id is required",
});
}
if (this.runtime.workspaceSelected !== true) {
return this.json(response, 409, {
code: "WORKSPACE_REQUIRED",
error: "Choose a workspace before reading workspace changes",
});
}
if (sessionId !== this.runtime.sessionManager.getSessionId()) {
return this.json(response, 409, {
code: "SESSION_CHANGED",
error: "The active Session changed. Refresh the workspace changes.",
});
}
const controller = new AbortController();
const abort = () => controller.abort();
request.once("aborted", abort);
response.once("close", abort);
try {
return this.json(
response,
200,
await loadWorkspaceChanges(this.runtime.cwd, sessionId, controller.signal),
);
} finally {
request.off("aborted", abort);
response.off("close", abort);
}
}
if (
url.pathname === "/api/workspaces/select" &&
request.method === "POST"
Expand Down
Loading
Loading