fix: guard against missing referencePanel in dockview focusOrAddPanel (#538)

This commit is contained in:
Carlos Florêncio
2026-04-03 15:02:40 +01:00
committed by GitHub
parent e72cd46552
commit 7e97e07c2d
2 changed files with 161 additions and 17 deletions
@@ -0,0 +1,123 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
/**
* Resolve the code-server install directory. Checks the default kandev install
* path at ~/.kandev/tools/code-server/ (real HOME, not e2e temp HOME).
*/
function findCodeServerInstall(): string | null {
const home = os.homedir();
const installDir = path.join(home, ".kandev", "tools", "code-server");
if (!fs.existsSync(installDir)) return null;
const entries = fs.readdirSync(installDir);
for (const entry of entries) {
const binPath = path.join(installDir, entry, "bin", "code-server");
if (fs.existsSync(binPath)) return installDir;
}
return null;
}
/**
* Seed a task + session via the API and navigate directly to the session page.
* Waits for the mock agent to complete its turn (idle input visible).
*/
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return { session, sessionId: task.session_id };
}
test.describe("VS Code open panel", () => {
test.describe.configure({ retries: 1 });
const codeServerDir = findCodeServerInstall();
test.beforeEach(async ({ backend }) => {
if (!codeServerDir) {
test.skip(true, "code-server not installed — skipping VS Code e2e tests");
return;
}
// Symlink the real code-server install into the e2e backend's HOME so
// ResolveBinary finds the pre-installed binary without re-downloading.
const targetDir = path.join(backend.tmpDir, ".kandev", "tools", "code-server");
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
fs.symlinkSync(codeServerDir, targetDir);
}
});
/**
* Regression test for: "dockview: referencePanel 'chat' does not exist"
*
* When opening the embedded VS Code panel via the top-bar editor button,
* the dockview layout must not throw if the 'chat' reference panel is absent.
*/
test("opens VS Code panel via Open Editor without referencePanel error", async ({
testPage,
apiClient,
seedData,
}) => {
// Track page errors to catch the dockview referencePanel exception
const pageErrors: Error[] = [];
testPage.on("pageerror", (err) => pageErrors.push(err));
const { session } = await seedTaskWithSession(
testPage,
apiClient,
seedData,
"VSCode Open Panel Test",
);
// Click the main "Open editor" button (the IconCode button in EditorsMenu).
// The tooltip reads "Open editor", so target the button inside that wrapper.
const editorBtn = testPage.locator("button:has(.tabler-icon-code)").first();
await expect(editorBtn).toBeEnabled({ timeout: 10_000 });
await editorBtn.click();
// Assert: VS Code tab appears in dockview
await expect(session.vscodeTab()).toBeVisible({ timeout: 10_000 });
// Assert: "Starting VS Code Server" progress text is visible while booting
await expect(testPage.getByText("Starting VS Code Server")).toBeVisible({ timeout: 30_000 });
// Assert: code-server iframe loads
await expect(session.vscodeIframe()).toBeVisible({ timeout: 90_000 });
// Assert: no dockview referencePanel errors were thrown
const referencePanelErrors = pageErrors.filter(
(e) => e.message.includes("referencePanel") && e.message.includes("does not exist"),
);
expect(referencePanelErrors).toHaveLength(0);
});
});
+38 -17
View File
@@ -41,6 +41,23 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds {
return resolveGroupIds(api);
}
/**
* Resolve a fallback group position when the intended reference is stale.
* Tries the well-known center group, then any non-sidebar group, then the
* first group. Returns undefined if no groups exist (drops the position).
*/
function fallbackGroupPosition(api: DockviewApi): { referenceGroup: string } | undefined {
const centerGroup = api.groups.find((g) => g.id === CENTER_GROUP);
if (centerGroup) return { referenceGroup: centerGroup.id };
const nonSidebarGroup = api.groups.find((g) => g.id !== SIDEBAR_GROUP);
if (nonSidebarGroup) return { referenceGroup: nonSidebarGroup.id };
if (api.groups.length > 0) return { referenceGroup: api.groups[0].id };
return undefined;
}
export function focusOrAddPanel(
api: DockviewApi,
options: AddPanelOptions & { id: string },
@@ -51,27 +68,31 @@ export function focusOrAddPanel(
if (!quiet) existing.api.setActive();
return;
}
// Guard: if the referenced group no longer exists (stale ID after layout
// transition), try the well-known center group, then the first non-sidebar
// group. Avoid falling back to the active panel's group because the user
// may have just clicked in the sidebar, which would place the new panel there.
// Guard: if the referenced group or panel no longer exists (stale ID after
// layout transition), fall back to a known group. Avoid the active panel's
// group because the user may have just clicked in the sidebar.
const pos = options.position;
if (pos && "referenceGroup" in pos) {
const groupExists = api.groups.some((g) => g.id === pos.referenceGroup);
if (!groupExists) {
const centerGroup = api.groups.find((g) => g.id === CENTER_GROUP);
const nonSidebarGroup = api.groups.find((g) => g.id !== SIDEBAR_GROUP);
if (centerGroup) {
options = { ...options, position: { ...pos, referenceGroup: centerGroup.id } };
} else if (nonSidebarGroup) {
options = { ...options, position: { ...pos, referenceGroup: nonSidebarGroup.id } };
} else if (api.groups.length > 0) {
options = { ...options, position: { ...pos, referenceGroup: api.groups[0].id } };
} else {
options = Object.fromEntries(
Object.entries(options).filter(([k]) => k !== "position"),
) as typeof options;
}
const fallback = fallbackGroupPosition(api);
options = fallback
? { ...options, position: { ...pos, ...fallback } }
: (Object.fromEntries(
Object.entries(options).filter(([k]) => k !== "position"),
) as typeof options);
}
}
if (pos && "referencePanel" in pos) {
const refPanel = api.getPanel(pos.referencePanel as string);
if (!refPanel) {
const fallback = fallbackGroupPosition(api);
options = fallback
? { ...options, position: fallback }
: (Object.fromEntries(
Object.entries(options).filter(([k]) => k !== "position"),
) as typeof options);
}
}