mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
* Upgrade to Expo 57 and unify Explorer across layouts * feat(app): retain Explorer across layout and appearance changes Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces. * fix(app): keep Explorer resize handles stretched through the dock The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract. * fix(app): keep Explorer drag widths out of the shared stylesheet Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules. * fix(test): align isolated test runtimes with Expo 57 Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions. Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform. * fix(test): await retained shell layout after composer resize The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state. * fix(app): preserve browser interactions in retained workspace hosts Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host. Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state. * fix(test): share developer toast handling with Electron browser runs The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type {
|
|
CheckoutPrStatusResponse,
|
|
PullRequestTimelineResponse,
|
|
} from "@getpaseo/protocol/messages";
|
|
import {
|
|
createInMemoryUnsupportedTimelineRegistry,
|
|
extractPrRepoIdentity,
|
|
fetchPrPaneTimelinePage,
|
|
isUnsupportedTimelineError,
|
|
type PrPaneTimelineClient,
|
|
selectPrPaneState,
|
|
shouldFetchTimelineFrom,
|
|
unsupportedTimelineKey,
|
|
} from "./use-data";
|
|
|
|
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
|
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
|
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0];
|
|
|
|
const githubStatus: CheckoutPrStatus["github"] = {
|
|
mergeStateStatus: null,
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: false,
|
|
mergeCommitAllowed: false,
|
|
squashMergeAllowed: false,
|
|
rebaseMergeAllowed: false,
|
|
viewerDefaultMergeMethod: null,
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
};
|
|
|
|
function prStatus(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
|
return {
|
|
forge: "github",
|
|
number: 42,
|
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
|
title: "Wire real PR pane data",
|
|
state: "open",
|
|
baseRefName: "main",
|
|
headRefName: "feature/pr-pane",
|
|
isMerged: false,
|
|
isDraft: false,
|
|
mergeable: "UNKNOWN",
|
|
checks: [],
|
|
reviewDecision: null,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
github: githubStatus,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function timelinePayload(
|
|
overrides: Partial<PullRequestTimelinePayload> = {},
|
|
): PullRequestTimelinePayload {
|
|
return {
|
|
cwd: "/repo",
|
|
prNumber: 42,
|
|
items: [],
|
|
truncated: false,
|
|
error: null,
|
|
requestId: "timeline-1",
|
|
githubFeaturesEnabled: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface FakeTimelineClient extends PrPaneTimelineClient {
|
|
calls: PullRequestTimelineInput[];
|
|
}
|
|
|
|
function createTimelineClient(
|
|
respond: (input: PullRequestTimelineInput) => Promise<PullRequestTimelinePayload>,
|
|
): FakeTimelineClient {
|
|
const calls: PullRequestTimelineInput[] = [];
|
|
return {
|
|
calls,
|
|
pullRequestTimeline: async (input) => {
|
|
calls.push(input);
|
|
return respond(input);
|
|
},
|
|
};
|
|
}
|
|
|
|
function unsupportedTimelineError(): Error {
|
|
const error = new Error(
|
|
"Unknown request, try upgrading the daemon requestType=pull_request_timeline_request code=unknown_schema",
|
|
) as Error & { code: string; requestType: string };
|
|
error.name = "DaemonRpcError";
|
|
error.code = "unknown_schema";
|
|
error.requestType = "pull_request_timeline_request";
|
|
return error;
|
|
}
|
|
|
|
const baseSelectInput = {
|
|
status: null as CheckoutPrStatus | null,
|
|
statusPayloadError: null,
|
|
statusError: null as Error | null,
|
|
statusIsLoading: false,
|
|
statusIsFetching: false,
|
|
githubFeaturesEnabled: true,
|
|
timelineEnabled: true,
|
|
shouldFetchTimeline: true,
|
|
timelinePayload: undefined as PullRequestTimelinePayload | undefined,
|
|
timelineError: null as Error | null,
|
|
timelineIsLoading: false,
|
|
timelineIsFetching: false,
|
|
};
|
|
|
|
describe("extractPrRepoIdentity", () => {
|
|
it("reads the PR number, owner, and name from a status payload", () => {
|
|
expect(extractPrRepoIdentity(prStatus())).toEqual({
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
});
|
|
});
|
|
|
|
it("returns null fields when the status is missing", () => {
|
|
expect(extractPrRepoIdentity(null)).toEqual({
|
|
prNumber: null,
|
|
repoOwner: null,
|
|
repoName: null,
|
|
});
|
|
});
|
|
|
|
it("treats empty owner or name strings as missing", () => {
|
|
expect(extractPrRepoIdentity(prStatus({ repoOwner: "", repoName: "" }))).toMatchObject({
|
|
repoOwner: null,
|
|
repoName: null,
|
|
});
|
|
});
|
|
|
|
it("reports no PR number when the status omits it", () => {
|
|
const status = prStatus();
|
|
delete status.number;
|
|
expect(extractPrRepoIdentity(status).prNumber).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("shouldFetchTimelineFrom", () => {
|
|
const baseGate = {
|
|
hasClient: true,
|
|
isConnected: true,
|
|
timelineEnabled: true,
|
|
githubFeaturesEnabled: true,
|
|
cwd: "/repo",
|
|
identity: { prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
|
|
timelineUnsupported: false,
|
|
};
|
|
|
|
it("fetches when every gate is open", () => {
|
|
expect(shouldFetchTimelineFrom(baseGate)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "no client", overrides: { hasClient: false } },
|
|
{ name: "disconnected", overrides: { isConnected: false } },
|
|
{ name: "timeline disabled", overrides: { timelineEnabled: false } },
|
|
{ name: "github features off", overrides: { githubFeaturesEnabled: false } },
|
|
{ name: "empty cwd", overrides: { cwd: "" } },
|
|
{
|
|
name: "missing PR number",
|
|
overrides: { identity: { ...baseGate.identity, prNumber: null } },
|
|
},
|
|
{ name: "missing owner", overrides: { identity: { ...baseGate.identity, repoOwner: null } } },
|
|
{ name: "missing name", overrides: { identity: { ...baseGate.identity, repoName: null } } },
|
|
{ name: "tuple marked unsupported", overrides: { timelineUnsupported: true } },
|
|
])("skips fetching when $name", ({ overrides }) => {
|
|
expect(shouldFetchTimelineFrom({ ...baseGate, ...overrides })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("createInMemoryUnsupportedTimelineRegistry", () => {
|
|
it("remembers added keys and answers has() against them", () => {
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 });
|
|
|
|
expect(registry.has(key)).toBe(false);
|
|
registry.add(key);
|
|
expect(registry.has(key)).toBe(true);
|
|
});
|
|
|
|
it("uses serverId, cwd, and prNumber to scope each key", () => {
|
|
expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual(
|
|
unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }),
|
|
);
|
|
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual(
|
|
unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }),
|
|
);
|
|
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual(
|
|
unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fetchPrPaneTimelinePage", () => {
|
|
it("forwards the cwd, PR number, owner, and name to the daemon client", async () => {
|
|
const client = createTimelineClient(async () => timelinePayload());
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
|
|
await fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo",
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
});
|
|
|
|
expect(client.calls).toEqual([
|
|
{ cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
|
|
]);
|
|
});
|
|
|
|
it("returns the daemon's timeline payload on success", async () => {
|
|
const payload = timelinePayload({
|
|
items: [
|
|
{
|
|
id: "comment-1",
|
|
kind: "comment",
|
|
author: "octocat",
|
|
body: "Looks good",
|
|
createdAt: Date.now(),
|
|
url: "https://github.com/getpaseo/paseo/pull/42#c1",
|
|
},
|
|
],
|
|
});
|
|
const client = createTimelineClient(async () => payload);
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
|
|
const result = await fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo",
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
});
|
|
|
|
expect(result).toBe(payload);
|
|
});
|
|
|
|
it("records the tuple in the registry when the daemon rejects the request as unsupported", async () => {
|
|
const error = unsupportedTimelineError();
|
|
const client = createTimelineClient(async () => {
|
|
throw error;
|
|
});
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
|
|
await expect(
|
|
fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo",
|
|
prNumber: 99,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
}),
|
|
).rejects.toBe(error);
|
|
|
|
expect(
|
|
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("leaves the registry alone when the failure is a generic error", async () => {
|
|
const error = new Error("network down");
|
|
const client = createTimelineClient(async () => {
|
|
throw error;
|
|
});
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
|
|
await expect(
|
|
fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo",
|
|
prNumber: 99,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
}),
|
|
).rejects.toBe(error);
|
|
|
|
expect(
|
|
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => {
|
|
const client = createTimelineClient(async (input) => {
|
|
if (input.cwd === "/repo-a") {
|
|
throw unsupportedTimelineError();
|
|
}
|
|
return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber });
|
|
});
|
|
const registry = createInMemoryUnsupportedTimelineRegistry();
|
|
|
|
await expect(
|
|
fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo-a",
|
|
prNumber: 1,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
const result = await fetchPrPaneTimelinePage({
|
|
client,
|
|
registry,
|
|
serverId: "host",
|
|
cwd: "/repo-b",
|
|
prNumber: 2,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
});
|
|
|
|
expect(result.prNumber).toBe(2);
|
|
expect(
|
|
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })),
|
|
).toBe(true);
|
|
expect(
|
|
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isUnsupportedTimelineError", () => {
|
|
it("matches the daemon's unknown-schema error for pull_request_timeline_request", () => {
|
|
expect(isUnsupportedTimelineError(unsupportedTimelineError())).toBe(true);
|
|
});
|
|
|
|
it("rejects unrelated errors", () => {
|
|
expect(isUnsupportedTimelineError(new Error("network down"))).toBe(false);
|
|
expect(isUnsupportedTimelineError("not an error")).toBe(false);
|
|
});
|
|
|
|
it("rejects daemon errors for a different request type", () => {
|
|
const error = new Error("nope") as Error & { code: string; requestType: string };
|
|
error.name = "DaemonRpcError";
|
|
error.code = "unknown_schema";
|
|
error.requestType = "something_else_request";
|
|
expect(isUnsupportedTimelineError(error)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selectPrPaneState", () => {
|
|
it("returns null data and no PR number when the status payload has no PR", () => {
|
|
const state = selectPrPaneState({ ...baseSelectInput, status: null });
|
|
|
|
expect(state.data).toBeNull();
|
|
expect(state.prNumber).toBeNull();
|
|
});
|
|
|
|
it("composes data from status and timeline when both are present", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
timelinePayload: timelinePayload(),
|
|
});
|
|
|
|
expect(state.data?.number).toBe(42);
|
|
expect(state.data?.title).toBe("Wire real PR pane data");
|
|
expect(state.data?.activity).toEqual([]);
|
|
});
|
|
|
|
it("passes GitLab native facts through the selected pane data", () => {
|
|
const gitlabFacts = {
|
|
forge: "gitlab" as const,
|
|
detailedMergeStatus: "mergeable",
|
|
hasConflicts: false,
|
|
blockingDiscussionsResolved: true,
|
|
approvalsRequired: 2,
|
|
approvalsGiven: 1,
|
|
pipelineStatus: null,
|
|
pipelineId: null,
|
|
pipelineUrl: null,
|
|
mergeWhenPipelineSucceeds: false,
|
|
};
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
forge: "gitlab",
|
|
status: prStatus({
|
|
forge: "gitlab",
|
|
github: undefined,
|
|
forgeSpecific: gitlabFacts,
|
|
}),
|
|
timelinePayload: timelinePayload(),
|
|
});
|
|
|
|
expect(state.data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
|
|
expect(state.data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
|
|
});
|
|
|
|
it("keeps cached content when retained activity disables timeline demand", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
timelineEnabled: false,
|
|
timelinePayload: timelinePayload(),
|
|
});
|
|
|
|
expect(state.data).not.toBeNull();
|
|
expect(state.prNumber).toBe(42);
|
|
});
|
|
|
|
it("drops timeline activity that belongs to a different PR number", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
timelinePayload: timelinePayload({
|
|
prNumber: 41,
|
|
items: [
|
|
{
|
|
id: "comment-1",
|
|
kind: "comment",
|
|
author: "octocat",
|
|
body: "Belongs to another PR",
|
|
createdAt: Date.now(),
|
|
url: "https://github.com/getpaseo/paseo/pull/41#c1",
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
expect(state.data?.activity).toEqual([]);
|
|
});
|
|
|
|
it("reports loading while the status query is loading", () => {
|
|
expect(selectPrPaneState({ ...baseSelectInput, statusIsLoading: true }).isLoading).toBe(true);
|
|
});
|
|
|
|
it("reports loading while the timeline is still pending its first response", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
shouldFetchTimeline: true,
|
|
timelineIsLoading: true,
|
|
});
|
|
expect(state.isLoading).toBe(true);
|
|
});
|
|
|
|
it("does not report loading on the timeline when no fetch was scheduled", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
shouldFetchTimeline: false,
|
|
timelineIsLoading: true,
|
|
});
|
|
expect(state.isLoading).toBe(false);
|
|
});
|
|
|
|
it("reports activityLoading while the timeline is pending its first response", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
shouldFetchTimeline: true,
|
|
timelineIsLoading: true,
|
|
});
|
|
expect(state.activityLoading).toBe(true);
|
|
});
|
|
|
|
it("does not report activityLoading when no timeline fetch was scheduled", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
shouldFetchTimeline: false,
|
|
timelineIsLoading: true,
|
|
});
|
|
expect(state.activityLoading).toBe(false);
|
|
});
|
|
|
|
it("does not report activityLoading once the timeline payload resolves", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
shouldFetchTimeline: true,
|
|
timelineIsLoading: false,
|
|
timelinePayload: timelinePayload(),
|
|
});
|
|
expect(state.activityLoading).toBe(false);
|
|
});
|
|
|
|
it("reports refreshing during background revalidation of either query", () => {
|
|
expect(
|
|
selectPrPaneState({
|
|
...baseSelectInput,
|
|
statusIsFetching: true,
|
|
}).isRefreshing,
|
|
).toBe(true);
|
|
expect(
|
|
selectPrPaneState({
|
|
...baseSelectInput,
|
|
timelineIsFetching: true,
|
|
}).isRefreshing,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("surfaces status payload errors with the daemon's message", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
statusPayloadError: { code: "UNKNOWN", message: "bad daemon payload" },
|
|
});
|
|
|
|
expect(state.error?.message).toContain("bad daemon payload");
|
|
});
|
|
|
|
it("falls back to a generic message when the status payload error is empty", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
statusPayloadError: { code: "UNKNOWN", message: "" },
|
|
});
|
|
|
|
expect(state.error?.message).toBe("Unable to load pull request status");
|
|
});
|
|
|
|
it("prefers the status thrown error over the timeline error", () => {
|
|
const statusError = new Error("status broke");
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
statusError,
|
|
timelineError: new Error("timeline broke"),
|
|
});
|
|
|
|
expect(state.error).toBe(statusError);
|
|
});
|
|
|
|
it("suppresses unsupported timeline errors", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
timelineError: unsupportedTimelineError(),
|
|
});
|
|
|
|
expect(state.error).toBeNull();
|
|
});
|
|
|
|
it("surfaces timeline payload errors when nothing earlier failed", () => {
|
|
const state = selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
timelinePayload: timelinePayload({ error: { kind: "unknown", message: "rate limited" } }),
|
|
});
|
|
|
|
expect(state.error?.message).toBe("rate limited");
|
|
});
|
|
|
|
it("passes the daemon's githubFeaturesEnabled flag through", () => {
|
|
expect(
|
|
selectPrPaneState({
|
|
...baseSelectInput,
|
|
status: prStatus(),
|
|
githubFeaturesEnabled: false,
|
|
}).githubFeaturesEnabled,
|
|
).toBe(false);
|
|
});
|
|
});
|