ba2c4e1914
The Workspace page's lane strip moves into the sidebar as a project tree: workspaces grouped by git repository (new GET /api/projects, cached in memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline rename, drag/Alt+arrow ordering and a right-click row menu. The selected workspace is the URL's ?lane=, one shared lane list feeds the tree, the new bottom status bar and the page. The Changes panel gains an activity bar (Files / Changes / Sessions, with resume), and split panes get a header.
113 lines
4.3 KiB
JavaScript
113 lines
4.3 KiB
JavaScript
/**
|
|
* @file Tests for server/lib/projects.js and GET /api/projects: a lane's
|
|
* project root from a main worktree, a linked worktree, a subdirectory and a
|
|
* non-repo; grouping lanes per repo with the Folders pseudo-project last; and
|
|
* matching each worktree to the lane that sits on it.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
const { execFileSync } = require("node:child_process");
|
|
|
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "projects-")));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
|
// Inside a git hook (the pre-commit suite run) git exports GIT_DIR,
|
|
// GIT_INDEX_FILE and friends; the fixture repos below must not inherit them.
|
|
for (const k of Object.keys(process.env)) {
|
|
if (k.startsWith("GIT_") && k !== "GIT_CONFIG_NOSYSTEM") delete process.env[k];
|
|
}
|
|
|
|
const { createApp } = require("../index");
|
|
const lanesLib = require("../lib/lanes");
|
|
const { projectRootFor, clearProjectCaches } = require("../lib/projects");
|
|
|
|
const repo = path.join(ROOT, "zrepo");
|
|
const linked = path.join(ROOT, "linked");
|
|
const claudeWt = path.join(repo, ".claude", "worktrees", "wf-1");
|
|
const plain = path.join(ROOT, "plain");
|
|
let app;
|
|
let base;
|
|
|
|
function git(cwd, ...args) {
|
|
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], {
|
|
cwd,
|
|
stdio: "ignore",
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(path.join(repo, "sub"), { recursive: true });
|
|
fs.mkdirSync(plain);
|
|
git(repo, "init", "-q", "-b", "main");
|
|
fs.writeFileSync(path.join(repo, "a.txt"), "a");
|
|
git(repo, "add", ".");
|
|
git(repo, "commit", "-q", "-m", "init");
|
|
git(repo, "worktree", "add", "-q", "-b", "feat", linked);
|
|
git(repo, "worktree", "add", "-q", "-b", "wf-1", claudeWt);
|
|
app = http.createServer(createApp());
|
|
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
|
base = `http://127.0.0.1:${app.address().port}`;
|
|
});
|
|
after(async () => {
|
|
await new Promise((r) => app.close(r));
|
|
});
|
|
|
|
describe("projectRootFor", () => {
|
|
it("resolves a main worktree, a linked one and a subdir to the main root", async () => {
|
|
assert.equal(await projectRootFor(repo), repo);
|
|
assert.equal(await projectRootFor(linked), repo);
|
|
assert.equal(await projectRootFor(path.join(repo, "sub")), repo);
|
|
});
|
|
it("answers null outside a repo", async () => {
|
|
assert.equal(await projectRootFor(plain), null);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/projects", () => {
|
|
it("groups lanes per repo, Folders last, and marks untracked worktrees", async () => {
|
|
const main = lanesLib.createLane({ cwd: repo });
|
|
const sub = lanesLib.createLane({ cwd: path.join(repo, "sub") });
|
|
const folder = lanesLib.createLane({ cwd: plain });
|
|
clearProjectCaches();
|
|
const res = await fetch(`${base}/api/projects`);
|
|
assert.equal(res.status, 200);
|
|
const { projects } = await res.json();
|
|
assert.deepEqual(
|
|
projects.map((p) => [p.root, p.name, p.workspaces]),
|
|
[
|
|
[repo, "zrepo", [main.id, sub.id]],
|
|
[null, "Folders", [folder.id]],
|
|
]
|
|
);
|
|
const wt = Object.fromEntries(projects[0].worktrees.map((w) => [w.path, w]));
|
|
assert.deepEqual(wt[repo], { path: repo, branch: "main", main: true, workspace_id: main.id });
|
|
// A lane on a SUBDIRECTORY marks no worktree; nobody sits on `linked`.
|
|
assert.equal(wt[linked].workspace_id, null);
|
|
assert.equal(wt[linked].source, undefined);
|
|
assert.equal(wt[claudeWt].source, "claude");
|
|
});
|
|
|
|
it("matches a worktree to a lane adopted on it after the list was cached", async () => {
|
|
const adopted = lanesLib.createLane({ cwd: linked });
|
|
const { projects } = await (await fetch(`${base}/api/projects`)).json();
|
|
const wt = projects[0].worktrees.find((w) => w.path === linked);
|
|
assert.equal(wt.workspace_id, adopted.id);
|
|
});
|
|
|
|
it("refresh refuses a cross-origin POST", async () => {
|
|
const res = await fetch(`${base}/api/projects/refresh`, {
|
|
method: "POST",
|
|
headers: { Origin: "http://evil.test" },
|
|
});
|
|
assert.equal(res.status, 403);
|
|
});
|
|
});
|