mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a02d8eb9 | ||
|
|
ee6b095d0e | ||
|
|
fb065afa2f | ||
|
|
6eee6a6b3a | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 |
@@ -0,0 +1,101 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const DRAG_DISTANCE = -40;
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function dividerX(divider: Locator): Promise<number> {
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
return box.x;
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX / 2, y, { steps: 4 });
|
||||
await page.mouse.move(x + deltaX, y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function expectDividerFollowsDrag({
|
||||
page,
|
||||
index,
|
||||
}: {
|
||||
page: Page;
|
||||
index: number;
|
||||
}): Promise<void> {
|
||||
const divider = splitDividers(page).nth(index);
|
||||
const before = await dividerX(divider);
|
||||
await dragDivider(divider, DRAG_DISTANCE);
|
||||
await expect
|
||||
.poll(async () => (await dividerX(divider)) - before, {
|
||||
message: `divider ${index + 1} should follow the drag`,
|
||||
})
|
||||
.toBeLessThan(DRAG_DISTANCE * 0.75);
|
||||
}
|
||||
|
||||
async function splitRight({
|
||||
page,
|
||||
expectedDividers,
|
||||
}: {
|
||||
page: Page;
|
||||
expectedDividers: number;
|
||||
}): Promise<void> {
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||
}
|
||||
|
||||
test.describe("Split pane resize", () => {
|
||||
test("every divider drags in a four-pane row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-resize-fresh-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitRight({ page, expectedDividers: 1 });
|
||||
await splitRight({ page, expectedDividers: 2 });
|
||||
await splitRight({ page, expectedDividers: 3 });
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expectDividerFollowsDrag({ page, index });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("every divider drags after panes are added to a resized row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-resize-grown-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitRight({ page, expectedDividers: 1 });
|
||||
await expectDividerFollowsDrag({ page, index: 0 });
|
||||
|
||||
await splitRight({ page, expectedDividers: 2 });
|
||||
await splitRight({ page, expectedDividers: 3 });
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expectDividerFollowsDrag({ page, index });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -968,8 +968,12 @@ function SplitNodeView({
|
||||
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
|
||||
);
|
||||
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
|
||||
// Dragged sizes live per group id, apart from the layout tree, and a group keeps its id when
|
||||
// panes are added or removed. They only apply while they still have one entry per child;
|
||||
// otherwise the dividers past the end of the list could not move.
|
||||
const layoutGroupSizes = node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES;
|
||||
const groupSizes =
|
||||
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
|
||||
storedGroupSizes?.length === groupChildren.length ? storedGroupSizes : layoutGroupSizes;
|
||||
const visibleFlex = useMemo(
|
||||
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
|
||||
[groupChildren, groupSizes, maximizedPaneId],
|
||||
|
||||
@@ -5487,7 +5487,6 @@ describe("Codex app-server provider", () => {
|
||||
method: "thread/goal/set",
|
||||
params: {
|
||||
threadId: "test-thread",
|
||||
origin: "user",
|
||||
objective: "ship feature",
|
||||
status: "active",
|
||||
},
|
||||
|
||||
@@ -5182,7 +5182,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "set": {
|
||||
await this.client.request("thread/goal/set", {
|
||||
threadId: this.currentThreadId,
|
||||
origin: "user",
|
||||
objective: subcommand.objective,
|
||||
status: "active",
|
||||
});
|
||||
@@ -5191,7 +5190,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "pause": {
|
||||
await this.client.request("thread/goal/set", {
|
||||
threadId: this.currentThreadId,
|
||||
origin: "user",
|
||||
status: "paused",
|
||||
});
|
||||
return "Goal paused.";
|
||||
@@ -5199,7 +5197,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "resume": {
|
||||
await this.client.request("thread/goal/set", {
|
||||
threadId: this.currentThreadId,
|
||||
origin: "user",
|
||||
status: "active",
|
||||
});
|
||||
return "Goal resumed.";
|
||||
@@ -5207,7 +5204,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "clear": {
|
||||
await this.client.request("thread/goal/clear", {
|
||||
threadId: this.currentThreadId,
|
||||
origin: "user",
|
||||
});
|
||||
return "Goal cleared.";
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
// Native Codex owns goals; the fixture supplies inference responses only.
|
||||
test("records all four /goal commands as user edits in the next model request", async () => {
|
||||
const root = mkdtempSync(path.resolve(".dev-goal-provenance-"));
|
||||
const requests: string[] = [];
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
requests.push(Buffer.concat(chunks).toString("utf8"));
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
const id = `resp-${requests.length}`;
|
||||
const events = [
|
||||
{ type: "response.created", response: { id } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg-${requests.length}`,
|
||||
content: [{ type: "output_text", text: "LOCAL_FIXTURE_RESPONSE" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id,
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
},
|
||||
];
|
||||
res.end(events.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""));
|
||||
});
|
||||
}
|
||||
const server = createServer(handleRequest);
|
||||
let session: AgentSession | null = null;
|
||||
try {
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("missing fixture address");
|
||||
writeFileSync(
|
||||
path.join(root, "config.toml"),
|
||||
`
|
||||
model = "gpt-6.1-sol"
|
||||
model_reasoning_effort = "low"
|
||||
model_provider = "fixture"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
[features]
|
||||
enable_request_compression = false
|
||||
[model_providers.fixture]
|
||||
name = "Local response fixture"
|
||||
base_url = "http://127.0.0.1:${address.port}/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
`,
|
||||
);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger(), { env: { CODEX_HOME: root } });
|
||||
session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
model: "gpt-6.1-sol",
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
let active = false;
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (event.type === "turn_started") active = true;
|
||||
if (["turn_completed", "turn_canceled", "turn_failed"].includes(event.type)) active = false;
|
||||
});
|
||||
try {
|
||||
expect((await session.listCommands?.())?.some((command) => command.name === "goal")).toBe(
|
||||
true,
|
||||
);
|
||||
for (const command of [
|
||||
"/goal AUDIT_GOAL: summarize local evidence",
|
||||
"/goal pause",
|
||||
"/goal resume",
|
||||
"/goal clear",
|
||||
]) {
|
||||
const handler = session.tryHandleOutOfBand?.(command);
|
||||
expect(handler).toBeTruthy();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
await handler!.run({ emit: (event) => events.push(event) });
|
||||
expect(events).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
text: expect.stringContaining("Failed to update goal"),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
await expect.poll(() => active, { timeout: 30_000 }).toBe(false);
|
||||
expect((await session.run("PROBE_AFTER_GOAL_CLEAR")).finalText).toBe(
|
||||
"LOCAL_FIXTURE_RESPONSE",
|
||||
);
|
||||
const body = JSON.parse(requests.at(-1)!);
|
||||
const goalContext = body.input
|
||||
.filter((item: { role: string }) => item.role === "user")
|
||||
.flatMap((item: { content: Array<{ text?: string }> }) =>
|
||||
item.content.map((part) => part.text ?? ""),
|
||||
)
|
||||
.filter((text: string) => text.includes('<codex_internal_context source="user_goal">'))
|
||||
.join("\n");
|
||||
expect(goalContext).toContain('User set the goal: "AUDIT_GOAL: summarize local evidence"');
|
||||
expect(goalContext).toContain('User set goal status: "paused".');
|
||||
expect(goalContext).toContain('User set goal status: "active".');
|
||||
expect(goalContext).toContain("User cleared the goal.");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await session?.close();
|
||||
} finally {
|
||||
try {
|
||||
server.closeAllConnections();
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1,84 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
|
||||
// A protocol peer exercises the public session interface without a Codex install.
|
||||
test("marks set, pause, resume and clear as user goal mutations", async () => {
|
||||
const root = mkdtempSync(path.resolve(".dev-goal-protocol-"));
|
||||
const script = path.join(root, "codex.cjs");
|
||||
const capture = path.join(root, "requests.jsonl");
|
||||
writeFileSync(
|
||||
script,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("codex-cli 0.162.1");
|
||||
process.exit(0);
|
||||
}
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
fs.appendFileSync(process.env.CAPTURE, JSON.stringify(request) + "\\n");
|
||||
let result = {};
|
||||
if (request.method === "initialize") result = { userAgent: "codex-cli/0.162.1" };
|
||||
if (request.method === "thread/start") result = { thread: { id: "goal-thread" } };
|
||||
if (request.method === "thread/loaded/list") result = { data: ["goal-thread"] };
|
||||
process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n");
|
||||
});
|
||||
`,
|
||||
);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { CODEX_HOME: root, CAPTURE: capture },
|
||||
});
|
||||
let session: AgentSession | null = null;
|
||||
try {
|
||||
session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: root,
|
||||
model: "test-model",
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
for (const command of ["/goal ship feature", "/goal pause", "/goal resume", "/goal clear"]) {
|
||||
const handler = session.tryHandleOutOfBand?.(command);
|
||||
if (!handler) throw new Error(`Missing handler for ${command}`);
|
||||
await handler.run({ emit: () => {} });
|
||||
}
|
||||
const requests = readFileSync(capture, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
const mutations = requests.filter((request) => request.method.startsWith("thread/goal/"));
|
||||
expect(mutations.map(({ method, params }) => ({ method, params }))).toEqual([
|
||||
{
|
||||
method: "thread/goal/set",
|
||||
params: {
|
||||
threadId: "goal-thread",
|
||||
origin: "user",
|
||||
objective: "ship feature",
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "thread/goal/set",
|
||||
params: { threadId: "goal-thread", origin: "user", status: "paused" },
|
||||
},
|
||||
{
|
||||
method: "thread/goal/set",
|
||||
params: { threadId: "goal-thread", origin: "user", status: "active" },
|
||||
},
|
||||
{ method: "thread/goal/clear", params: { threadId: "goal-thread", origin: "user" } },
|
||||
]);
|
||||
} finally {
|
||||
try {
|
||||
await session?.close();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
|
||||
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
@@ -387,6 +396,84 @@ describe("file explorer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a folder keeps relative symlinks inside the copy",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
mkdirSync(path.join(cwd, "template"));
|
||||
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
|
||||
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "template copy",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
|
||||
|
||||
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
const targetWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/data.txt",
|
||||
content: "copy payload\n",
|
||||
expectedModifiedAt: copiedTarget.modifiedAt,
|
||||
expectedRevision: copiedTarget.revision,
|
||||
});
|
||||
expect(targetWrite.status).toBe("written");
|
||||
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
|
||||
|
||||
const aliasWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/alias.txt",
|
||||
content: "edited through copied alias\n",
|
||||
expectedModifiedAt: copiedAlias.modifiedAt,
|
||||
expectedRevision: copiedAlias.revision,
|
||||
});
|
||||
expect(aliasWrite.status).toBe("written");
|
||||
const original = await ctx.client.readFile(cwd, "template/data.txt");
|
||||
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
|
||||
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
|
||||
|
||||
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
|
||||
expect(deleted).toMatchObject({ success: true, error: null });
|
||||
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
|
||||
"edited through copied alias\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a file symlink preserves an explicit absolute target",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const targetPath = path.join(cwd, "data.txt");
|
||||
try {
|
||||
writeFileSync(targetPath, "absolute target payload\n");
|
||||
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "alias copy.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
|
||||
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
|
||||
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("lists directory contents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
@@ -489,3 +576,63 @@ describe("file explorer", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
});
|
||||
|
||||
describe("commit file diffs", () => {
|
||||
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
|
||||
const cwd = tmpCwd();
|
||||
onTestFinished(() =>
|
||||
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
);
|
||||
const workspace = path.join(cwd, relativePath);
|
||||
const filePath = "apps/web/notes.txt";
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
git(["init", "-b", "main"]);
|
||||
git(["config", "user.name", "Test"]);
|
||||
git(["config", "user.email", "test@example.com"]);
|
||||
git(["config", "core.autocrlf", "false"]);
|
||||
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
|
||||
writeFileSync(path.join(cwd, filePath), "before\n");
|
||||
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
writeFileSync(path.join(cwd, filePath), "after\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
|
||||
const sha = git(["rev-parse", "HEAD"]).trim();
|
||||
|
||||
const history = await ctx.client.listCheckoutCommits(workspace);
|
||||
const commit = history.commits.find((entry) => entry.sha === sha);
|
||||
expect(commit?.files).toEqual([
|
||||
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
|
||||
]);
|
||||
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
|
||||
expect(result).toEqual({
|
||||
file: {
|
||||
path: filePath,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
expect(added).toMatchObject({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const {
|
||||
projectIconRevision: _icon,
|
||||
projectCustomIconRevision: _customIcon,
|
||||
...project
|
||||
} = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
expect(update).toMatchObject({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
@@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
projectKey: expect.any(String),
|
||||
customIconRevision: null,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
|
||||
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
const added = await client.addProject(projectRoot);
|
||||
expect(added.project?.projectKind).toBe("non_git");
|
||||
const source = {
|
||||
kind: "worktree" as const,
|
||||
cwd: projectRoot,
|
||||
projectId: added.project!.projectId,
|
||||
action: "branch-off" as const,
|
||||
refName: "main",
|
||||
branchName: "after-init",
|
||||
worktreeSlug: "after-init",
|
||||
};
|
||||
const before = await client.createWorkspace({ source });
|
||||
expect(before.error).toBe("Create worktree requires a git repository");
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === added.project!.projectId &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
|
||||
await execFile(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.dev",
|
||||
"-c",
|
||||
"commit.gpgSign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: projectRoot },
|
||||
);
|
||||
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
|
||||
const projects = await client.fetchWorkspaces({
|
||||
filter: { projectId: added.project!.projectId },
|
||||
});
|
||||
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
|
||||
|
||||
const created = await client.createWorkspace({ source });
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(created.workspace?.projectId).toBe(added.project!.projectId);
|
||||
}, 30_000);
|
||||
|
||||
@@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
|
||||
} catch (error) {
|
||||
|
||||
@@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
const deletions = expectFileChanges(removedPaths, "delete");
|
||||
const movedFiles = expectFileChanges(movedPaths);
|
||||
const recovery = expectFileChanges([sentinel]);
|
||||
const subscription = await subscribeToFileChanges(root, (error, events) => {
|
||||
const observer = createObserver();
|
||||
const subscription = await observer.subscribe(root, (error, events) => {
|
||||
expect(error).toBeNull();
|
||||
for (const event of events) {
|
||||
population.record(event);
|
||||
@@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
|
||||
await writes.complete;
|
||||
|
||||
// A native watcher may recover coalesced paths through its safety audit.
|
||||
// Completion comes from the delivered deletions, not an idle diagnostic or
|
||||
// a deadline shorter than that recovery cycle.
|
||||
// The first notice for each write can arrive before its remaining
|
||||
// classifications. Drain those deliveries before starting a separate deletion
|
||||
// phase, so a late create cannot coalesce with the expected delete as update.
|
||||
await expect
|
||||
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
pendingEventCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
});
|
||||
|
||||
// Completion still requires the delivered deletion notices.
|
||||
await Promise.all(removedPaths.map((path) => rm(path)));
|
||||
await deletions.complete;
|
||||
|
||||
|
||||
@@ -63,6 +63,27 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
});
|
||||
cleanup.push(async () => {
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
|
||||
|
||||
expect((await service.getCheckout(repo)).isGit).toBe(true);
|
||||
const repoRoot = await service.resolveRepoRoot(repo);
|
||||
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
|
||||
}, 30_000);
|
||||
|
||||
test("recursive observation updates tracked state and prunes ignored storms", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
|
||||
@@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
const cached = this.peekSnapshot(normalizedCwd);
|
||||
if (cached && cached.git.isGit !== status.isGit) {
|
||||
// A fresh checkout read discovered a repository transition. Keep the
|
||||
// shared snapshot current before exposing the new project capability.
|
||||
await this.getSnapshot(normalizedCwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "checkout-classification-changed",
|
||||
});
|
||||
}
|
||||
if (!status.isGit) {
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: false,
|
||||
|
||||
@@ -2665,10 +2665,11 @@ export async function getCommitFileDiff({
|
||||
sha: string;
|
||||
path: string;
|
||||
}): Promise<ParsedDiffFile | null> {
|
||||
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||
const { stdout } = await runGitCommand(
|
||||
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
|
||||
{
|
||||
cwd,
|
||||
cwd: worktreeRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
|
||||
});
|
||||
|
||||
// `--` scopes the diff to a single pathspec, so there is at most one real
|
||||
|
||||
Reference in New Issue
Block a user