Files
paseo/packages/app/e2e/helpers/workspace.ts
Mathias Kurz 44d13919d7 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-05 19:56:51 +07:00

139 lines
4.7 KiB
TypeScript

import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
}
async function configureRemote(input: {
repoPath: string;
withRemote: boolean;
originUrl: string | undefined;
}): Promise<void> {
const { repoPath, withRemote, originUrl } = input;
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
return;
}
if (originUrl) {
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
cwd: repoPath,
stdio: "ignore",
});
}
}
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: {
withRemote?: boolean;
originUrl?: string;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
if (options?.paseoConfig) {
await writeFile(
path.join(repoPath, "paseo.json"),
JSON.stringify(options.paseoConfig, null, 2),
);
}
for (const file of options?.files ?? []) {
const filePath = path.join(repoPath, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
}
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
if (options?.paseoConfig) {
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
}
for (const file of options?.files ?? []) {
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
return {
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
};
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}