Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 337 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ function renderWithI18n(node: ReturnType<typeof createElement>) {
return render(createElement(I18nextProvider, { i18n }, node));
}

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}

describe("OpenPI React transcript", () => {
it("renders sanitized GFM and projects images as links", () => {
const { container } = render(
Expand Down Expand Up @@ -784,3 +792,332 @@ it("does not repeat a provider identity used as the fallback model label", () =>
screen.queryByText("provider-alpha/model-a (provider-alpha/model-a)"),
).toBeNull();
});

it("keeps a retyped draft when an earlier send settles", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");

fireEvent.change(input, { target: { value: "first" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));
fireEvent.change(input, { target: { value: "second" } });
fireEvent.change(input, { target: { value: "first" } });

await act(async () => {
result.resolve(true);
await result.promise;
});

expect(input.value).toBe("first");
});

it("keeps a draft after a failed send", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
renderWithI18n(
createElement(Composer, {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
}),
);
const input = screen.getByRole<HTMLTextAreaElement>("textbox");

fireEvent.change(input, { target: { value: "keep me" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));
await act(async () => {
result.resolve(false);
await result.promise;
});

expect(input.value).toBe("keep me");
});

it("clears an old session draft without letting its late send clear the new one", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "old session" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));

const nextSnapshot = {
...snapshot,
currentSessionId: "next-session",
selectedSession: {
...snapshot.selectedSession!,
id: "next-session",
path: "/tmp/next-session",
},
};
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: nextSnapshot,
selectedPath: "/tmp/next-session",
}),
),
);
expect(input.value).toBe("");

fireEvent.change(input, { target: { value: "new session" } });
await act(async () => {
result.resolve(true);
await result.promise;
});

expect(input.value).toBe("new session");
});

it("clears an active Session draft when switching to another workspace", () => {
const store = createWebStore();
const snapshot = activeSnapshot();
snapshot.workspaces = [
{ path: "/tmp", name: "A", current: true },
{ path: "/tmp/other", name: "B", current: false },
];
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: store.getState().actions,
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "Session A draft" } });

view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
selectedWorkspace: "/tmp/other",
workspaceDraft: true,
selectedPath: "/tmp/session",
}),
),
);

expect(input.value).toBe("");
});

it("transfers an unsent draft through manual new-session creation", () => {
const store = createWebStore();
const snapshot = activeSnapshot();
const props = {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: store.getState().actions,
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "keep this draft" } });

view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
workspaceDraft: true,
selectedPath: null,
sessionSwitching: true,
}),
),
);
expect(input.value).toBe("keep this draft");

const createdSnapshot = {
...snapshot,
currentSessionId: "created-session",
selectedSession: {
...snapshot.selectedSession!,
id: "created-session",
path: "/tmp/created-session",
},
};
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: createdSnapshot,
selectedPath: "/tmp/created-session",
sessionSwitching: false,
}),
),
);
expect(input.value).toBe("keep this draft");
});

it("ignores a rapid second Enter while admission is pending", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
renderWithI18n(
createElement(Composer, {
snapshot,
selectedPath: "/tmp/session",
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: false,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
}),
);
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "once" } });
fireEvent.keyDown(input, {
key: "Enter",
nativeEvent: { isComposing: false },
});
fireEvent.keyDown(input, {
key: "Enter",
nativeEvent: { isComposing: false },
});

expect(sendPrompt).toHaveBeenCalledOnce();
await act(async () => {
result.resolve(true);
await result.promise;
});
expect(input.value).toBe("");
});

it("transfers a new-session draft until its first send is accepted", async () => {
const result = deferred<boolean>();
const store = createWebStore();
const sendPrompt = vi.fn(() => result.promise);
const draftSnapshot = activeSnapshot();
draftSnapshot.runtime.status = "idle";
delete draftSnapshot.currentSessionId;
delete draftSnapshot.selectedSession;
draftSnapshot.sessions = [];
const props = {
snapshot: draftSnapshot,
selectedPath: null,
selectedWorkspace: "/tmp",
sessionSwitching: false,
promptAdmissionPending: false,
liveRunning: false,
landing: true,
activeTurn: null,
turnCancellationPending: false,
turnTerminalStatus: null,
pendingFollowUpsReceipt: null,
actions: { ...store.getState().actions, sendPrompt },
};
const view = renderWithI18n(createElement(Composer, props));
const input = screen.getByRole<HTMLTextAreaElement>("textbox");
fireEvent.change(input, { target: { value: "first prompt" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));

const createdSnapshot = {
...draftSnapshot,
currentSessionId: "created-session",
selectedSession: {
id: "created-session",
path: "/tmp/created-session",
cwd: "/tmp",
entries: [],
bytes: 0,
truncation,
},
};
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: createdSnapshot,
selectedPath: "/tmp/created-session",
sessionSwitching: true,
landing: false,
}),
),
);
expect(input.value).toBe("first prompt");

await act(async () => {
result.resolve(true);
await result.promise;
});
expect(input.value).toBe("");
});
11 changes: 6 additions & 5 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,13 +994,14 @@ test("workspace selection survives refresh and creates the exact native Session
const { path: canonicalWorkspace } = await imported.json();
const before = await page.request.get("/api/snapshot", { headers });
const initial = await before.json();
const workspaceName = canonicalWorkspace.split("/").at(-1);
const workspaceName = canonicalWorkspace.split(/[\\/]/u).at(-1);
const prompts: Array<{ sessionId: string; content: string }> = [];
// Only intercept model admission. Workspace import, snapshots and native
// Session creation use the isolated real Host and Pi runtime.
await page.route("**/api/prompt", async (route) => {
const body = route.request().postDataJSON();
prompts.push({ sessionId: body.sessionId, content: body.content });
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({
status: 202,
json: { id: body.commandId, accepted: true },
Expand All @@ -1025,11 +1026,11 @@ test("workspace selection survives refresh and creates the exact native Session
});
await refresh;
await expect(picker).toHaveText(workspaceName);
await page
.getByRole("textbox", { name: "描述任务" })
.fill("Only work in the selected repository");
await page.getByRole("button", { name: "发送", exact: true }).click();
const input = page.getByRole("textbox", { name: "描述任务" });
await input.fill("Only work in the selected repository");
await Promise.all([input.press("Enter"), input.press("Enter")]);
await expect.poll(() => prompts.length).toBe(1);
await expect(input).toHaveValue("");
const after = await page.request.get("/api/snapshot", { headers });
const current = await after.json();
expect(current.selectedSession.cwd).toBe(canonicalWorkspace);
Expand Down
4 changes: 2 additions & 2 deletions web/dist/app.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions web/ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export function App() {
turnTerminalStatus={state.turnTerminalStatus}
pendingFollowUpsReceipt={state.pendingFollowUpsReceipt}
snapshot={state.snapshot}
selectedPath={state.selectedPath}
selectedWorkspace={state.selectedWorkspace}
sessionSwitching={state.sessionSwitching}
promptAdmissionPending={state.promptAdmissionPending}
Expand Down
Loading
Loading