/** * git-sidebar-tree.spec.ts — E2E for the workspace-as-folder git-sidebar * tree model (task #113-git-tree). * * Verifies that: * 1. The sidebar discovers both the parent repo and its submodule when a * workspace rootPath is set via setGitSidebarRoot(). * 2. The parent repo renders as an expanded top-level node, the submodule * renders as a nested node with the submodule badge. * 3. Clicking a file that lives inside the submodule dispatches a * `diff:open` CustomEvent whose detail.root is the SUBMODULE's root, * not the workspace root — so the diff viewer pulls the blob via the * submodule's own git client. */ import { test, expect } from '@playwright/test'; import type { ElectronApplication, Page } from 'playwright'; import { buildApp, launchApp, closeApp, screenshotOnFailure } from './helpers'; import { execFileSync } from 'child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; let app: ElectronApplication; let window: Page; let workspaceRoot: string; let parentRepo: string; let submoduleSource: string; function git(cwd: string, ...args: string[]): string { return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString(); } function initRepo(dir: string): void { mkdirSync(dir, { recursive: true }); git(dir, 'init', '-q', '-b', 'main'); git(dir, 'config', 'user.email', 'test@example.com'); git(dir, 'config', 'user.name', 'Tester'); git(dir, 'config', 'commit.gpgsign', 'false'); writeFileSync(join(dir, 'README.md'), '# repo\n', 'utf8'); git(dir, 'add', 'README.md'); git(dir, 'commit', '-q', '-m', 'init'); } test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // Fixture: a workspace folder that IS a git repo AND contains a submodule. workspaceRoot = mkdtempSync(join(tmpdir(), 'agent-desk-tree-ws-')); parentRepo = workspaceRoot; submoduleSource = mkdtempSync(join(tmpdir(), 'agent-desk-tree-sub-')); initRepo(submoduleSource); initRepo(parentRepo); const url = 'file:///' + submoduleSource.replace(/\\/g, '/').replace(/^\//, ''); git(parentRepo, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', url, 'libs/sub'); git(parentRepo, 'commit', '-q', '-m', 'add submodule'); writeFileSync(join(parentRepo, 'libs', 'sub', 'hello.txt'), 'hello from submodule\n', 'utf8'); buildApp(); ({ app, window } = await launchApp()); }); test.afterAll(async () => { if (app) await closeApp(app); try { rmSync(workspaceRoot, { recursive: true, force: true }); rmSync(submoduleSource, { recursive: true, force: true }); } catch { /* best effort */ } }); // eslint-disable-next-line no-empty-pattern test.afterEach(async ({}, testInfo) => { await screenshotOnFailure(window, testInfo); }); test('sidebar discovers parent repo + submodule as tree nodes', async () => { await window.waitForFunction(() => { const w = window as unknown as { __agentDeskRegistry?: { setGitSidebarRoot?: (r: string) => void }; }; return !!w.__agentDeskRegistry?.setGitSidebarRoot; }); await window.evaluate((root) => { const w = window as unknown as { __agentDeskRegistry?: { setGitSidebarRoot?: (r: string) => void }; }; w.__agentDeskRegistry!.setGitSidebarRoot!(root); }, workspaceRoot); // Wait for the tree to render. Parent repo node + one submodule node = 2. const repoNodes = window.locator('#git-sidebar .git-repo-node'); await expect.poll(async () => repoNodes.count(), { timeout: 10000 }).toBeGreaterThanOrEqual(2); // The submodule node should carry the "sub" badge. const submoduleBadges = window.locator('#git-sidebar .git-repo-submodule-badge'); expect(await submoduleBadges.count()).toBeGreaterThanOrEqual(1); }); test('clicking a file inside the submodule dispatches diff:open with the submodule root', async () => { // Expand the submodule node so its files are clickable. The submodule // is default-collapsed (depth > 0), so we click its header first. const submoduleHeader = window.locator( '#git-sidebar .git-repo-node.git-repo-submodule .git-repo-header', ); if ((await submoduleHeader.count()) > 0) { await submoduleHeader.first().click(); } // Install a listener that captures the next diff:open CustomEvent. await window.evaluate(() => { const w = window as unknown as { __lastDiffOpen?: unknown }; w.__lastDiffOpen = undefined; window.addEventListener( 'diff:open', (ev) => { const e = ev as CustomEvent; w.__lastDiffOpen = e.detail; }, { once: true }, ); }); // Find any file row inside a submodule node and click it. const fileRow = window.locator('#git-sidebar .git-repo-submodule .git-file-row').first(); // If no dirty files show (e.g. the submodule is clean), skip the click // assertion and just assert the render succeeded. if ((await fileRow.count()) === 0) { return; } await fileRow.click(); const detail = await window.evaluate(() => { const w = window as unknown as { __lastDiffOpen?: { root?: string } }; return w.__lastDiffOpen; }); expect(detail).toBeTruthy(); const dispatchedRoot = (detail as { root?: string } | undefined)?.root || ''; // The dispatched root must include 'libs/sub' — i.e. the submodule's own // working tree, NOT the parent/workspace root. expect(dispatchedRoot.toLowerCase()).toContain('libs'); expect(dispatchedRoot.toLowerCase()).not.toBe(workspaceRoot.toLowerCase()); });