Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.
Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
and slugifies all labels at the boundary; `untitled` fallback only
applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
the slug; GitHub URL parsing now requires `host === "github.com"` to
avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
cleanup after a branch rename doesn't leave a stale entry behind.
App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.
Landing-page service URL examples updated; allowlist docs unchanged.
173 lines
4.8 KiB
TypeScript
173 lines
4.8 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import type { DaemonClient } from "@server/client/daemon-client";
|
|
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
|
|
|
import {
|
|
mergeWorkspaceSnapshotWithExisting,
|
|
normalizeWorkspaceDescriptor,
|
|
useSessionStore,
|
|
type WorkspaceDescriptor,
|
|
} from "./session-store";
|
|
|
|
function createWorkspace(
|
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
|
): WorkspaceDescriptor {
|
|
return {
|
|
id: input.id,
|
|
projectId: input.projectId ?? "project-1",
|
|
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
|
projectRootPath: input.projectRootPath ?? "/repo",
|
|
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
|
projectKind: input.projectKind ?? "git",
|
|
workspaceKind: input.workspaceKind ?? "local_checkout",
|
|
name: input.name ?? "main",
|
|
status: input.status ?? "done",
|
|
diffStat: input.diffStat ?? null,
|
|
scripts: input.scripts ?? [],
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
useSessionStore.getState().clearSession("test-server");
|
|
});
|
|
|
|
describe("normalizeWorkspaceDescriptor", () => {
|
|
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
|
const scripts = [
|
|
{
|
|
scriptName: "web",
|
|
type: "service" as const,
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running" as const,
|
|
health: "healthy" as const,
|
|
exitCode: null,
|
|
},
|
|
];
|
|
const workspace = normalizeWorkspaceDescriptor({
|
|
id: "1",
|
|
projectId: "1",
|
|
projectDisplayName: "Project 1",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "checkout",
|
|
name: "main",
|
|
status: "running",
|
|
activityAt: "not-a-date",
|
|
diffStat: null,
|
|
scripts,
|
|
});
|
|
|
|
expect(workspace.scripts).toEqual([
|
|
{
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
exitCode: null,
|
|
},
|
|
]);
|
|
expect(workspace.scripts).not.toBe(scripts);
|
|
});
|
|
|
|
it("defaults missing scripts to an empty array", () => {
|
|
const payload = {
|
|
id: "1",
|
|
projectId: "1",
|
|
projectDisplayName: "Project 1",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "checkout",
|
|
name: "main",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
scripts: [],
|
|
} as WorkspaceDescriptorPayload;
|
|
|
|
const workspace = normalizeWorkspaceDescriptor(payload);
|
|
|
|
expect(workspace.scripts).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("mergeWorkspaces", () => {
|
|
it("preserves scripts on merged workspace entries", () => {
|
|
const store = useSessionStore.getState();
|
|
store.initializeSession("test-server", null as unknown as DaemonClient);
|
|
store.setWorkspaces(
|
|
"test-server",
|
|
new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]),
|
|
);
|
|
|
|
store.mergeWorkspaces("test-server", [
|
|
createWorkspace({
|
|
id: "/repo/main",
|
|
scripts: [
|
|
{
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
exitCode: null,
|
|
},
|
|
],
|
|
}),
|
|
]);
|
|
|
|
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
|
|
{
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
exitCode: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
|
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
|
|
const existing = createWorkspace({
|
|
id: "/tmp/repo",
|
|
diffStat: { additions: 4, deletions: 2 },
|
|
});
|
|
const incoming = createWorkspace({
|
|
id: "/tmp/repo",
|
|
diffStat: null,
|
|
});
|
|
|
|
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
|
|
...incoming,
|
|
diffStat: { additions: 4, deletions: 2 },
|
|
});
|
|
});
|
|
|
|
it("uses the incoming diff stat when the server provides a known value", () => {
|
|
const existing = createWorkspace({
|
|
id: "/tmp/repo",
|
|
diffStat: { additions: 4, deletions: 2 },
|
|
});
|
|
const incoming = createWorkspace({
|
|
id: "/tmp/repo",
|
|
diffStat: { additions: 0, deletions: 0 },
|
|
});
|
|
|
|
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
|
|
});
|
|
});
|