mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12824' into takeover/12824
# Conflicts: # desktop/frontend-next/src/ui/WorkbenchPanel.tsx # desktop/frontend-next/src/ui/browser-missed-read.interaction.test.tsx # desktop/frontend-next/src/ui/browser-open.test.ts # desktop/frontend-next/src/ui/html-preview.interaction.test.tsx # desktop/frontend-next/src/ui/useColumnOpening.ts
This commit is contained in:
commit
a81212d745
20 files changed
+799
-29
No files matched your search
@@ -500,6 +500,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.clear-search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
|
||||
@@ -17,6 +17,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
|
||||
"正在处理所选会话…": "Processing selected sessions…",
|
||||
"删除 {n} 个会话?": "Delete {n} sessions?",
|
||||
"请先打开一个会话,再查看它的文件。": "Open a session first to browse its files.",
|
||||
"运行指标": "Run metrics",
|
||||
"时间线": "Timeline",
|
||||
"定位": "Locate",
|
||||
|
||||
@@ -102,6 +102,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
|
||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||
const [findPulse, setFindPulse] = useState(0);
|
||||
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
|
||||
const [error, setError] = useState("");
|
||||
// false = closed, true = open at its last section, a string = open there.
|
||||
const [settings, setSettings] = useState<string | boolean>(false);
|
||||
@@ -342,6 +343,16 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
void reloadRemotes();
|
||||
}, [reloadRemotes]);
|
||||
|
||||
const openFiles = useCallback(() => {
|
||||
if (!panePorts.has(active)) {
|
||||
setError(t("请先打开一个会话,再查看它的文件。"));
|
||||
return;
|
||||
}
|
||||
hidePrefs();
|
||||
if (roomGaveUp("scene")) setRail(false);
|
||||
setFilesRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 }));
|
||||
}, [active, panePorts, hidePrefs]);
|
||||
|
||||
const openPane = useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes: refreshPanes, sw });
|
||||
|
||||
// Awaitable because deleting a conversation has to close its pane first and
|
||||
@@ -577,6 +588,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
<Nav
|
||||
at={settings === false ? null : settings === true ? "" : settings}
|
||||
onGo={showPrefs}
|
||||
onFiles={openFiles}
|
||||
onHome={hidePrefs}
|
||||
onRail={() => setRail((v) => !v)}
|
||||
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
|
||||
@@ -618,6 +630,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
accountUnread={accountUnread}
|
||||
wallet={report.wallet}
|
||||
onSettings={showPrefs}
|
||||
onFiles={openFiles}
|
||||
onFeedback={(tab) => setFeedback(tab ?? "send")}
|
||||
feedbackUnread={feedbackUnread}
|
||||
onFind={openFind}
|
||||
@@ -670,6 +683,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onSessionChanged={refreshPanes}
|
||||
pulse={settingsPulse}
|
||||
findPulse={findPulse}
|
||||
filesPulse={filesRequests[rt.id] ?? 0}
|
||||
alert={rt.id === active ? bar : undefined}
|
||||
needsProject={needsProject}
|
||||
onOpenProject={() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { Nav } from "./Nav";
|
||||
import { ICON, type Section } from "./prefsnav";
|
||||
|
||||
const props = { at: null, onGo: vi.fn(), onHome: vi.fn(), onRail: vi.fn(), onFeedback: vi.fn(), feedbackUnread: 0, shown: true };
|
||||
const props = { at: null, onGo: vi.fn(), onFiles: vi.fn(), onHome: vi.fn(), onRail: vi.fn(), onFeedback: vi.fn(), feedbackUnread: 0, shown: true };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -13,7 +13,6 @@ const sectionMark = (section: Section) => render(<svg>{ICON[section]}</svg>).con
|
||||
describe("the icon rail draws the mark of the section it opens", () => {
|
||||
it.each([
|
||||
["用量", "usage"],
|
||||
["文件", "storage"],
|
||||
["工具与权限", "tools"],
|
||||
["扩展", "ext"],
|
||||
["记忆", "memory"],
|
||||
@@ -27,3 +26,14 @@ describe("the icon rail draws the mark of the section it opens", () => {
|
||||
expect(btn?.querySelector("svg")?.innerHTML).toBe(sectionMark(section));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("routes Files to the explorer without marking it as Storage", () => {
|
||||
const { container } = render(<Nav {...props} at="storage" />);
|
||||
const files = container.querySelector('button[aria-label="文件"]')!;
|
||||
expect(files.hasAttribute("aria-current")).toBe(false);
|
||||
expect(files.getAttribute("data-action")).toBe("files.open");
|
||||
fireEvent.click(files);
|
||||
expect(props.onFiles).toHaveBeenCalledOnce();
|
||||
expect(props.onGo).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3,18 +3,16 @@ import { FeedbackBadge } from "./feedbackentry";
|
||||
import { ICON, type Section } from "./prefsnav";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// An entry that opens a settings section draws that section's mark from prefsnav,
|
||||
// so the rail and the page it opens never show two icons for one destination. Only
|
||||
// the conversation item, which opens no section, keeps a mark of its own.
|
||||
// Settings entries share their destination's mark with prefsnav.
|
||||
const CHAT = "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z";
|
||||
type Mark = "chat" | Section;
|
||||
type Mark = "chat" | "files" | Section;
|
||||
|
||||
// Every entry goes somewhere that exists. A rail of icons that open nothing is
|
||||
// the cheapest thing to draw and the most expensive thing to keep.
|
||||
const TO: [Mark, string, string | null][] = [
|
||||
const TO: [Mark, string, string | null, string?][] = [
|
||||
["chat", "会话", null],
|
||||
["usage", "用量", "usage"],
|
||||
["storage", "文件", "storage"],
|
||||
["files", "文件", "files", "files.open"],
|
||||
["tools", "工具与权限", "tools"],
|
||||
["ext", "扩展", "ext"],
|
||||
["memory", "记忆", "memory"],
|
||||
@@ -29,6 +27,7 @@ const FOOT: [Mark, string, string, string][] = [
|
||||
interface Props {
|
||||
at: string | null;
|
||||
onGo: (sec?: string) => void;
|
||||
onFiles: () => void;
|
||||
onHome: () => void;
|
||||
/** The conversation item already shows the conversation, so pressing it again
|
||||
* does what the rail beside it can: open or close the workspace column. */
|
||||
@@ -39,7 +38,7 @@ interface Props {
|
||||
shown: boolean;
|
||||
}
|
||||
|
||||
export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, shown }: Props) {
|
||||
export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, feedbackUnread, shown }: Props) {
|
||||
const btn = ([key, label, sec, action]: [Mark, string, string | null, string?]) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -47,11 +46,11 @@ export function Nav({ at, onGo, onHome, onRail, onFeedback, feedbackUnread, show
|
||||
data-action={action}
|
||||
aria-label={t(label)}
|
||||
aria-current={(sec ?? null) === at ? "page" : undefined}
|
||||
onClick={() => (sec === null ? (at === null ? onRail() : onHome()) : onGo(sec || undefined))}
|
||||
onClick={() => (key === "files" ? onFiles() : sec === null ? (at === null ? onRail() : onHome()) : onGo(sec || undefined))}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
{key === "files" ? <StudioIcon name="file" aria-hidden="true" /> : <svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
{key === "chat" ? <path d={CHAT} /> : ICON[key]}
|
||||
</svg>
|
||||
</svg>}
|
||||
<span className="navhint" role="tooltip">{t(label)}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Metrics } from "./Metrics";
|
||||
import { railOf } from "./panels/derive";
|
||||
import { accountOf, useHidesAmounts, useWallet } from "./wallet";
|
||||
import { PaneNav } from "./PaneNav";
|
||||
import { useOnBump } from "./useOnBump";
|
||||
import { useShowView } from "./showview";
|
||||
import { useSubmit } from "./usesubmit";
|
||||
import { useSurfaceSlots } from "./usesurfaceslots";
|
||||
@@ -74,7 +75,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, filesPulse = 0, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -298,6 +299,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
setRevealChanges((n) => n + 1);
|
||||
showView("browser");
|
||||
}, [showView]);
|
||||
useOnBump(filesPulse, openChanges);
|
||||
useEffect(() => { void loadChangedFilesPref(port); }, [port]);
|
||||
useEffect(() => { void loadFoldWorkPref(port); }, [port]);
|
||||
const foldWork = useFoldsTurnWork(port);
|
||||
|
||||
@@ -57,6 +57,7 @@ interface Props {
|
||||
accountUnread: string;
|
||||
wallet: string;
|
||||
onSettings: (section?: string) => void;
|
||||
onFiles: () => void;
|
||||
onFeedback: (tab?: "send" | "mine") => void;
|
||||
// Reports with a new reply or a question waiting; the entry and the palette say so.
|
||||
feedbackUnread: number;
|
||||
@@ -102,6 +103,7 @@ export function Sidebar({
|
||||
accountUnread,
|
||||
wallet,
|
||||
onSettings,
|
||||
onFiles,
|
||||
onFeedback,
|
||||
feedbackUnread,
|
||||
onFind,
|
||||
@@ -149,7 +151,8 @@ export function Sidebar({
|
||||
run: () => void onOpen({ root: newSessionRoot }).catch(onError) },
|
||||
{ id: "find", label: t("在这段对话里查找"), icon: "search", keywords: "find search conversation 查找 搜索 对话",
|
||||
keys: chord("F"), run: onFind },
|
||||
{ id: "storage", label: t("文件"), icon: "file", keywords: "files storage 文件 存储",
|
||||
{ id: "files", label: t("文件"), icon: "file", keywords: "files workspace explorer 文件 工作区", run: onFiles },
|
||||
{ id: "storage", label: t("存储"), icon: "file", keywords: "storage disk data 存储 磁盘 数据",
|
||||
run: () => onSettings("storage") },
|
||||
{ id: "ext", label: t("工具与集成"), icon: "plug", keywords: "tools mcp skills 工具 集成",
|
||||
run: () => onSettings("ext") },
|
||||
@@ -168,7 +171,7 @@ export function Sidebar({
|
||||
{ id: "settings", label: t("设置"), icon: "settings", keywords: "settings preferences 设置 偏好",
|
||||
run: () => onSettings() },
|
||||
],
|
||||
[newSessionRoot, onOpen, onSettings, onFeedback, feedbackUnread, onFind, onError],
|
||||
[newSessionRoot, onOpen, onFiles, onSettings, onFeedback, feedbackUnread, onFind, onError],
|
||||
);
|
||||
const sessionCount = tree.reduce(
|
||||
(n, ws) => n + (ws.total === undefined ? ws.sessions.filter((session) => !session.archived).length : ws.total - (ws.archived ?? 0)),
|
||||
@@ -231,7 +234,7 @@ export function Sidebar({
|
||||
</button>
|
||||
{!navShown && (
|
||||
<div className="studio-quicknav">
|
||||
<button data-action="settings.section" data-value="storage" onClick={() => onSettings("storage")}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
|
||||
<button data-action="files.open" onClick={onFiles}><span aria-hidden="true"><StudioIcon name="file" /></span><b>{t("文件")}</b></button>
|
||||
<button data-action="settings.section" data-value="ext" onClick={() => onSettings("ext")}><span aria-hidden="true"><StudioIcon name="plug" /></span><b>{t("工具与集成")}</b><small>MCP · Skills</small></button>
|
||||
<button data-action="settings.section" data-value="remote" onClick={() => onSettings("remote")}><span aria-hidden="true"><StudioIcon name="server" /></span><b>{t("运行环境")}</b><small>Local / SSH</small></button>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,7 @@ export function WorkbenchPanel({
|
||||
const dropBlankStart = useCallback(() => setBrowsers((open) => open.filter((id) => !!visited.current[id])), []);
|
||||
const agentNow = useRef(agentTarget);
|
||||
agentNow.current = agentTarget;
|
||||
useColumnOpening({ manual, browserAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
|
||||
useColumnOpening({ manual, browserAsk, filesAsk: revealChanges, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
|
||||
// A page that has just appeared is the one somebody wants to see, whoever
|
||||
// opened it: the agent's popup, or a link the person followed, which opens
|
||||
// beside the agent's tab without becoming the one it acts on.
|
||||
|
||||
@@ -43,3 +43,39 @@ it.each(['failed-read', 'stream-gap'].flatMap(missed => ['same-url', 'changed-ur
|
||||
expect(document.querySelector('[data-target="browser:target-b"]')?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['failed-read', 'stream-gap'].flatMap(missed => ['same-url', 'changed-url', 'deactivation'].flatMap(activity => ['before', 'after'].map(order => [missed, activity, order]))))('follows only later activity after %s (%s, response %s event)', async (missed, activity, order) => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
const port = new MockPort();
|
||||
const first = { id: 'a', target: 'target-a', url: 'https://first.example/', title: 'First', active: true };
|
||||
const opened = { id: 'b', target: 'target-b', url: 'https://opened.example/', title: 'Opened', active: activity === 'deactivation' };
|
||||
let emit!: (event: WireEvent) => void, gap!: () => void, complete!: (tab: typeof opened) => void;
|
||||
const subscribe = port.subscribe.bind(port);
|
||||
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; gap = onGap!; return subscribe(onEvent, onGap, bootstrap); });
|
||||
const reads = vi.spyOn(port, 'browserTabs').mockResolvedValue(activity === 'deactivation' ? [] : [first]);
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { complete = done; }));
|
||||
const pending = openPage(port, opened.url);
|
||||
function Harness({filesPulse}: {filesPulse:number}) {
|
||||
const [manual, setManual] = useState(true);
|
||||
return <Pane {...props} rt={{id:'missed-read',base:'',root:'/w',name:'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
|
||||
}
|
||||
const {rerender} = render(<Harness filesPulse={0} />);
|
||||
await waitFor(() => expect(reads).toHaveBeenCalledOnce());
|
||||
await act(async () => {});
|
||||
rerender(<Harness filesPulse={1} />);
|
||||
const choiceShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
|
||||
expect(choiceShown()).toBe(true);
|
||||
if (missed === 'failed-read') {
|
||||
reads.mockRejectedValueOnce(new Error('tab read failed'));
|
||||
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
|
||||
} else await act(async () => { gap(); });
|
||||
if (order === 'before') await act(async () => { complete(opened); await pending; });
|
||||
expect(choiceShown()).toBe(true);
|
||||
const later = {...opened, active:activity !== 'deactivation', url:activity === 'changed-url' ? opened.url+'later' : opened.url};
|
||||
reads.mockResolvedValue([{...first,active:activity === 'deactivation'},later]);
|
||||
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
|
||||
if (order === 'after') await act(async () => { complete(opened); await pending; });
|
||||
expect(choiceShown()).toBe(false);
|
||||
expect(document.querySelector(`[data-target="browser:${activity === 'deactivation' ? first.target : opened.target}"]`)?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { refresh } from "./viewport";
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(1440);
|
||||
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
refresh();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); sessionStorage.clear(); });
|
||||
|
||||
async function mount(empty = false) {
|
||||
const hub = new MockHub();
|
||||
const runtime = empty ? null : await hub.open({ root: "~/projects/my-website", sessionPath: "/sessions/site.jsonl" });
|
||||
if (empty) await hub.close("r1");
|
||||
const ports = (await hub.runtimes()).map((rt) => hub.portFor(rt));
|
||||
for (const port of ports) {
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
vi.spyOn(port, "browserOpen");
|
||||
}
|
||||
if (runtime) sessionStorage.setItem("rx-active-pane", runtime.id);
|
||||
const open = vi.spyOn(hub, "open");
|
||||
await act(async () => { render(<App hub={hub} />); });
|
||||
await waitFor(() => expect(document.querySelector(".rail")).toBeTruthy());
|
||||
return { ports, open };
|
||||
}
|
||||
const focused = () => document.querySelector<HTMLElement>(".pane[data-active]")!;
|
||||
const filesShown = (pane = focused()) => pane.querySelector(".workbench-body")!.hasAttribute("data-files");
|
||||
async function palette(query: string) {
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
await userEvent.type(await screen.findByRole("textbox", { name: "搜索会话或操作" }), query);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
}
|
||||
|
||||
it.each(["rail", "sidebar", "palette"])("opens only the focused pane's files from %s and handles repeated navigation", async (entry) => {
|
||||
if (entry === "sidebar") localStorage.setItem("rx-nav-rail", "off");
|
||||
const { ports } = await mount();
|
||||
const openFiles = async () => {
|
||||
if (entry === "palette") return palette("文件");
|
||||
const scope = document.querySelector<HTMLElement>(entry === "rail" ? ".nav" : ".studio-quicknav")!;
|
||||
await userEvent.click(within(scope).getByRole("button", { name: "文件" }));
|
||||
};
|
||||
await openFiles();
|
||||
await waitFor(() => expect(filesShown()).toBe(true));
|
||||
expect(document.querySelector(".app")!.getAttribute("data-browser")).toBe("on");
|
||||
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
|
||||
const background = document.querySelector<HTMLElement>(".pane[data-off]")!;
|
||||
expect(filesShown(background)).toBe(false);
|
||||
expect(focused().querySelectorAll(".workbench-tab")).toHaveLength(0);
|
||||
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
|
||||
expect(filesShown()).toBe(false);
|
||||
await openFiles();
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(filesShown(background)).toBe(false);
|
||||
expect(screen.queryByRole("dialog", { name: "搜索与快捷操作" })).toBeNull();
|
||||
for (const port of ports) expect(port.browserOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Storage in settings and dismisses settings when Files is chosen", async () => {
|
||||
await mount();
|
||||
await palette("storage");
|
||||
await waitFor(() => expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(true));
|
||||
await waitFor(() => expect(document.querySelector("#set-storage")).toBeTruthy());
|
||||
await palette("文件");
|
||||
await waitFor(() => expect(filesShown()).toBe(true));
|
||||
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the phone-width sidebar drawer after choosing files", async () => {
|
||||
vi.mocked(Object.getOwnPropertyDescriptor(document.body, "clientWidth")!.get!).mockReturnValue(390);
|
||||
refresh();
|
||||
await mount();
|
||||
fireEvent.click(document.querySelector('[data-action="chrome.rail"]')!);
|
||||
expect(document.querySelector(".app")!.getAttribute("data-rail")).toBe("on");
|
||||
fireEvent.click(document.querySelector(".studio-quicknav button")!);
|
||||
await waitFor(() => expect(filesShown()).toBe(true));
|
||||
expect(document.querySelector(".app")!.getAttribute("data-rail")).toBe("off");
|
||||
});
|
||||
|
||||
it("does not open an arbitrary project when no session is open", async () => {
|
||||
const { open } = await mount(true);
|
||||
await palette("文件");
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".errbar")!.textContent).toContain("请先打开一个会话");
|
||||
expect(document.querySelector(".app")!.hasAttribute("data-prefs")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replay a Files request into a newly focused session", async () => {
|
||||
await mount();
|
||||
await palette("文件");
|
||||
await userEvent.click(within(focused()).getByRole("button", { name: "README.md" }));
|
||||
const previous = focused();
|
||||
fireEvent.click(document.querySelector('.ptab[data-pane="r1"]')!);
|
||||
expect(focused()).not.toBe(previous);
|
||||
expect(filesShown()).toBe(false);
|
||||
await palette("文件");
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(filesShown(previous)).toBe(false);
|
||||
fireEvent.click(document.querySelector('.ptab[data-pane="r2"]')!);
|
||||
expect(filesShown()).toBe(false);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import * as hostModule from "../port/host";
|
||||
import type { BrowserTab } from "../port/port";
|
||||
import { SsePort } from "../port/sse";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { unseenPageOpenings } from "./browser_open";
|
||||
import { openPage, unseenPageOpenings } from "./browser_open";
|
||||
|
||||
const ROOT = "/ws/site";
|
||||
const page = (over: Partial<BrowserTab> = {}): BrowserTab => ({
|
||||
@@ -47,6 +47,57 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([["before", false], ["before", true], ["after", false], ["after", true]])("keeps newer Files intent through a failed preview (event %s response, mixed=%s)", async (order, mixed) => {
|
||||
const user = userEvent.setup();
|
||||
const { port, open } = setup();
|
||||
let reply!: (response: Response) => void;
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((resolve) => { reply = resolve; })));
|
||||
const wire = new SsePort("", "r1");
|
||||
open.mockImplementation((url) => wire.browserOpen(url, true));
|
||||
const agent = page({ id: "agent", target: "ta", url: "https://example.com", title: "Agent", active: true });
|
||||
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
|
||||
const { container, rerender } = render(panel(port, { tabs: [agent] }));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
const tabs = mixed ? [agent, fresh, page()] : [agent, page()];
|
||||
if (order === "before") {
|
||||
rerender(panel(port, { tabs }));
|
||||
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
|
||||
}
|
||||
await act(async () => reply(new Response(JSON.stringify({ code: "browser.open_failed", params: { tab: page().id, error: "net::ERR_FILE_NOT_FOUND" } }), { status: 400 })));
|
||||
if (order === "after") rerender(panel(port, { tabs }));
|
||||
await waitFor(() => expect(unseenPageOpenings(port)).toHaveLength(0));
|
||||
expect(screen.getByRole("tab", { name: "Landing" }).getAttribute("aria-selected")).toBe("false");
|
||||
await waitFor(() => expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(!mixed));
|
||||
if (mixed) expect(screen.getByRole("tab", { name: "Fresh" }).getAttribute("aria-selected")).toBe("true");
|
||||
rerender(panel(port, { tabs: [{ ...agent, active: false }, page({ active: true, url: "https://example.com/reused" })] }));
|
||||
await waitFor(() => expect(screen.getByRole("tab", { name: "Landing" }).getAttribute("aria-selected")).toBe("true"));
|
||||
});
|
||||
|
||||
it("keeps a superseded successful user tab owned while a separate preview claim hides it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { port, open } = setup();
|
||||
let previewDone!: (tab: BrowserTab) => void;
|
||||
let userDone!: (tab: BrowserTab) => void;
|
||||
open.mockReturnValueOnce(new Promise((resolve) => { previewDone = resolve; }));
|
||||
open.mockReturnValueOnce(new Promise((resolve) => { userDone = resolve; }));
|
||||
const userTab = page({ id: "user", target: "tu", url: "https://example.com", title: "User page" });
|
||||
const { container, rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
const pending = openPage(port, userTab.url);
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
await act(async () => { userDone(userTab); await pending; });
|
||||
rerender(panel(port, { tabs: [userTab, page()] }));
|
||||
expect(screen.queryByRole("tab", { name: "User page" })).toBeNull();
|
||||
await act(async () => previewDone(page()));
|
||||
await waitFor(() => expect(unseenPageOpenings(port)).toHaveLength(0));
|
||||
expect(screen.getByRole("tab", { name: "User page" }).getAttribute("aria-selected")).toBe("false");
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
});
|
||||
|
||||
describe("opening an HTML file from the explorer", () => {
|
||||
it.each(["Files", "launcher"])("does not start a hidden preview after a late file read and newer %s choice", async (choice) => {
|
||||
const user = userEvent.setup();
|
||||
@@ -108,6 +159,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
await waitFor(() => expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy());
|
||||
expect(screen.getByRole("button", { name: "预览" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(document.querySelector(".cm-content")).toBeNull();
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not turn the page it opened for the file into a second tab", async () => {
|
||||
@@ -126,6 +178,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["index.html"]);
|
||||
expect(screen.getByRole("tab", { name: "index.html" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("opens from the keyboard with Enter on the focused row", async () => {
|
||||
|
||||
@@ -142,4 +142,20 @@ describe("a pane's view", () => {
|
||||
probe.unmount();
|
||||
expect(all.rt.id).toBe("p1");
|
||||
});
|
||||
|
||||
it("leaves analysis for an external files request and does not replay it after a takeover", async () => {
|
||||
const onManualBrowser = vi.fn();
|
||||
const { send, view, all } = mount("p1", { onManualBrowser });
|
||||
send({ kind: "turn_started" });
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /运行分析/ }));
|
||||
view.rerender(<Pane {...all} filesPulse={1} />);
|
||||
expect(selected()).toBe("flow");
|
||||
expect(onManualBrowser).toHaveBeenLastCalledWith(true);
|
||||
expect(document.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
onManualBrowser.mockClear();
|
||||
view.rerender(<Pane key="p1:1" {...all} filesPulse={1} />);
|
||||
expect(onManualBrowser).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export interface PaneProps {
|
||||
// pane keeps reporting the posture it had when it opened.
|
||||
pulse: number;
|
||||
findPulse: number;
|
||||
filesPulse?: number;
|
||||
onSettings: (section?: string) => void;
|
||||
// Open a session this pane made, beside it: a branch is its own conversation.
|
||||
onOpenSession?: (path: string) => void;
|
||||
|
||||
@@ -34,6 +34,7 @@ function draw(collapsed = false) {
|
||||
return <>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
<Sidebar
|
||||
onFiles={vi.fn()}
|
||||
hub={hub} collapsed={collapsed} navShown={false} paging={NO_PAGING} tree={[]} treeRead runtimes={[]} runs={{}} active="remote-pane"
|
||||
activeWorkspace={undefined} liveIds={() => []} pinned={new Set()} onPin={() => {}}
|
||||
remotes={null} remoteTrees={{}} reloadRemotes={async () => {}} reloadRemoteTrees={async () => {}}
|
||||
|
||||
@@ -8,15 +8,16 @@ interface Opening {
|
||||
manual: boolean;
|
||||
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
|
||||
browserAsk: number;
|
||||
filesAsk: number;
|
||||
port: AgentPort;
|
||||
agentNow: MutableRefObject<string>;
|
||||
dropBlankStart: () => void;
|
||||
pick: (key: string) => void;
|
||||
follow: (key: string) => void;
|
||||
manualSelection: string;
|
||||
current: () => () => boolean;
|
||||
setFailed: Dispatch<SetStateAction<string>>;
|
||||
setBrowsers: Dispatch<SetStateAction<string[]>>;
|
||||
manualSelection: string;
|
||||
}
|
||||
|
||||
/** What the workbench column does when it is opened. It follows the agent's page,
|
||||
@@ -24,19 +25,24 @@ interface Opening {
|
||||
* its launcher until a browser is asked for. The title bar's Browser button
|
||||
* asks for one itself: each press is one ask, and a page already on its way is
|
||||
* shared rather than requested twice. */
|
||||
export function useColumnOpening({ manual, browserAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection }: Opening) {
|
||||
export function useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, manualSelection, current, setFailed, setBrowsers }: Opening) {
|
||||
const filesRequested = useRef(filesAsk);
|
||||
useEffect(() => {
|
||||
if (!manual) return;
|
||||
const files = filesRequested.current !== filesAsk;
|
||||
filesRequested.current = filesAsk;
|
||||
if (!manual || files) return;
|
||||
if (agentNow.current) {
|
||||
dropBlankStart();
|
||||
follow(`browser:${agentNow.current}`);
|
||||
return;
|
||||
}
|
||||
const valid = current();
|
||||
let alive = true;
|
||||
pageInFlight(port)
|
||||
?.then((tab) => { if (valid()) follow(`browser:${tab.target}`); })
|
||||
.catch((e) => { if (valid()) setFailed(reason(e)); });
|
||||
}, [manual, agentNow, dropBlankStart, follow, current, port, setFailed]);
|
||||
?.then((tab) => { if (alive && valid()) follow(`browser:${tab.target}`); })
|
||||
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
|
||||
return () => { alive = false; };
|
||||
}, [manual, filesAsk, agentNow, dropBlankStart, follow, port, setFailed, current]);
|
||||
|
||||
const asked = useRef(browserAsk);
|
||||
useEffect(() => {
|
||||
@@ -52,12 +58,13 @@ export function useColumnOpening({ manual, browserAsk, port, agentNow, dropBlank
|
||||
// by any site sending X-Frame-Options or frame-ancestors, as many do.
|
||||
if (host().drawsBrowserViews()) {
|
||||
const valid = current();
|
||||
let alive = true;
|
||||
pageForEmptyColumn(port)
|
||||
.then((tab) => { if (valid()) pick(`browser:${tab.target}`); })
|
||||
.catch((e) => { if (valid()) setFailed(reason(e)); });
|
||||
return;
|
||||
.then((tab) => { if (alive && valid()) pick(`browser:${tab.target}`); })
|
||||
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
|
||||
return () => { alive = false; };
|
||||
}
|
||||
setBrowsers((open) => (open.length ? open : ["b0"]));
|
||||
pick(manualSelection);
|
||||
}, [browserAsk, manual, agentNow, dropBlankStart, pick, current, port, setFailed, setBrowsers, manualSelection]);
|
||||
}, [browserAsk, filesAsk, manual, agentNow, dropBlankStart, pick, port, setFailed, setBrowsers, manualSelection, current]);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import './testkit';
|
||||
import { WorkbenchPanel } from './WorkbenchPanel';
|
||||
import { MockPort } from '../port/mock';
|
||||
import { host } from '../port/host';
|
||||
import { openPage, unseenPageOpenings } from './browser_open';
|
||||
const page = { id: 'b1', target: 't1', url: 'https://example.com', title: 'Existing page', active: true };
|
||||
const props = { scheme: 'dark' as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
|
||||
const filesShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
it('keeps Files visible when late browser tab list announces older followed link', async () => {
|
||||
const port = new MockPort();
|
||||
let resolve!: (v: typeof page) => void;
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
|
||||
const pending = openPage(port, page.url);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
it.each([['workbench.launch-browser'], ['workbench.new', 'workbench.launch-browser']])('keeps Files visible when the browser opened by %j finishes', async (...actions) => {
|
||||
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
let resolve!: (v: typeof page) => void;
|
||||
const pending = new Promise<typeof page>(done => { resolve = done; });
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(pending);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
for (const action of actions) fireEvent.click(document.querySelector(`[data-action="${action}"]`)!);
|
||||
expect(port.browserOpen).toHaveBeenCalledOnce();
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
import { useState } from 'react';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { Pane } from './Pane';
|
||||
import type { WireEvent } from '../port/wire';
|
||||
const paneProps = { title: 'w', active: true, visible: true, sideHost: null, side: false, onFocus() {}, onReport() {}, onSessionChanged() {}, pulse: 0, findPulse: 0, onSettings() {}, needsProject: false, onOpenProject() {}, onKeepHere() {}, theme: 'dark' as const, dockW: 560, dockMax: 880, onDockW() {} };
|
||||
it.each(['before', 'after'])('keeps Files visible when Pane receives browser_tabs_changed %s the response', async (order) => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
const port = new MockPort();
|
||||
let emit!: (event: WireEvent) => void;
|
||||
const subscribe = port.subscribe.bind(port);
|
||||
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
|
||||
const readTabs = vi.spyOn(port, 'browserTabs').mockResolvedValue([]);
|
||||
let resolve!: (v: typeof page) => void;
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
|
||||
const pending = openPage(port, page.url);
|
||||
function Harness({filesPulse}: {filesPulse: number}) {
|
||||
const [manual, setManual] = useState(true);
|
||||
return <Pane {...paneProps} rt={{id: 'event-pane', base: '', root: '/w', name: 'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
|
||||
}
|
||||
const { rerender } = render(<Harness filesPulse={0} />);
|
||||
await waitFor(() => expect(readTabs).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {});
|
||||
rerender(<Harness filesPulse={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
if (order === 'after') await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
readTabs.mockResolvedValue([page]);
|
||||
await act(async () => { emit({ kind: 'browser_tabs_changed' } as WireEvent); });
|
||||
expect(readTabs).toHaveBeenCalledTimes(2);
|
||||
if (order === 'before') await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])('ignores superseded user opens while following a new agent page (same batch=%s)', async (together) => {
|
||||
const port = new MockPort();
|
||||
const resolve: ((tab: typeof page) => void)[] = [];
|
||||
vi.spyOn(port, 'browserOpen').mockImplementation(() => new Promise((done) => { resolve.push(done); }));
|
||||
const first = openPage(port, page.url);
|
||||
const second = openPage(port, 'https://example.org');
|
||||
const later = { ...page, id: 'b2', target: 't2', url: 'https://example.org' };
|
||||
const agent = { ...page, id: 'b3', target: 't3', url: 'https://example.net', title: 'New agent page' };
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
await act(async () => { resolve[0](page); resolve[1](later); await Promise.all([first, second]); });
|
||||
if (!together) {
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page, later]} manual revealChanges={1} />);
|
||||
await act(async () => {});
|
||||
expect(filesShown()).toBe(true);
|
||||
}
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, page, later]} manual revealChanges={1} />);
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
expect(document.querySelector('[data-target="browser:t3"]')?.closest('[role="tab"]')?.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it.each(['before', 'after', 'agent-before-response', 'canceled-final-read', 'stale-blank', 'stale-intermediate', 'canceled-before-response'])('reconciles an early blank tab and final navigation (order=%s)', async (order) => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
const port = new MockPort();
|
||||
let emit!: (event: WireEvent) => void, resolve!: (tab: typeof page) => void;
|
||||
const subscribe = port.subscribe.bind(port);
|
||||
vi.spyOn(port, 'subscribe').mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
|
||||
const readTabs = vi.spyOn(port, 'browserTabs').mockResolvedValue([]);
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise(done => { resolve = done; }));
|
||||
const pending = openPage(port, page.url);
|
||||
function Harness({filesPulse}: {filesPulse: number}) {
|
||||
const [manual, setManual] = useState(true);
|
||||
return <Pane {...paneProps} rt={{id: 'navigation-pane', base: '', root: '/w', name: 'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual} />;
|
||||
}
|
||||
const {rerender} = render(<Harness filesPulse={0} />);
|
||||
await waitFor(() => expect(readTabs).toHaveBeenCalledOnce());
|
||||
const tabsChanged = async (tabs: typeof page[]) => {
|
||||
readTabs.mockResolvedValue(tabs);
|
||||
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
|
||||
};
|
||||
await tabsChanged([{...page, url:'about:blank'}]);
|
||||
rerender(<Harness filesPulse={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
if (order.startsWith('stale-')) {
|
||||
let readStale!: (tabs: typeof page[]) => void;
|
||||
readTabs.mockReturnValueOnce(new Promise(done => {readStale=done;}));
|
||||
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
|
||||
readTabs.mockResolvedValue([page]);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
await act(async () => { readStale([{...page,url:order === 'stale-blank' ? 'about:blank' : page.url+'/intermediate'}]); });
|
||||
expect(filesShown()).toBe(true);
|
||||
}
|
||||
if (order === 'after' || order === 'canceled-final-read') {
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
}
|
||||
if (order === 'canceled-final-read' || order === 'canceled-before-response') {
|
||||
let readFinal!: (tabs: typeof page[]) => void;
|
||||
readTabs.mockReturnValueOnce(new Promise(done => {readFinal=done;}));
|
||||
await act(async () => { emit({kind:'browser_tabs_changed'} as WireEvent); });
|
||||
await tabsChanged([{...page,url:page.url+'/newer'}]);
|
||||
await act(async () => { readFinal([page]); });
|
||||
} else await tabsChanged([page]);
|
||||
if (order === 'agent-before-response') await tabsChanged([{...page,url:page.url+'/agent-navigation'}]);
|
||||
if (order !== 'after') await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(order !== 'agent-before-response' && order !== 'canceled-final-read' && order !== 'canceled-before-response');
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
await tabsChanged([{...page, url:page.url+'/agent-navigation'}]);
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
});
|
||||
|
||||
it('does not let an older ambiguous follow outrun a newer genuine agent tab in the same response wait', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue(null);
|
||||
const port = new MockPort();
|
||||
let emit!: (event:WireEvent)=>void, complete!: (tab:typeof page)=>void;
|
||||
const subscribe=port.subscribe.bind(port);
|
||||
vi.spyOn(port,'subscribe').mockImplementation((onEvent,onGap,bootstrap)=>{emit=onEvent;return subscribe(onEvent,onGap,bootstrap);});
|
||||
const tabs=vi.spyOn(port,'browserTabs').mockResolvedValue([]);
|
||||
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise(resolve=>{complete=resolve;}));
|
||||
const pending=openPage(port,page.url);
|
||||
function Harness({filesPulse}:{filesPulse:number}) {
|
||||
const [manual,setManual]=useState(true);
|
||||
return <Pane {...paneProps} rt={{id:'newer-agent-review',base:'',root:'/w',name:'w'}} port={port} filesPulse={filesPulse} manualBrowser={manual} onManualBrowser={setManual}/>;
|
||||
}
|
||||
const {rerender}=render(<Harness filesPulse={0}/>);
|
||||
await waitFor(()=>expect(tabs).toHaveBeenCalledOnce());
|
||||
tabs.mockResolvedValue([{...page,url:'about:blank'}]);
|
||||
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
|
||||
rerender(<Harness filesPulse={1}/>);
|
||||
const later={...page,url:'https://genuine-later.example/'};
|
||||
tabs.mockResolvedValue([later]);
|
||||
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
|
||||
const newest={...page,id:'b2',target:'t2',url:'https://newest-agent.example/',title:'Newest agent'};
|
||||
tabs.mockResolvedValue([{...later,active:false},newest]);
|
||||
await act(async()=>{emit({kind:'browser_tabs_changed'} as WireEvent);});
|
||||
let verify!: (value:typeof page[])=>void;
|
||||
tabs.mockReturnValueOnce(new Promise(resolve=>{verify=resolve;}));
|
||||
await act(async()=>{complete(page);await pending;});
|
||||
const selected=()=>document.querySelector('[data-target="browser:t2"]')?.closest('[role="tab"]')?.getAttribute('aria-selected');
|
||||
expect(selected()).toBe('true');
|
||||
await act(async()=>{verify([{...later,active:false},newest]);});
|
||||
expect(selected()).toBe('true');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { openPage } from "./browser_open";
|
||||
|
||||
const page = { id: "b1", target: "t1", url: "https://example.com", title: "User page", active: false };
|
||||
const other = { ...page, id: "b2", target: "t2", title: "Agent page", active: true };
|
||||
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
|
||||
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
it.each([
|
||||
["navigation", "before"], ["navigation", "after"],
|
||||
["activation", "before"], ["activation", "after"],
|
||||
])("follows later agent %s of a superseded user tab (event %s response)", async (reuse, order) => {
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: typeof page) => void;
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const pending = openPage(port, page.url);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[other]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[other]} manual revealChanges={1} />);
|
||||
const first = { ...page, active: reuse === "navigation" };
|
||||
const agent = { ...other, active: !first.active };
|
||||
if (order === "after") await act(async () => { resolve(first); await pending; });
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, first]} manual revealChanges={1} />);
|
||||
if (order === "before") await act(async () => { resolve(first); await pending; });
|
||||
await act(async () => {});
|
||||
expect(filesShown()).toBe(true);
|
||||
const reused = { ...first, active: true, url: reuse === "navigation" ? "https://example.com/next" : first.url };
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[{ ...other, active: false }, reused]} manual revealChanges={1} />);
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
expect(document.querySelector('[data-target="browser:t1"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { BrowserTab } from "../port/port";
|
||||
import { host } from "../port/host";
|
||||
import { openPage } from "./browser_open";
|
||||
|
||||
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Existing page", active: true };
|
||||
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
|
||||
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
it("gives an explicit files request priority over the existing agent page when opening the dock", () => {
|
||||
const port = new MockPort();
|
||||
const browser = vi.spyOn(port, "browserOpen");
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[page]} manual={false} />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(browser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a newer files request visible when the previously followed link finishes opening", async () => {
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const pending = openPage(port, page.url);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Files visible when an earlier explicit browser request finishes", async () => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual={false} />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} />);
|
||||
expect(port.browserOpen).toHaveBeenCalledTimes(1);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} revealChanges={1} />);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("lets an explicit title-bar Browser request replace Files (native=%s)", async (native) => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(native);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
if (native) void openPage(port, page.url);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} browserAsk={1} />);
|
||||
expect(filesShown()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace a file picked while the title-bar browser request is pending", async () => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown browserAsk={1} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual shown browserAsk={1} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { host } from "../port/host";
|
||||
import { HttpError, type BrowserTab } from "../port/port";
|
||||
import { observePageTabs, openPage, unseenPageOpenings } from "./browser_open";
|
||||
const agent: BrowserTab = {id:'a', target:'ta', url:'https://agent.example', title:'Agent', active:true};
|
||||
const page: BrowserTab = {id:'b', target:'tb', url:'file:///w/index.html', title:'Failed preview', active:false};
|
||||
const props = {manual:false, shown:true, scheme:'dark' as const, changes:[], onSurfaces:vi.fn(), onCloseManual:vi.fn(), onExternal:vi.fn()};
|
||||
const filesShown = () => document.querySelector('.workbench-body')!.hasAttribute('data-files');
|
||||
afterEach(() => {cleanup(); vi.restoreAllMocks();});
|
||||
it.each(['event-first', 'reply-first'])('preserves newer Files when preview workspace lookup delays browser request: %s', async (order) => {
|
||||
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
|
||||
vi.spyOn(port, 'workspaceFile').mockResolvedValue({path:'index.html',content:'hello',revision:'r1'});
|
||||
let root!: (v: Awaited<ReturnType<MockPort['workspaces']>>) => void;
|
||||
const workspaces = vi.spyOn(port, 'workspaces').mockReturnValue(new Promise(done => {root=done;}));
|
||||
let fail!: (e: unknown) => void;
|
||||
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
expect(filesShown()).toBe(true);
|
||||
await act(async()=>root({current:'/w',canSwitch:true,canIsolate:true,recents:[]}));
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
if(order==='event-first') rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,page]} />);
|
||||
await act(async()=>fail(new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:page.id}})));
|
||||
if(order==='reply-first') rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,page]} />);
|
||||
await act(async()=>{});
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(screen.getByRole('tab',{name:'Failed preview'}).getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
it('retires an opening whose tab was observed then closed before the response', async()=>{
|
||||
const port=new MockPort(); let done!: (v:BrowserTab)=>void;
|
||||
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise(resolve=>{done=resolve;}));
|
||||
const {rerender}=render(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
|
||||
const pending=openPage(port,page.url);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page]} />);
|
||||
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
|
||||
await act(async()=>{done(page);await pending;});
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not start a preview after its file read finishes behind a newer Files selection', async () => {
|
||||
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
|
||||
let read!: (file: Awaited<ReturnType<MockPort['workspaceFile']>>) => void;
|
||||
const reading = vi.spyOn(port, 'workspaceFile').mockReturnValue(new Promise(done => {read=done;}));
|
||||
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
|
||||
render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
await waitFor(()=>expect(reading).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(filesShown()).toBe(true);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.tab"][data-target="file:index.html"]')!);
|
||||
await waitFor(()=>expect(open).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it('follows a genuine agent tab while an older preview is still preparing its workspace', async () => {
|
||||
vi.spyOn(host(), 'drawsBrowserViews').mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, 'workspaceFiles').mockResolvedValue({files:['index.html'],directories:[]});
|
||||
vi.spyOn(port, 'workspaceFile').mockResolvedValue({path:'index.html',content:'hello',revision:'r1'});
|
||||
let root!: (value: Awaited<ReturnType<MockPort['workspaces']>>) => void;
|
||||
const lookup = vi.spyOn(port, 'workspaces').mockReturnValue(new Promise(done => {root=done;}));
|
||||
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
await waitFor(()=>expect(lookup).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent,fresh]} />);
|
||||
await waitFor(()=>expect(screen.getByRole('tab',{name:'Fresh'}).getAttribute('aria-selected')).toBe('true'));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
await act(async()=>root({current:'/w',canSwitch:true,canIsolate:true,recents:[]}));
|
||||
});
|
||||
|
||||
it.each(['fulfilled', 'rejected'])('remembers raw observations through a claim and close until %s settlement reconciles', async (outcome) => {
|
||||
const port = new MockPort();
|
||||
let done!: (tab: BrowserTab) => void, fail!: (error: unknown) => void;
|
||||
vi.spyOn(port, 'browserOpen').mockReturnValue(new Promise((resolve, reject) => {done=resolve; fail=reject;}));
|
||||
const pending = openPage(port, page.url).catch(() => {});
|
||||
observePageTabs(port, [page], false);
|
||||
observePageTabs(port, [], false);
|
||||
await act(async () => {
|
||||
if (outcome === 'fulfilled') done(page);
|
||||
else fail(new HttpError(400, 'failed', {code:'browser.open_failed',params:{tab:page.id}}));
|
||||
await pending;
|
||||
});
|
||||
expect(unseenPageOpenings(port)).toHaveLength(1);
|
||||
observePageTabs(port, [], true);
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not reuse historical observations for a new opening with the same public ID', async () => {
|
||||
const port = new MockPort();
|
||||
let done!: (tab: BrowserTab) => void;
|
||||
vi.spyOn(port, 'browserOpen').mockImplementation(() => new Promise(resolve => {done=resolve;}));
|
||||
const first = openPage(port, page.url);
|
||||
observePageTabs(port, [page]);
|
||||
observePageTabs(port, []);
|
||||
await act(async () => {done(page); await first;});
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
const next = openPage(port, page.url);
|
||||
await act(async () => {done(page); await next;});
|
||||
expect(unseenPageOpenings(port)).toHaveLength(1);
|
||||
observePageTabs(port, [page]);
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
it.each(['resolve', 'reject'])('preserves new mounted panel Files selection after an old open %s', async(outcome)=>{
|
||||
const port=new MockPort(); let done!: (v:BrowserTab)=>void; let fail!: (e:unknown)=>void;
|
||||
vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((resolve,reject)=>{done=resolve;fail=reject;}));
|
||||
const pending=openPage(port,page.url).catch(()=>{});
|
||||
const initial=render(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent]} />);
|
||||
initial.unmount();
|
||||
const {rerender}=render(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
rerender(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[agent,page]} />);
|
||||
await act(async()=>{if(outcome==='resolve') done(page); else fail(new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:page.id}})); await pending;});
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
rerender(<WorkbenchPanel {...props} manual shown={false} port={port} tabs={[{...agent,active:false},{...page,active:true,url:'https://legitimate.example'}]} />);
|
||||
await act(async()=>{});
|
||||
expect(filesShown()).toBe(false);
|
||||
expect(screen.getByRole('tab',{name:'Failed preview'}).getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
it.each(['resolve', 'reject'])('preserves latest Files choice across concurrent mixed opens that %s independently', async(outcome)=>{
|
||||
const port=new MockPort(); const done: ((v:BrowserTab)=>void)[]=[]; const fail: ((e:unknown)=>void)[]=[];
|
||||
vi.spyOn(port,'browserOpen').mockImplementation(()=>new Promise((resolve,reject)=>{done.push(resolve);fail.push(reject);}));
|
||||
const first=openPage(port,page.url).catch(()=>{}); const second=openPage(port,'https://second.example').catch(()=>{});
|
||||
const other={...page,id:'c',target:'tc',title:'Second'}; const genuine={...page,id:'d',target:'td',title:'Genuine'};
|
||||
const {rerender}=render(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page]} />);
|
||||
await act(async()=>{done[0](page);await first;});
|
||||
expect(filesShown()).toBe(true);
|
||||
rerender(<WorkbenchPanel {...props} shown={false} port={port} tabs={[agent,page,other,genuine]} />);
|
||||
await act(async()=>{if(outcome==='resolve')done[1](other);else fail[1](new HttpError(400,'failed',{code:'browser.open_failed',params:{tab:other.id}}));await second;});
|
||||
expect(filesShown()).toBe(false);
|
||||
expect(screen.getByRole('tab',{name:'Genuine'}).getAttribute('aria-selected')).toBe('true');
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { SsePort } from "../port/sse";
|
||||
import { openPage, unseenPageOpenings } from "./browser_open";
|
||||
|
||||
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Failed page", active: false };
|
||||
const other = { ...page, id: "b2", target: "t2", title: "Agent page", active: true };
|
||||
const props = { scheme: "dark" as const, shown: false, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
|
||||
const filesShown = () => document.querySelector(".workbench-body")!.hasAttribute("data-files");
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
function failedOpen() {
|
||||
let respond!: (response: Response) => void;
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((done) => { respond = done; })));
|
||||
const port = new MockPort();
|
||||
const wire = new SsePort("", "failed-open");
|
||||
vi.spyOn(port, "browserOpen").mockImplementation((url) => wire.browserOpen(url, true));
|
||||
const pending = openPage(port, page.url).catch(() => {});
|
||||
const reject = async (reason = { code: "browser.open_failed", params: { tab: page.id, url: page.url, error: "navigation failed" } }) => {
|
||||
await act(async () => { respond(new Response(JSON.stringify(reason), { status: 400 })); await pending; });
|
||||
};
|
||||
return { port, reject };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["before", "navigation"], ["after", "navigation"],
|
||||
["before", "activation"], ["after", "activation"],
|
||||
])("keeps Files when failed tab arrives %s rejection, then follows later %s", async (order, reuse) => {
|
||||
const { port, reject } = failedOpen();
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[other]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[other]} manual revealChanges={1} />);
|
||||
const first = { ...page, active: reuse === "navigation" };
|
||||
const agent = { ...other, active: !first.active };
|
||||
if (order === "after") await reject();
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[agent, first]} manual revealChanges={1} />);
|
||||
if (order === "before") await reject();
|
||||
await act(async () => {});
|
||||
expect(filesShown()).toBe(true);
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
const reused = { ...first, active: true, url: reuse === "navigation" ? `${page.url}/next` : page.url };
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[{ ...other, active: false }, reused]} manual revealChanges={1} />);
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
expect(document.querySelector('[data-target="browser:t1"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it.each(["browser.open_failed", "unrelated.failure"])("does not claim an agent tab from %s without a valid tab identity", async (code) => {
|
||||
const { port, reject } = failedOpen();
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
await reject({ code, params: { tab: code === "browser.open_failed" ? "" : page.id, url: page.url, error: "failed" } });
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual revealChanges={1} />);
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
});
|
||||
|
||||
it.each(["before", "after"])("follows a genuine agent tab beside the failed user tab (event %s rejection)", async (order) => {
|
||||
const { port, reject } = failedOpen();
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
if (order === "after") await reject();
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[other, page]} manual revealChanges={1} />);
|
||||
if (order === "before") await reject();
|
||||
await waitFor(() => expect(filesShown()).toBe(false));
|
||||
expect(document.querySelector('[data-target="browser:t2"]')?.closest('[role="tab"]')?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
Reference in new issue
Block a user