mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c54e9106d | ||
|
|
a1633baa74 | ||
|
|
4fdd9eab4b | ||
|
|
71dc9f44c9 | ||
|
|
7495c20f78 | ||
|
|
b92a34ab3d | ||
|
|
8018231a00 | ||
|
|
555818f5f7 | ||
|
|
27caf913f6 |
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPageHandlesFragment(
|
||||
page: Page,
|
||||
handler: "Document" | "Window",
|
||||
): Promise<void> {
|
||||
const preview = htmlPreview(page);
|
||||
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
@@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragments-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "fragments.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section">Jump to section</a>
|
||||
<div style="height: 1200px"></div>
|
||||
<h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "fragments.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
|
||||
await preview.document.getByRole("link", { name: "Jump to section" }).click();
|
||||
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
|
||||
});
|
||||
|
||||
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "handled.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
|
||||
<output>Waiting for click</output>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "handled.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await preview.document.getByRole("link", { name: "Handled jump" }).click();
|
||||
await expect(preview.document.locator("output")).toHaveText("Page handled click");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "defaults.html"),
|
||||
`<!doctype html><html><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section" id="document-handler">Document handled jump</a>
|
||||
<a href="#section" id="window-handler">Window handled jump</a>
|
||||
<a href=" \t#section\n">Whitespace jump</a>
|
||||
<a href="#100%">Percent jump</a>
|
||||
<output>Waiting for click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'document-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'window-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "defaults.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expectPageHandlesFragment(page, "Document");
|
||||
await expectPageHandlesFragment(page, "Window");
|
||||
|
||||
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await followHtmlPreviewLink(page, "Percent jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "targets.html"),
|
||||
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section"><span>Nested jump</span></a>
|
||||
<a href="#legacy">Named jump</a>
|
||||
<a href="#encoded%20section">Encoded jump</a>
|
||||
<a href="#missing">Missing jump</a>
|
||||
<a href="#">Empty jump</a>
|
||||
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><a name="legacy">Named target</a>
|
||||
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
|
||||
function modifiedClick(event) {
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
document.getElementById('modified').textContent = String(event.defaultPrevented);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', modifiedClick);
|
||||
window.addEventListener('auxclick', modifiedClick);
|
||||
window.addEventListener('contextmenu', modifiedClick);
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "targets.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
const body = preview.document.locator("body");
|
||||
const nested = preview.document.getByText("Nested jump", { exact: true });
|
||||
await nested.click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#section");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
|
||||
|
||||
await body.evaluate(() => window.scrollTo(0, 0));
|
||||
await nested.click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
|
||||
await preview.document.getByRole("link", { name: "Named jump" }).click();
|
||||
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
|
||||
|
||||
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Encoded target" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||
const scrollY = await body.evaluate(() => window.scrollY);
|
||||
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
|
||||
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
|
||||
await nested.click({ modifiers: [modifier] });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
}
|
||||
await nested.click({ button: "middle" });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||
// Leave the daemon running. This test observes the public reconnecting state.
|
||||
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||
assert(
|
||||
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||
"Pass all five arguments",
|
||||
);
|
||||
const scale = Number(coordinateScale);
|
||||
assert(scale > 0, "Pass a positive coordinate scale");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
function capture(name) {
|
||||
const snapshot = ad("snapshot", "--raw", "--json");
|
||||
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||
const nodes = JSON.parse(snapshot).data.nodes;
|
||||
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||
return toast.rect.y / scale;
|
||||
}
|
||||
|
||||
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||
const closedTop = capture("keyboard-closed");
|
||||
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||
const opened = ad("snapshot", "--raw", "--json");
|
||||
const nodes = JSON.parse(opened).data.nodes;
|
||||
assert(
|
||||
nodes.some(
|
||||
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||
),
|
||||
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||
);
|
||||
const openTop = capture("keyboard-open");
|
||||
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||
console.log(
|
||||
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||
);
|
||||
@@ -1,8 +1,26 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal, PortalHost } from "@gorhom/portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
|
||||
const OverlayClearanceContext = createContext(0);
|
||||
const OverlayHostContext = createContext<string | null>(null);
|
||||
|
||||
/** Keep status overlays above the fade without lifting the scrolling content. */
|
||||
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
|
||||
const hostName = useContext(OverlayHostContext);
|
||||
const active = useRetainedPanelActive();
|
||||
if (!hostName) return children;
|
||||
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
|
||||
}
|
||||
|
||||
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
|
||||
export function useFloatingHeaderOverlayClearance(): number {
|
||||
return useContext(OverlayClearanceContext);
|
||||
}
|
||||
|
||||
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
|
||||
const FADE_OVERHANG = 48;
|
||||
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
|
||||
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
|
||||
* same tree position in both modes, so toggling `floating` never remounts them.
|
||||
*/
|
||||
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
|
||||
const overlayHostName = useId();
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
|
||||
}, []);
|
||||
|
||||
const overlayClearance = floating ? headerHeight : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{floating ? null : header}
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
<View style={styles.content}>
|
||||
<OverlayClearanceContext.Provider value={overlayClearance}>
|
||||
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
|
||||
{children(floating ? headerHeight : 0)}
|
||||
</OverlayHostContext.Provider>
|
||||
</OverlayClearanceContext.Provider>
|
||||
</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<EdgeFade
|
||||
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.statusOverlay} pointerEvents="box-none">
|
||||
<PortalHost name={overlayHostName} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
statusOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
FloatingHeaderOverlay,
|
||||
useFloatingHeaderOverlayClearance,
|
||||
} from "@/components/floating-header";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -190,9 +194,11 @@ export function ToastViewport({
|
||||
return clearTimer;
|
||||
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
|
||||
|
||||
const panelClearance = useFloatingHeaderOverlayClearance();
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
||||
const topOffset =
|
||||
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||
|
||||
const content = (
|
||||
<View
|
||||
@@ -220,7 +226,7 @@ export function ToastViewport({
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
|
||||
@@ -179,8 +179,8 @@ export function ComposerDock({
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
@@ -214,8 +214,8 @@ export function ComposerDock({
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("keeps dock overlays outside keyboard translation in every native layout", () => {
|
||||
const source = ts.createSourceFile(
|
||||
"dock.tsx",
|
||||
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let surfaces = 0;
|
||||
function visit(node: ts.Node) {
|
||||
if (
|
||||
ts.isJsxElement(node) &&
|
||||
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
|
||||
) {
|
||||
surfaces++;
|
||||
function assertStationary(child: ts.Node) {
|
||||
if (ts.isJsxExpression(child))
|
||||
expect(child.expression?.getText(source)).not.toBe("overlay");
|
||||
ts.forEachChild(child, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(surfaces).toBe(2);
|
||||
});
|
||||
@@ -25,12 +25,17 @@ const iframeStyle = {
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
// A document URL lets the browser resolve fragment links within the preview.
|
||||
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
|
||||
const source = useMemo(
|
||||
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
|
||||
[html],
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
srcDoc={document}
|
||||
src={source}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
|
||||
|
||||
describe("paseo logs JSON output", () => {
|
||||
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
|
||||
let agentId: string;
|
||||
let host: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
daemon = await startTestDaemon({
|
||||
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
|
||||
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
|
||||
});
|
||||
host = `127.0.0.1:${daemon.port}`;
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
try {
|
||||
// Creating an idle agent needs no inference or external provider connection.
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "JSON logs",
|
||||
});
|
||||
agentId = agent.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await daemon?.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "top-level --json", args: ["logs", "--json"] },
|
||||
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
|
||||
{ label: "--format json alias", args: ["logs", "--format", "json"] },
|
||||
])(
|
||||
"prints JSON through $label",
|
||||
async ({ args }) => {
|
||||
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
|
||||
import {
|
||||
createFollowJsonWriter,
|
||||
createFollowTranscriptWriter,
|
||||
formatAgentActivityTranscript,
|
||||
} from "./logs.js";
|
||||
|
||||
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
|
||||
let output = "";
|
||||
@@ -149,3 +153,31 @@ describe("createFollowTranscriptWriter", () => {
|
||||
expect(output).toBe("# Title\n\nBody\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFollowJsonWriter", () => {
|
||||
it("writes one JSON item per line and honours the filter", () => {
|
||||
let output = "";
|
||||
const writer = createFollowJsonWriter("tools", (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
const toolCall = {
|
||||
type: "tool_call",
|
||||
callId: "call_1",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
error: null,
|
||||
} as AgentTimelineItem;
|
||||
|
||||
writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" });
|
||||
writer.push(toolCall);
|
||||
writer.end();
|
||||
|
||||
expect(
|
||||
output
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([toolCall]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,35 @@ export function formatAgentActivityTranscript(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the items `--tail` keeps, the same way the transcript does: the last n
|
||||
* timeline items, before any of them are dropped from the curated view.
|
||||
*/
|
||||
function selectTailItems(
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): AgentTimelineItem[] {
|
||||
if (tailCount === undefined) {
|
||||
return timelineItems;
|
||||
}
|
||||
if (tailCount === 0) {
|
||||
return [];
|
||||
}
|
||||
return timelineItems.slice(-tailCount);
|
||||
}
|
||||
|
||||
function formatAgentLogsJson(
|
||||
agentId: string,
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): string {
|
||||
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
|
||||
}
|
||||
|
||||
function usesJsonOutput(options: AgentLogsOptions): boolean {
|
||||
return options.json === true || options.format?.trim().toLowerCase() === "json";
|
||||
}
|
||||
|
||||
type StreamingTextType = "assistant_message" | "reasoning";
|
||||
|
||||
const THOUGHT_PREFIX = "[Thought] ";
|
||||
@@ -198,6 +227,21 @@ export function createFollowTranscriptWriter(
|
||||
};
|
||||
}
|
||||
|
||||
/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */
|
||||
export function createFollowJsonWriter(
|
||||
filter?: string,
|
||||
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
|
||||
): FollowTranscriptWriter {
|
||||
return {
|
||||
push(item) {
|
||||
if (matchesFilter(item, filter)) {
|
||||
write(`${JSON.stringify(item)}\n`);
|
||||
}
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTailCount(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
@@ -306,6 +350,11 @@ export async function runLogsCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
if (usesJsonOutput(options)) {
|
||||
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use curateAgentActivity to format the transcript
|
||||
if (tailCount === 0) {
|
||||
return;
|
||||
@@ -350,8 +399,14 @@ async function runFollowMode({
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
const json = usesJsonOutput(options);
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (tailCount > 0) {
|
||||
if (json) {
|
||||
for (const item of selectTailItems(existingItems, tailCount)) {
|
||||
console.log(JSON.stringify(item));
|
||||
}
|
||||
} else if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript);
|
||||
@@ -362,12 +417,16 @@ async function runFollowMode({
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
const writer = createFollowTranscriptWriter(undefined, options.filter);
|
||||
const writer = json
|
||||
? createFollowJsonWriter(options.filter)
|
||||
: createFollowTranscriptWriter(undefined, options.filter);
|
||||
// Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr.
|
||||
const notice = json ? console.error : console.log;
|
||||
|
||||
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
notice("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -378,7 +437,7 @@ async function runFollowMode({
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
notice("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
@@ -399,7 +458,7 @@ async function runFollowMode({
|
||||
});
|
||||
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await waitForStop();
|
||||
writer.end();
|
||||
|
||||
@@ -643,6 +643,7 @@ interface PendingPermission {
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
turnId: string | null;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
interface PendingUserMessage {
|
||||
@@ -2644,6 +2645,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolve,
|
||||
reject,
|
||||
turnId: this.activeForegroundTurnId,
|
||||
toolCallId: params.toolCall.toolCallId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3207,6 +3209,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(toolCallId, snapshot);
|
||||
if (update.status === "completed" || update.status === "failed") {
|
||||
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
|
||||
}
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
|
||||
@@ -3414,11 +3419,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
): void {
|
||||
this.deliverTranslatedEvents(this.flushPendingUserMessage());
|
||||
// A turn that ends without completing leaves no way to answer its open
|
||||
// permission requests, so tell the agent they were cancelled.
|
||||
if (event.type !== "turn_completed") {
|
||||
this.cancelPendingPermissions();
|
||||
}
|
||||
// Failed and canceled turns clear every request in the manager. Answer the
|
||||
// same set here so an outside-turn request cannot keep the provider blocked.
|
||||
this.cancelPendingPermissions(
|
||||
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
|
||||
);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.fallbackAssistantMessageId = null;
|
||||
if (this.submittedUserMessageTurnId === event.turnId) {
|
||||
@@ -3427,11 +3432,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.pushEvent(event);
|
||||
}
|
||||
|
||||
private cancelPendingPermissions(): void {
|
||||
for (const pending of this.pendingPermissions.values()) {
|
||||
private cancelPendingPermissions(
|
||||
matches: (pending: PendingPermission) => boolean = () => true,
|
||||
): void {
|
||||
for (const [requestId, pending] of this.pendingPermissions) {
|
||||
if (!matches(pending)) continue;
|
||||
this.pendingPermissions.delete(requestId);
|
||||
pending.resolve({ outcome: { outcome: "cancelled" } });
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
requestId,
|
||||
resolution: { behavior: "deny", message: "Permission request canceled" },
|
||||
turnId: pending.turnId ?? undefined,
|
||||
});
|
||||
}
|
||||
this.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
private emitBootstrapThreadEvent(): void {
|
||||
|
||||
+156
-22
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
test.each([
|
||||
{ ending: "fail", terminalEvent: "turn_failed" },
|
||||
{ ending: "cancel", terminalEvent: "turn_canceled" },
|
||||
{ ending: "end_turn", terminalEvent: "turn_completed" },
|
||||
] as const)(
|
||||
"answers a permission request left open by a $terminalEvent turn with cancelled",
|
||||
async ({ ending, terminalEvent }) => {
|
||||
@@ -25,6 +27,16 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
await waitForEvent(events, terminalEvent);
|
||||
expect(hasEvent(events, "permission_requested")).toBe(true);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
const requested = permissionRequests(events)[0]!;
|
||||
expect(resolvedEvents(events)).toEqual([
|
||||
{
|
||||
type: "permission_resolved",
|
||||
provider: "acp",
|
||||
requestId: requested.request.id,
|
||||
resolution: { behavior: "deny", message: "Permission request canceled" },
|
||||
turnId: requested.turnId,
|
||||
},
|
||||
]);
|
||||
|
||||
const replyStart = events.length;
|
||||
await session.startTurn("turn two");
|
||||
@@ -39,6 +51,84 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["completed", "failed"] as const)(
|
||||
"cancels only the matching permission when its tool becomes %s",
|
||||
async (status) => {
|
||||
await withFakeACPAgent(status, async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
const events = collectEvents(session);
|
||||
await session.startTurn("turn one");
|
||||
await waitForToolUpdate(events);
|
||||
expect(pendingToolIds(session)).toEqual(["tool-2"]);
|
||||
expect(resolvedEvents(events)).toEqual([
|
||||
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
|
||||
await withFakeACPAgent("outside_turn", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
const outside = session.getPendingPermissions();
|
||||
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
|
||||
const events = collectEvents(session);
|
||||
await session.startTurn("turn one");
|
||||
await waitForEvent(events, "turn_completed");
|
||||
expect(session.getPendingPermissions()).toEqual(outside);
|
||||
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["fail", "cancel"] as const)(
|
||||
"settles an outside-turn permission that the manager clears when a turn ends with %s",
|
||||
async (ending) => {
|
||||
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
|
||||
const logger = createTestLogger();
|
||||
const client = new GenericACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
waitForInitialCommands: false,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { acp: client }, logger });
|
||||
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
await waitForManagedPermission(manager, agent.id);
|
||||
if (ending === "fail") {
|
||||
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
|
||||
"turn failed with a permission open",
|
||||
);
|
||||
} else {
|
||||
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
|
||||
canceled: true,
|
||||
});
|
||||
}
|
||||
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
|
||||
const result = await manager.runAgent(agent.id, "turn two");
|
||||
expect(result.finalText).toBe(
|
||||
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function collectEvents(session: AgentSession): AgentStreamEvent[] {
|
||||
@@ -47,6 +137,10 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
|
||||
return events;
|
||||
}
|
||||
|
||||
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
|
||||
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
|
||||
}
|
||||
|
||||
function hasEvent(
|
||||
events: AgentStreamEvent[],
|
||||
type: AgentStreamEvent["type"],
|
||||
@@ -77,8 +171,32 @@ function assistantText(events: AgentStreamEvent[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function permissionRequests(events: AgentStreamEvent[]) {
|
||||
return events.filter((event) => event.type === "permission_requested");
|
||||
}
|
||||
|
||||
function resolvedEvents(events: AgentStreamEvent[]) {
|
||||
return events.filter((event) => event.type === "permission_resolved");
|
||||
}
|
||||
|
||||
function pendingToolIds(session: AgentSession) {
|
||||
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
|
||||
}
|
||||
|
||||
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
|
||||
await expect.poll(() => assistantText(events)).toBe("tool terminal");
|
||||
}
|
||||
|
||||
async function withFakeACPAgent(
|
||||
ending: "fail" | "cancel",
|
||||
ending:
|
||||
| "fail"
|
||||
| "cancel"
|
||||
| "end_turn"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "outside_turn"
|
||||
| "outside_fail"
|
||||
| "outside_cancel",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
|
||||
@@ -91,8 +209,7 @@ async function withFakeACPAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// Turn one asks for permission, then fails or is cancelled while the request is
|
||||
// still open.
|
||||
// Turn one ends while its permission request is still open.
|
||||
// Later turns report whether that request was ever answered.
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
@@ -101,15 +218,34 @@ const ending = process.argv[2];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
let promptCount = 0;
|
||||
let permissionAnswer = null;
|
||||
let outsidePermissionAnswer = null;
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
function ask(sessionId, requestId, toolCallId) {
|
||||
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
|
||||
write({
|
||||
id: requestId,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall,
|
||||
options: [
|
||||
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === undefined && message.id === "perm-1") {
|
||||
permissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.method === undefined) {
|
||||
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialize") {
|
||||
@@ -117,6 +253,7 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/new") {
|
||||
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
|
||||
write({ id: message.id, result: { sessionId: "session-1" } });
|
||||
return;
|
||||
}
|
||||
@@ -124,35 +261,32 @@ rl.on("line", (line) => {
|
||||
const sessionId = message.params.sessionId;
|
||||
promptCount += 1;
|
||||
if (promptCount === 1) {
|
||||
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
|
||||
write({
|
||||
id: "perm-1",
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall,
|
||||
options: [
|
||||
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
ask(sessionId, "perm-1", "tool-1");
|
||||
if (ending === "completed" || ending === "failed") {
|
||||
ask(sessionId, "perm-2", "tool-2");
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (ending === "cancel") {
|
||||
if (ending === "cancel" || ending === "outside_cancel") {
|
||||
write({ id: message.id, result: { stopReason: "cancelled" } });
|
||||
} else if (ending === "end_turn" || ending === "outside_turn") {
|
||||
write({ id: message.id, result: { stopReason: "end_turn" } });
|
||||
} else {
|
||||
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
|
||||
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
|
||||
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
|
||||
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
|
||||
},
|
||||
});
|
||||
write({ id: message.id, result: { stopReason: "end_turn" } });
|
||||
|
||||
@@ -1600,6 +1600,111 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime after replaying its timeline", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.messages = [
|
||||
{ role: "user", content: "Old question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
|
||||
];
|
||||
});
|
||||
|
||||
const client: import("../../agent-sdk-types.js").AgentClient = createClient(pi);
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
|
||||
onTestFinished(() => session.close());
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
|
||||
expect(history).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "Old answer" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
|
||||
await session.close();
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime when replay is stopped", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.messages = [
|
||||
{ role: "user", content: "Old question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
|
||||
];
|
||||
});
|
||||
const session = await createClient(pi).resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const history = session.streamHistory();
|
||||
expect((await history.next()).done).toBe(false);
|
||||
await history.return();
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime when replay fails", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.getMessagesError = new Error("History unavailable");
|
||||
});
|
||||
const session = await createClient(pi).resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const history = session.streamHistory();
|
||||
await expect(history.next()).rejects.toThrow("History unavailable");
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps an interactive Pi runtime open after replaying history", async () => {
|
||||
const pi = new FakePi();
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Hydrate the session before its next interactive prompt.
|
||||
}
|
||||
expect(pi.latestSession().closeCalls).toBe(0);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ provider: "pi" });
|
||||
});
|
||||
|
||||
test("resumes a session whose model was removed on the model Pi falls back to", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.removeModel("9router/deepseek-v4-flash");
|
||||
@@ -3740,14 +3845,17 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
|
||||
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
const sessionCopy = pi.recordedLaunches[0]!.session!;
|
||||
expect(existsSync(sessionCopy)).toBe(true);
|
||||
await session.close();
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Completion releases both the runtime and its temporary session copy.
|
||||
}
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
|
||||
@@ -253,6 +253,7 @@ interface PiRpcAgentSessionOptions {
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
closeAfterHistory?: boolean;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
usagePollScheduler?: PiUsagePollScheduler;
|
||||
@@ -1255,6 +1256,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.persistedSessionFile = options.persistedSessionFile;
|
||||
this.cleanup = options.cleanup;
|
||||
this.closeAfterHistory = options.closeAfterHistory ?? false;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
this.extensionHost = createPiExtensionHost(this.logger);
|
||||
@@ -1286,6 +1288,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly persistedSessionFile?: string;
|
||||
private readonly cleanup?: () => void;
|
||||
private readonly closeAfterHistory: boolean;
|
||||
private readonly extensionTimeoutMs: number;
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1454,16 +1457,20 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.requestEntryCapture("history");
|
||||
yield* streamPiHistory(
|
||||
this.provider,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.contextUserEntries,
|
||||
{},
|
||||
// At most eight 2 MiB child files per replay; later cards retain their summaries.
|
||||
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
|
||||
this.closeController.signal,
|
||||
);
|
||||
try {
|
||||
await this.requestEntryCapture("history");
|
||||
yield* streamPiHistory(
|
||||
this.provider,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.contextUserEntries,
|
||||
{},
|
||||
// At most eight 2 MiB child files per replay; later cards retain their summaries.
|
||||
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
|
||||
this.closeController.signal,
|
||||
);
|
||||
} finally {
|
||||
if (this.closeAfterHistory) await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2738,6 +2745,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
closeAfterHistory: options?.purpose === "history",
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
|
||||
@@ -153,6 +153,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
// The user entries on the current branch that getMessages() replays.
|
||||
contextUserEntries: FakePiUserEntry[] = [];
|
||||
abortRequested = false;
|
||||
closeCalls = 0;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
id: string;
|
||||
@@ -161,6 +162,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
setModelResult: PiModel | null = null;
|
||||
models: PiModel[] = [];
|
||||
messages: PiAgentMessage[] = [];
|
||||
getMessagesError: Error | null = null;
|
||||
stats: PiSessionStats = {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
@@ -303,6 +305,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async getMessages(): Promise<PiAgentMessage[]> {
|
||||
if (this.getMessagesError) throw this.getMessagesError;
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
@@ -422,7 +425,9 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
this.respondToExtensionUiRequest(id, { cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -137,3 +139,89 @@ test("omitted after limit pages forward while explicit zero preserves complete C
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
{ label: "--json", flags: ["--json"] },
|
||||
{ label: "--format json", flags: ["--format", "json"] },
|
||||
])(
|
||||
"logs --follow $label keeps history, live items and notices on their channels",
|
||||
async ({ flags }) => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-json-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const history = [
|
||||
{ type: "user_message" as const, text: "Saved JSON prompt" },
|
||||
{ type: "assistant_message" as const, text: "Saved JSON reply", messageId: "saved" },
|
||||
];
|
||||
for (const item of history)
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, item);
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const exited = once(follow, "exit");
|
||||
follow.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
follow.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
if (follow?.exitCode !== null)
|
||||
throw new Error(`CLI exited before following logs: ${stderr}`);
|
||||
return stdout + stderr;
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toContain("Following logs");
|
||||
const live = {
|
||||
type: "assistant_message" as const,
|
||||
text: "Live JSON reply",
|
||||
messageId: "live",
|
||||
};
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, live);
|
||||
await expect.poll(() => stdout, { timeout: 10_000 }).toContain(live.text);
|
||||
follow.kill("SIGINT");
|
||||
await exited;
|
||||
expect(
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([...history, live]);
|
||||
expect(stderr).toContain("Following logs");
|
||||
expect(stdout).not.toContain("Following logs");
|
||||
} finally {
|
||||
follow?.kill("SIGTERM");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
||||
import {
|
||||
getCheckoutStatus as readCheckoutStatus,
|
||||
type CheckoutSnapshotFacts,
|
||||
type CheckoutStatusGit,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
@@ -60,6 +64,49 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"disposal waits for an admitted Git refresh after observation ends (unsubscribed: %s)",
|
||||
async (unsubscribeBeforeDisposal) => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const root = realpathSync(mkdtempSync(path.join(artifacts, "git-refresh-shutdown-")));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
deps: {
|
||||
getCheckoutStatus: async (...args) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return readCheckoutStatus(...args);
|
||||
},
|
||||
},
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: root }, () => {});
|
||||
try {
|
||||
await entered.promise;
|
||||
if (unsubscribeBeforeDisposal) subscription.unsubscribe();
|
||||
let disposed = false;
|
||||
const closing = service.dispose().then(() => {
|
||||
disposed = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(disposed).toBe(false);
|
||||
release.resolve();
|
||||
await closing;
|
||||
expect(disposed).toBe(true);
|
||||
} finally {
|
||||
release.resolve();
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
@@ -614,6 +614,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly disposeController = new AbortController();
|
||||
private disposed = false;
|
||||
private disposePromise: Promise<void> | null = null;
|
||||
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
|
||||
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
@@ -1092,7 +1093,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchResolutions.clear();
|
||||
this.workingTreeWatchAliases.clear();
|
||||
this.snapshotUpdatedListeners.clear();
|
||||
this.disposePromise = this.fileObserver.close();
|
||||
// Unsubscribing can remove a target while its Git command still owns the cwd.
|
||||
// Stop admission first, then drain those refreshes before releasing the service.
|
||||
this.disposePromise = Promise.all([
|
||||
this.fileObserver.close(),
|
||||
Promise.allSettled(this.pendingRefreshes),
|
||||
]).then(() => undefined);
|
||||
return this.disposePromise;
|
||||
}
|
||||
|
||||
@@ -3212,11 +3218,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => {
|
||||
this.pendingRefreshes.delete(promise);
|
||||
const state = target.refreshState;
|
||||
if (state.status === "in-flight" && state.promise === promise) {
|
||||
target.refreshState = { status: "idle" };
|
||||
}
|
||||
});
|
||||
this.pendingRefreshes.add(promise);
|
||||
target.refreshState = {
|
||||
status: "in-flight",
|
||||
promise,
|
||||
|
||||
@@ -806,25 +806,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
test("checks out the authoritative GitLab MR ref before a same-named origin branch", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitLabMrWithConflictingOriginBranchRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceRemote = path.join(tempDir, "fork.git");
|
||||
execFileSync("git", ["clone", "--bare", path.join(tempDir, "origin.git"), sourceRemote], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
const mrHead = execFileSync(
|
||||
"git",
|
||||
[
|
||||
`--git-dir=${path.join(tempDir, "origin.git")}`,
|
||||
"rev-parse",
|
||||
"refs/merge-requests/14/head",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
execFileSync("git", [
|
||||
`--git-dir=${sourceRemote}`,
|
||||
"update-ref",
|
||||
"refs/heads/feature/gitlab-mr",
|
||||
mrHead,
|
||||
]);
|
||||
const gitlab = createGitLabService({
|
||||
resolveGlabPath: async () => "/usr/bin/glab",
|
||||
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
|
||||
@@ -839,23 +820,12 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
state: "opened",
|
||||
source_branch: "feature/gitlab-mr",
|
||||
target_branch: "main",
|
||||
author: { username: "mr-author" },
|
||||
source_project_id: 202,
|
||||
target_project_id: 101,
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "api" && args[1] === "projects/202") {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
namespace: { full_path: "team" },
|
||||
ssh_url_to_repo: sourceRemote,
|
||||
http_url_to_repo: "https://gitlab.example.com/team/fork.git",
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected glab args: ${args.join(" ")}`);
|
||||
},
|
||||
});
|
||||
@@ -873,17 +843,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
expect(result.intent).toMatchObject({
|
||||
kind: "checkout-change-request",
|
||||
forge: "gitlab",
|
||||
headRepository: "team/fork",
|
||||
localBranchName: "team/feature/gitlab-mr",
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
|
||||
],
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("team/feature/gitlab-mr");
|
||||
expect(getGitConfigValue(result.worktree.worktreePath, "remote.paseo-pr-14.url")).toBe(
|
||||
sourceRemote,
|
||||
);
|
||||
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
|
||||
"authoritative merge request head\n",
|
||||
);
|
||||
|
||||
@@ -558,34 +558,31 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
|
||||
cleanupPaths.push(tempDir);
|
||||
const repoDir = path.join(tempDir, "source");
|
||||
const sourceWorkspaceCwd = path.join(repoDir, "packages", "app");
|
||||
const worktree: WorktreeConfig = {
|
||||
branchName: "feature-subdirectory-setup",
|
||||
worktreePath: path.join(tempDir, "worktree"),
|
||||
};
|
||||
const workspaceCwd = path.join(worktree.worktreePath, "packages", "app");
|
||||
// Setup consumes an existing workspace. Git creation is covered by the workflow tests.
|
||||
mkdirSync(sourceWorkspaceCwd, { recursive: true });
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", worktree.branchName], {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(sourceWorkspaceCwd, "paseo.json"),
|
||||
path.join(workspaceCwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["pwd > setup-cwd.txt"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature-subdirectory-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-subdirectory-setup",
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -600,16 +597,16 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
requestCwd: sourceWorkspaceCwd,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "ws-subdirectory-setup",
|
||||
worktree: createdWorktree,
|
||||
worktree,
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-subdirectory-setup",
|
||||
worktreePath: createdWorktree.worktreePath,
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveWorktreeCreationIntent } from "../server/resolve-worktree-creation-intent.js";
|
||||
|
||||
import type { PullRequestCommandStatus } from "./forge-service.js";
|
||||
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
|
||||
import type { GitLabStatusFacts } from "./gitlab-facts.js";
|
||||
@@ -465,8 +463,9 @@ describe("createGitLabService", () => {
|
||||
it("maps a same-repo merge request view to a checkout target", async () => {
|
||||
const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR)));
|
||||
|
||||
const target = await service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 });
|
||||
expect(target).toEqual({
|
||||
await expect(
|
||||
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
|
||||
).resolves.toEqual({
|
||||
number: 14,
|
||||
baseRefName: "main",
|
||||
headRefName: "release/v0.4.0",
|
||||
@@ -480,76 +479,30 @@ describe("createGitLabService", () => {
|
||||
isCrossRepository: false,
|
||||
headRefKind: "branch",
|
||||
});
|
||||
expect(
|
||||
service.buildPrLocalBranchName?.({
|
||||
headRef: target!.headRefName,
|
||||
checkoutTarget: target!,
|
||||
}),
|
||||
).toBe("release/v0.4.0");
|
||||
expect(calls).toEqual([["mr", "view", "14", "-F", "json"]]);
|
||||
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
|
||||
});
|
||||
|
||||
it.each(["contributor", "team"])(
|
||||
"resolves the source project rather than the MR author for %s forks",
|
||||
async (owner) => {
|
||||
const { service, calls } = makeService((args) => {
|
||||
if (args[0] === "api")
|
||||
return ok(
|
||||
JSON.stringify({
|
||||
namespace: { full_path: owner },
|
||||
ssh_url_to_repo: `git@gitlab.example.com:${owner}/fork.git`,
|
||||
http_url_to_repo: `https://gitlab.example.com/${owner}/fork.git`,
|
||||
}),
|
||||
);
|
||||
return ok(
|
||||
JSON.stringify({ ...OPEN_MR, source_project_id: 202, author: { username: "mr-author" } }),
|
||||
);
|
||||
});
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "gitlab", number: 14 },
|
||||
},
|
||||
"/repo",
|
||||
{ forge: "gitlab", forgeService: service, resolveDefaultBranch: async () => "main" },
|
||||
);
|
||||
expect(intent).toMatchObject({
|
||||
kind: "checkout-change-request",
|
||||
headRepositoryOwner: owner,
|
||||
headRepository: `${owner}/fork`,
|
||||
pushRemoteUrl: `git@gitlab.example.com:${owner}/fork.git`,
|
||||
localBranchName: `${owner}/release/v0.4.0`,
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
|
||||
],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
["mr", "view", "14", "-F", "json"],
|
||||
["api", "projects/202"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
it("marks fork merge request checkout targets as cross-repository", async () => {
|
||||
const { service } = makeService(() =>
|
||||
ok(
|
||||
JSON.stringify({
|
||||
...OPEN_MR,
|
||||
source_project_id: 202,
|
||||
target_project_id: 101,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
it("keeps the target MR ref usable when its source project is inaccessible", async () => {
|
||||
const { service } = makeService((args) => {
|
||||
if (args[0] === "api") throw { code: 1, stderr: "404 Project Not Found" };
|
||||
return ok(JSON.stringify({ ...OPEN_MR, source_project_id: 202 }));
|
||||
});
|
||||
const target = await service.getPullRequestCheckoutTarget!({ cwd: "/repo", number: 14 });
|
||||
expect(target).toMatchObject({
|
||||
await expect(
|
||||
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
|
||||
).resolves.toMatchObject({
|
||||
number: 14,
|
||||
headRefName: "release/v0.4.0",
|
||||
isCrossRepository: true,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
checkoutRefs: [
|
||||
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
|
||||
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
service.buildPrLocalBranchName!({ headRef: target.headRefName, checkoutTarget: target }),
|
||||
).toBe("pr-14/release/v0.4.0");
|
||||
});
|
||||
|
||||
it("creates a merge request and parses the URL and iid from glab output", async () => {
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
} from "@getpaseo/protocol/check-traits";
|
||||
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
|
||||
import { isGitLabStatusFacts, type GitLabStatusFacts } from "./gitlab-facts.js";
|
||||
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
|
||||
|
||||
const GLAB_ENV = {
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
@@ -145,12 +144,6 @@ const GitLabPipelineDetailsSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const GitLabSourceProjectSchema = z.object({
|
||||
namespace: z.object({ full_path: z.string() }),
|
||||
ssh_url_to_repo: z.string(),
|
||||
http_url_to_repo: z.string(),
|
||||
});
|
||||
|
||||
const GitLabMergeRequestSchema = z
|
||||
.object({
|
||||
iid: z.number(),
|
||||
@@ -956,16 +949,6 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
|
||||
}
|
||||
|
||||
async function loadSourceProject(cwd: string, projectId: number) {
|
||||
try {
|
||||
return await runJson(["api", `projects/${projectId}`], { cwd }, GitLabSourceProjectSchema);
|
||||
} catch (error) {
|
||||
// The target's MR ref remains fetchable even if the source project is private.
|
||||
if (error instanceof GlabCommandError && /\b404\b/.test(error.stderr)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function listMergeRequestsBySourceBranch(
|
||||
cwd: string,
|
||||
sourceBranch: string,
|
||||
@@ -1200,23 +1183,10 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
return mr.source_branch;
|
||||
},
|
||||
|
||||
buildPrLocalBranchName({ headRef, checkoutTarget }) {
|
||||
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
|
||||
},
|
||||
|
||||
async getPullRequestCheckoutTarget(
|
||||
input: GetPullRequestOptions,
|
||||
): Promise<PullRequestCheckoutTarget> {
|
||||
const mr = await viewMergeRequest(input.cwd, String(input.number));
|
||||
const isCrossRepository =
|
||||
mr.source_project_id !== undefined &&
|
||||
mr.source_project_id !== null &&
|
||||
mr.target_project_id !== undefined &&
|
||||
mr.target_project_id !== null &&
|
||||
mr.source_project_id !== mr.target_project_id;
|
||||
const sourceProject = isCrossRepository
|
||||
? await loadSourceProject(input.cwd, mr.source_project_id!)
|
||||
: null;
|
||||
return {
|
||||
number: mr.iid,
|
||||
baseRefName: mr.target_branch,
|
||||
@@ -1225,10 +1195,15 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
|
||||
{ remoteName: "origin", remoteRef: `refs/merge-requests/${mr.iid}/head` },
|
||||
{ remoteName: "origin", remoteRef: `refs/heads/${mr.source_branch}` },
|
||||
],
|
||||
headOwnerLogin: sourceProject?.namespace.full_path ?? null,
|
||||
headRepositorySshUrl: sourceProject?.ssh_url_to_repo ?? null,
|
||||
headRepositoryUrl: sourceProject?.http_url_to_repo ?? null,
|
||||
isCrossRepository,
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository:
|
||||
mr.source_project_id !== undefined &&
|
||||
mr.source_project_id !== null &&
|
||||
mr.target_project_id !== undefined &&
|
||||
mr.target_project_id !== null &&
|
||||
mr.source_project_id !== mr.target_project_id,
|
||||
headRefKind: "branch",
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user