mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
* feat(protocol): define streamed diff revisions and resolve tag bases * feat(server): stream shared diff revisions with paced file bodies * feat(client): observe streamed diffs with atomic revision updates * feat(app): show the Changes tree while diff bodies stream * fix(server): retain the legacy structured diff payload budget * fix(diff): preserve revision ordering and readable files * fix(diff): select renamed patches and read dangling symlinks * fix(diff): preserve nested checkouts and ambiguous patch paths Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks. * fix(diff): preserve BOM text and complete file replacements Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey. * fix(diff): highlight symlinks from their stored target text Working tree source context must match the symlink blob emitted by Git rather than the contents reached by following the link. Otherwise highlight runs do not cover the patch line and encoding fails the revision. * fix(diff): tolerate disappearing highlight sources Source context is optional after Git has produced the patch. Missing worktree link metadata must preserve the existing hunk-based highlighting fallback instead of failing the revision. * fix(diff): batch cold reads and refresh logical comparison inputs Acquire bounded pages of tracked patches lazily after Begin while retaining per-file highlighting and awaited frame delivery. Keep legacy preferred-base selection live across revisions and include submodule Git state in worktree cache identity. * fix(diff): distinguish gitlink directories from replacements A raw old gitlink mode also occurs when a submodule becomes a regular file. Query nested Git state only for a current directory, preserving normal file identity for replacements. * fix(diff): invalidate timestamp-preserving file replacements Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads. * fix(diff): align highlight tokens with normalized patch lines Git can normalize CRLF worktrees while highlighting reads their original line endings. Match tokens to the emitted patch text and trim only a normalization CR before encoding runs; mismatched source tokens are not attached. * fix(diff): refresh equivalent worktree paths across platforms Explicit refreshes must match filesystem-equivalent roots, including Windows path aliases. Use the existing realpath-aware matcher, budget real Git completion waits consistently, and assert optional status metadata in the filesystem commit-file journey. * fix(diff): validate Git inputs and resolve refresh roots Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command. * fix(diff): keep non-Git workspace refresh successful Diff invalidation has no target repository for an ordinary folder. Treat that expected absence as a no-op while preserving other root-resolution errors. * fix(diff): observe scoped Git interpretation settings Classify each metadata root with its worktree and common scopes, retaining both when the directories coincide. Shared attributes affect all checkouts, while config.worktree belongs to the owning checkout. Real subscriptions verify automatic binary and hunk-context refreshes in main and linked worktrees. * fix(diff): observe interpretation inputs for pinned comparisons Immutable commit objects still depend on mutable Git configuration and attributes. Declare those metadata inputs independently of ref, index, and worktree mutability, while keeping unrelated endpoint events out of pinned comparisons. * fix(diff): observe interpretation independently of comparison endpoints Commit comparisons still read worktree attributes. Watch those inputs independently, checking effective Git interpretation before invalidating fixed contents. Retain paths hidden by whitespace filtering so attribute changes can expose them. Forward binary frames through the branch-loading test interceptor.
198 lines
7.5 KiB
TypeScript
198 lines
7.5 KiB
TypeScript
import { writeFile, unlink } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import {
|
|
expectNoBranchSwitcherInWorkspaceHeader,
|
|
expectWorkspaceBranch,
|
|
openChangesPanel,
|
|
switchBranchFromChangesPanel,
|
|
} from "../support/helpers/branch-switcher";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { readWorktreeBranchInfo } from "../support/helpers/workspace";
|
|
import {
|
|
switchWorkspaceViaSidebar,
|
|
waitForSidebarHydration,
|
|
} from "../support/helpers/workspace-ui";
|
|
|
|
// Hold the real daemon response until the test has observed the pending UI.
|
|
async function holdBranchSuggestions(page: Page) {
|
|
let requestCount = 0;
|
|
let release!: () => void;
|
|
let gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
|
const server = browser.connectToServer();
|
|
browser.onMessage((message) => {
|
|
if (typeof message === "string") {
|
|
const envelope = JSON.parse(message);
|
|
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
|
requestCount++;
|
|
}
|
|
server.send(message);
|
|
});
|
|
server.onMessage(async (message) => {
|
|
if (typeof message === "string") {
|
|
const envelope = JSON.parse(message);
|
|
if (
|
|
envelope.type === "session" &&
|
|
envelope.message?.type === "branch_suggestions_response"
|
|
) {
|
|
await gate;
|
|
}
|
|
}
|
|
browser.send(message);
|
|
});
|
|
});
|
|
return {
|
|
release: () => release(),
|
|
holdNextResponse: () => {
|
|
gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
},
|
|
requestCount: () => requestCount,
|
|
};
|
|
}
|
|
|
|
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
|
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
|
const picker = page.getByTestId("combobox-desktop-container");
|
|
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
|
release();
|
|
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(picker).not.toBeVisible();
|
|
}
|
|
|
|
async function expectFailedRefreshRecovery(
|
|
page: Page,
|
|
repoPath: string,
|
|
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
|
) {
|
|
// Expire the actual query freshness window before reopening the cached picker.
|
|
const started = Date.now();
|
|
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
|
const picker = page.getByTestId("combobox-desktop-container");
|
|
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
|
await writeFile(packedRefs, "invalid packed refs\n");
|
|
try {
|
|
await page
|
|
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
|
.click();
|
|
await expect(picker.getByRole("alert")).toHaveText(
|
|
"Could not load branches. Reopen the picker to try again.",
|
|
);
|
|
} finally {
|
|
await unlink(packedRefs);
|
|
}
|
|
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
|
responses.holdNextResponse();
|
|
const beforeRetry = responses.requestCount();
|
|
await page.keyboard.press("Escape");
|
|
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
|
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
|
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
|
await expect(picker).toBeVisible();
|
|
responses.release();
|
|
await expect(picker.getByRole("alert")).toHaveCount(0);
|
|
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expectWorkspaceBranch(page, "dev");
|
|
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
|
}
|
|
|
|
async function renameWorkspaceViaSidebar(
|
|
page: Page,
|
|
input: { workspaceId: string; title: string },
|
|
): Promise<void> {
|
|
const serverId = getServerId();
|
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`);
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.hover();
|
|
|
|
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`);
|
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
|
await kebab.click();
|
|
|
|
const renameItem = page.getByTestId(
|
|
`sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`,
|
|
);
|
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
|
await renameItem.click();
|
|
|
|
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
|
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
|
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
|
await renameInput.fill(input.title);
|
|
await page.getByTestId(`${modalPrefix}-submit`).click();
|
|
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
|
}
|
|
|
|
test.describe("Branch switcher", () => {
|
|
// The first test after a spec-file switch can fail while the shared daemon
|
|
// releases stale sessions from the previous spec; one retry stabilizes it.
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const serverId = getServerId();
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "branch-coherence-",
|
|
repo: { branches: ["main", "dev"] },
|
|
});
|
|
|
|
try {
|
|
expect(workspace.workspaceName).toBe("main");
|
|
|
|
const releaseBranches = await holdBranchSuggestions(page);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
|
|
|
const customTitle = "Payments Refactor";
|
|
await renameWorkspaceViaSidebar(page, {
|
|
workspaceId: workspace.workspaceId,
|
|
title: customTitle,
|
|
});
|
|
|
|
// The header shows the custom title verbatim (a plain static title), never a
|
|
// branch name, and the branch switcher does not live in the header.
|
|
const headerTitle = page
|
|
.getByTestId("workspace-header-title")
|
|
.filter({ visible: true })
|
|
.first();
|
|
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
|
await expectNoBranchSwitcherInWorkspaceHeader(page);
|
|
|
|
// The diff panel's switcher tracks the real branch ("main"), not the title,
|
|
// and switching it checks out the real branch on disk.
|
|
await openChangesPanel(page);
|
|
await expectWorkspaceBranch(page, "main");
|
|
await expectInitialBranchLoading(page, releaseBranches.release);
|
|
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
|
await expectWorkspaceBranch(page, "dev");
|
|
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
|
|
|
// The custom title is unaffected by the branch switch.
|
|
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe("dev");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|