mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
* fix(app): open a nested-repo file when the workspace-root path is missing A workspace that contains another git repo often gets paths written relative to that nested repo. `schema/x.md` is really `repo-a/schema/x.md`. A markdown link was joined onto the workspace root and opened a missing file. Inline code already asked the daemon for a suffix match. Resolved workspace-relative paths that contain a slash now use that same lookup before opening. An exact hit stays on the original path. A suffix hit replaces it and keeps the line range. A failed lookup still opens the original path. Bare names, absolute paths, and `~/` paths are unchanged. Example: - Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md` - Before: open `/repo/schema/x.md` (missing) - After: open `/repo/repo-a/schema/x.md` * fix(app): preserve explicit destinations when finding nested files Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations. Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> * fix(app): keep network share links on their absolute destination Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths. * fix(app): keep nested-file lookup inside ordered path opening Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection. * test(app): load UI modules before test lifecycle hooks Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings. --------- Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
610 lines
17 KiB
TypeScript
610 lines
17 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useDraftStore } from "@/stores/draft-store";
|
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
|
|
|
const { asyncStorage } = vi.hoisted(() => ({
|
|
asyncStorage: new Map<string, string>(),
|
|
}));
|
|
|
|
vi.hoisted(() => {
|
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
|
});
|
|
|
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
default: {
|
|
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
|
setItem: async (key: string, value: string) => {
|
|
asyncStorage.set(key, value);
|
|
},
|
|
removeItem: async (key: string) => {
|
|
asyncStorage.delete(key);
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/attachments/service", () => ({
|
|
garbageCollectAttachments: async () => undefined,
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-agent-form-state", () => ({
|
|
useAgentFormState: () => ({
|
|
selectedServerId: "host-1",
|
|
selectedProvider: "codex",
|
|
setProviderFromUser: () => undefined,
|
|
selectedMode: "auto",
|
|
setModeFromUser: () => undefined,
|
|
selectedModel: "",
|
|
setModelFromUser: () => undefined,
|
|
selectedThinkingOptionId: "",
|
|
setThinkingOptionFromUser: () => undefined,
|
|
workingDir: "/repo",
|
|
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
|
providerDefinitionMap: new Map(),
|
|
agentDefinition: undefined,
|
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
availableModels: [
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "gpt-5.4",
|
|
isDefault: true,
|
|
defaultThinkingOptionId: "high",
|
|
thinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
},
|
|
],
|
|
allProviderModels: new Map([
|
|
[
|
|
"codex",
|
|
[
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "gpt-5.4",
|
|
isDefault: true,
|
|
defaultThinkingOptionId: "high",
|
|
thinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
},
|
|
],
|
|
],
|
|
]),
|
|
modelSelectorProviders: [
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
{
|
|
favoriteKey: "codex:gpt-5.4",
|
|
provider: "codex",
|
|
providerLabel: "Codex",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "gpt-5.4",
|
|
isDefault: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
isAllModelsLoading: false,
|
|
isProviderModelsRefreshing: false,
|
|
availableThinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
isModelLoading: false,
|
|
modelError: null,
|
|
refreshProviderModels: () => undefined,
|
|
setProviderAndModelFromUser: () => undefined,
|
|
workingDirIsEmpty: false,
|
|
persistFormPreferences: async () => undefined,
|
|
}),
|
|
}));
|
|
|
|
const mountedRoots = new Set<Root>();
|
|
function createTestRoot(container: HTMLElement): Root {
|
|
const root = createRoot(container);
|
|
mountedRoots.add(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
for (const root of mountedRoots) root.unmount();
|
|
mountedRoots.clear();
|
|
});
|
|
});
|
|
|
|
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
|
|
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
const { useAgentInputDraft } = await import("./input-draft");
|
|
|
|
describe("useAgentInputDraft live contract", () => {
|
|
beforeEach(() => {
|
|
asyncStorage.clear();
|
|
document.body.innerHTML = "<div id='root'></div>";
|
|
localStorage.clear();
|
|
|
|
useDraftStore.setState({
|
|
drafts: {},
|
|
createModalDraft: null,
|
|
attachmentFocusRequestByDraftKey: {},
|
|
});
|
|
});
|
|
|
|
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "attachment-1",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/1",
|
|
createdAt: 1,
|
|
fileName: "image.png",
|
|
byteSize: 128,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe({ draftKey }: { draftKey: string }) {
|
|
latest = useAgentInputDraft({
|
|
draftKey,
|
|
composer: {
|
|
initialServerId: "host-1",
|
|
isVisible: true,
|
|
lockedWorkingDir: "/repo",
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
let root: Root | null = createTestRoot(container);
|
|
await act(async () => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe draftKey="draft:setup" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
|
expect(getLatest().composerState?.commandDraft).toEqual({
|
|
status: "ready",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/repo",
|
|
modeId: "auto",
|
|
model: "gpt-5.4",
|
|
thinkingOptionId: "high",
|
|
},
|
|
});
|
|
|
|
const hydratedTextReplacement = getLatest().textReplacement;
|
|
// The editor applies a retained caret to the hydrated text, so hydration announces itself.
|
|
expect(hydratedTextReplacement.kind).toBe("hydrated");
|
|
|
|
await act(async () => {
|
|
getLatest().editText("hello world");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
expect(getLatest().textReplacement).toBe(hydratedTextReplacement);
|
|
|
|
await act(async () => {
|
|
getLatest().replaceText("replacement text");
|
|
});
|
|
|
|
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
|
|
expect(getLatest().textReplacement.kind).toBe("replace");
|
|
expect(getLatest().textReplacement.text).toBe("replacement text");
|
|
|
|
await act(async () => {
|
|
getLatest().editText("hello world");
|
|
});
|
|
|
|
await act(async () => {
|
|
root!.unmount();
|
|
});
|
|
|
|
root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe draftKey="draft:setup" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("hello world");
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
it("migrates legacy image drafts to image attachments on hydration", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "legacy-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/legacy-image",
|
|
createdAt: 10,
|
|
fileName: "legacy.png",
|
|
byteSize: 512,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:legacy": {
|
|
input: {
|
|
text: "legacy text",
|
|
images: [image],
|
|
},
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
createModalDraft: null,
|
|
});
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:legacy" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("legacy text");
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
|
|
text: "legacy text",
|
|
attachments: [{ kind: "image", metadata: image }],
|
|
});
|
|
});
|
|
|
|
it("hydrates drafts saved by old builds with cwd", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const githubIssue: ComposerAttachment = {
|
|
kind: "github_issue",
|
|
item: {
|
|
kind: "issue",
|
|
number: 42,
|
|
title: "Unify attachments",
|
|
url: "https://github.com/paseo/paseo/issues/42",
|
|
state: "open",
|
|
body: "body",
|
|
labels: ["composer"],
|
|
},
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:new-shape": {
|
|
input: {
|
|
text: "new text",
|
|
attachments: [githubIssue],
|
|
cwd: "/persisted",
|
|
},
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
createModalDraft: null,
|
|
});
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:new-shape" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("new text");
|
|
expect(getLatest().attachments).toEqual([githubIssue]);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
|
|
expect(useDraftStore.getState().drafts["draft:new-shape"]?.input).toEqual({
|
|
text: "new text",
|
|
attachments: [githubIssue],
|
|
cwd: "/persisted",
|
|
});
|
|
});
|
|
|
|
it("updates and persists attachments through setAttachments", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "next-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/next-image",
|
|
createdAt: 11,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
let renders = 0;
|
|
function Probe() {
|
|
renders += 1;
|
|
latest = useAgentInputDraft({ draftKey: "draft:attachments" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("with attachment");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input;
|
|
await act(async () => {
|
|
// Web text publication occurs after paint; attachments save immediately.
|
|
await expect.poll(readPersistedInput).toEqual({
|
|
text: "with attachment",
|
|
attachments: [{ kind: "image", metadata: image }],
|
|
});
|
|
});
|
|
const settledRenders = renders;
|
|
const attached = getLatest().attachments;
|
|
const source = getLatest().textSource;
|
|
let textNotifications = 0;
|
|
const unsubscribe = source.subscribe(() => {
|
|
textNotifications += 1;
|
|
});
|
|
await act(async () => {
|
|
getLatest().editText("with attachment\n");
|
|
await expect.poll(source.getSnapshot).toBe("with attachment\n");
|
|
});
|
|
expect(renders).toBe(settledRenders);
|
|
expect(getLatest().attachments).toBe(attached);
|
|
expect(getLatest().textSource).toBe(source);
|
|
expect(textNotifications).toBe(1);
|
|
unsubscribe();
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
|
const image: AttachmentMetadata = {
|
|
id: "legacy-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/legacy-image",
|
|
createdAt: 10,
|
|
};
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:legacy-workspace-file": {
|
|
input: { text: "legacy text", images: [image] },
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
});
|
|
|
|
await useDraftStore.getState().attachWorkspaceFile({
|
|
draftKey: "draft:legacy-workspace-file",
|
|
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
|
});
|
|
|
|
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
|
text: "legacy text",
|
|
attachments: [
|
|
{ kind: "image", metadata: image },
|
|
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("clear resets text and attachments", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "clear-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/clear-image",
|
|
createdAt: 12,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:clear" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("queued message");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("sent");
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("");
|
|
expect(getLatest().attachments).toEqual([]);
|
|
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
|
|
text: "",
|
|
attachments: [],
|
|
});
|
|
});
|
|
|
|
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const sentImage: AttachmentMetadata = {
|
|
id: "attachment-sent",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/sent",
|
|
createdAt: 2,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("queued message");
|
|
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("sent");
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("");
|
|
expect(getLatest().attachments).toEqual([]);
|
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
|
lifecycle: "sent",
|
|
input: { text: "", attachments: [] },
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("draft again");
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("abandoned");
|
|
});
|
|
|
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
|
lifecycle: "abandoned",
|
|
input: { text: "", attachments: [] },
|
|
});
|
|
});
|
|
});
|