Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { withProjectOwnership } from "./project-ownership";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
export interface SeedWorkspaceDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
interface SeedProjectDescriptor {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
||||
* prefer those wrappers over reaching for this client directly.
|
||||
*/
|
||||
export interface SeedDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectKey?: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
| { kind: "directory"; path: string; projectId?: string }
|
||||
| {
|
||||
kind: "worktree";
|
||||
cwd?: string;
|
||||
projectId?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
refName?: string;
|
||||
baseBranch?: string;
|
||||
githubPrNumber?: number;
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
title?: string;
|
||||
}): Promise<{
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
/**
|
||||
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
||||
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
||||
* working-tree write authoritative before asserting on it in the UI, instead
|
||||
* of racing the filesystem watcher's debounce.
|
||||
*/
|
||||
checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
requestId?: string,
|
||||
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
listTerminals(
|
||||
cwd?: string,
|
||||
requestId?: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<{
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
activity?: TerminalActivity | null;
|
||||
}>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
agent: {
|
||||
id: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
model: string | null;
|
||||
currentModeId: string | null;
|
||||
status: string;
|
||||
title?: string | null;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
providers: string[];
|
||||
limit: number;
|
||||
}): Promise<{
|
||||
entries: Array<{
|
||||
providerId: string;
|
||||
providerHandleId: string;
|
||||
cwd: string;
|
||||
firstPromptPreview?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
setAgentMode(agentId: string, modeId: string): Promise<unknown>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(options?: {
|
||||
port?: number;
|
||||
/** Use only with a private host whose teardown removes its entire PASEO_HOME. */
|
||||
projectOwnership?: "client" | "host";
|
||||
}): Promise<SeedDaemonClient> {
|
||||
const client = await connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
port: options?.port,
|
||||
});
|
||||
return options?.projectOwnership === "host" ? client : withProjectOwnership(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp project opened as a workspace, with a seed client connected to drive
|
||||
* it out of band. `cleanup` closes the client and removes the project. This is
|
||||
* the canonical bootstrap for specs that need a real workspace plus daemon
|
||||
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
||||
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
||||
*/
|
||||
export interface SeededWorkspace {
|
||||
client: SeedDaemonClient;
|
||||
repoPath: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
/** Host-local identity used by daemon project operations. */
|
||||
projectId: string;
|
||||
/** Opaque cross-host key used by grouped project UI. */
|
||||
projectKey: string;
|
||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||
projectDisplayName: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
title?: string;
|
||||
port?: number;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
||||
git?: boolean;
|
||||
}): Promise<SeededWorkspace> {
|
||||
const project =
|
||||
options.git === false
|
||||
? await createTempDirectory(options.repoPrefix)
|
||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||
const client = await connectSeedClient({ port: options.port });
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
title: options.title,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
const workspace = created.workspace;
|
||||
const listed = await client.listProjects();
|
||||
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
||||
if (!descriptor?.projectKey) {
|
||||
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectId: workspace.projectId,
|
||||
projectKey: descriptor.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
Reference in New Issue
Block a user