aboutsummaryrefslogtreecommitdiffstats
path: root/cli/test
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 21:55:12 +0200
committeruser <user@clank>2026-08-12 21:55:12 +0200
commit72441afa75c058e4b1454108d962f2bebaee941b (patch)
treed668f1f61d8cbf19127013724db50cf680aa59c9 /cli/test
parentdocs: agree the sl0ppi CLI design (diff)
downloadsl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.gz
sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.tar.xz
sl0ppi-72441afa75c058e4b1454108d962f2bebaee941b.zip
cli: the idempotency core (discovery, tab identity, plan diff)
Slice 1 of docs/cli-design.md: the only part with logic worth testing hard. Pure functions, no subprocesses, so the tests hit them directly. purpose.ts tab identity as a zellij tab purpose carrying the project's root-relative path discover.ts depth-1 scan for .sl0ppi/ markers plan.ts discovered projects vs list-tabs -> {create, close, keep, foreign} Identity carries the *path* because `list-panes --json` exposes no cwd (checked against a live session, not assumed), so there is otherwise no way back from a tab to a directory on disk. Slashes survive the fork's sanitiser, so nested paths encode fine. Two safety properties, both fuzzed over 5000 random names: - a purpose never exceeds 64 chars and always survives re-sanitising, so what we generate is what the terminal stores - projectRelPath() reconstructs the original path exactly or returns null -- never a different path. Wrong answers close the wrong tab; refusing only leaves a stale one. The fuzz caught a real bug in the second property. The first implementation detected lossy encodings by re-encoding and comparing, which silently failed: `project:<47 a's>.<hash8>` is itself 64 legal characters, so it re-encodes to itself, the check passed, and a bogus path was "reconstructed" -- which would have closed a live tab. Lossy purposes now carry a structural marker (a leading dot, which no discovered path can have) instead of relying on a comparison. Plus the mount guard from the design: when the ROOT itself is missing, close nothing. An unmounted filesystem is indistinguishable from mass deletion, and one of those two readings eats your whole session. 31 unit tests + 2 fuzz properties, 10178 assertions, green.
Diffstat (limited to 'cli/test')
-rw-r--r--cli/test/discover.test.ts68
-rw-r--r--cli/test/plan.test.ts147
-rw-r--r--cli/test/purpose.test.ts140
3 files changed, 355 insertions, 0 deletions
diff --git a/cli/test/discover.test.ts b/cli/test/discover.test.ts
new file mode 100644
index 0000000..3e23aeb
--- /dev/null
+++ b/cli/test/discover.test.ts
@@ -0,0 +1,68 @@
+import { afterAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { discoverProjects, isProjectDir } from "../src/core/discover.ts";
+
+// Disk-backed scratch: /tmp is tmpfs on the dev box, and these are tiny, but
+// the habit matters.
+const root = mkdtempSync(join(tmpdir(), "sl0ppi-discover-"));
+afterAll(() => rmSync(root, { recursive: true, force: true }));
+
+function project(rel: string): void {
+ mkdirSync(join(root, rel, ".sl0ppi"), { recursive: true });
+ writeFileSync(join(root, rel, ".sl0ppi", "layout.kdl"), "pane\n");
+}
+function plainDir(rel: string): void {
+ mkdirSync(join(root, rel), { recursive: true });
+}
+
+project("beta");
+project("alpha");
+plainDir("not-a-project");
+plainDir(".hidden-dir");
+mkdirSync(join(root, ".hidden-dir", ".sl0ppi"), { recursive: true });
+project("group/nested"); // depth 2
+writeFileSync(join(root, "a-file"), "");
+symlinkSync(join(root, "alpha"), join(root, "linked"));
+symlinkSync(join(root, "nowhere"), join(root, "broken-link"));
+
+describe("discoverProjects", () => {
+ test("finds directories carrying the .sl0ppi marker, alphabetically", () => {
+ expect(discoverProjects(root)).toEqual(["alpha", "beta", "linked"]);
+ });
+
+ test("ignores directories without the marker", () => {
+ expect(discoverProjects(root)).not.toContain("not-a-project");
+ });
+
+ test("ignores dotfiles even when they carry a marker", () => {
+ expect(discoverProjects(root)).not.toContain(".hidden-dir");
+ });
+
+ test("is depth-1 by default", () => {
+ expect(discoverProjects(root)).not.toContain("group/nested");
+ });
+
+ test("finds nested projects when asked", () => {
+ expect(discoverProjects(root, { depth: 2 })).toContain("group/nested");
+ });
+
+ test("follows symlinked project dirs but survives broken ones", () => {
+ const found = discoverProjects(root);
+ expect(found).toContain("linked");
+ expect(found).not.toContain("broken-link");
+ });
+
+ test("returns empty for a missing root instead of throwing", () => {
+ expect(discoverProjects(join(root, "does-not-exist"))).toEqual([]);
+ });
+});
+
+describe("isProjectDir", () => {
+ test("requires the marker to be a directory", () => {
+ expect(isProjectDir(join(root, "alpha"))).toBe(true);
+ expect(isProjectDir(join(root, "not-a-project"))).toBe(false);
+ expect(isProjectDir(join(root, "nope"))).toBe(false);
+ });
+});
diff --git a/cli/test/plan.test.ts b/cli/test/plan.test.ts
new file mode 100644
index 0000000..d7bb8ba
--- /dev/null
+++ b/cli/test/plan.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, test } from "bun:test";
+import { isNoOp, planTabs, type TabInfo } from "../src/core/plan.ts";
+import { projectPurpose } from "../src/core/purpose.ts";
+
+const ROOT = "/home/user/dev";
+
+/** Every path under the root exists unless a test says otherwise. */
+function existsAll(): (p: string) => boolean {
+ return () => true;
+}
+function existsOnly(paths: string[]): (p: string) => boolean {
+ const set = new Set(paths);
+ return (p) => set.has(p);
+}
+
+function tab(position: number, name: string, purpose?: string): TabInfo {
+ return { position, name, purpose };
+}
+
+describe("planTabs", () => {
+ test("creates one tab per discovered project, alphabetically", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["zeta", "alpha", "mid"],
+ tabs: [],
+ pathExists: existsAll(),
+ });
+
+ expect(plan.create.map((c) => c.relPath)).toEqual(["alpha", "mid", "zeta"]);
+ expect(plan.close).toHaveLength(0);
+ });
+
+ // THE property. If this ever fails, the tool is worse than useless.
+ test("is a no-op when every project already has its tab", () => {
+ const projects = ["alpha", "mid", "zeta"];
+ const tabs = projects.map((p, i) => tab(i, p, projectPurpose(p)));
+
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects,
+ tabs,
+ pathExists: existsAll(),
+ });
+
+ expect(isNoOp(plan)).toBe(true);
+ expect(plan.keep).toHaveLength(3);
+ });
+
+ test("applying the plan twice converges (second run is a no-op)", () => {
+ const projects = ["alpha", "beta"];
+ let tabs: TabInfo[] = [];
+
+ const first = planTabs({ root: ROOT, rootExists: true, projects, tabs, pathExists: existsAll() });
+ // simulate applying it
+ tabs = first.create.map((c, i) => tab(i, c.relPath, c.purpose));
+
+ const second = planTabs({ root: ROOT, rootExists: true, projects, tabs, pathExists: existsAll() });
+ expect(first.create).toHaveLength(2);
+ expect(isNoOp(second)).toBe(true);
+ });
+
+ test("creates only what is missing", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha", "beta"],
+ tabs: [tab(0, "alpha", projectPurpose("alpha"))],
+ pathExists: existsAll(),
+ });
+
+ expect(plan.create.map((c) => c.relPath)).toEqual(["beta"]);
+ expect(plan.keep).toHaveLength(1);
+ });
+
+ test("closes a tab whose project directory is gone", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha"],
+ tabs: [tab(0, "alpha", projectPurpose("alpha")), tab(1, "deleted", projectPurpose("deleted"))],
+ pathExists: existsOnly([`${ROOT}/alpha`]),
+ });
+
+ expect(plan.close.map((t) => t.name)).toEqual(["deleted"]);
+ });
+
+ // The mount guard: an unmounted root looks exactly like mass deletion.
+ test("closes NOTHING when the root itself is missing", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: false,
+ projects: [],
+ tabs: [
+ tab(0, "alpha", projectPurpose("alpha")),
+ tab(1, "beta", projectPurpose("beta")),
+ ],
+ pathExists: existsOnly([]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.keep).toHaveLength(2);
+ });
+
+ test("keeps an existing project that simply was not discovered (nested / marker removed)", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: [],
+ tabs: [tab(0, "nested", projectPurpose("group/nested"))],
+ pathExists: existsOnly([`${ROOT}/group/nested`]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.keep).toHaveLength(1);
+ });
+
+ test("never touches tabs without a project purpose", () => {
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: ["alpha"],
+ tabs: [tab(0, "scratch"), tab(1, "notes", "human:notes")],
+ pathExists: existsOnly([`${ROOT}/alpha`]),
+ });
+
+ expect(plan.close).toHaveLength(0);
+ expect(plan.foreign.map((t) => t.name)).toEqual(["scratch", "notes"]);
+ expect(plan.create.map((c) => c.relPath)).toEqual(["alpha"]);
+ });
+
+ // A purpose that had to be truncated+hashed cannot be turned back into a
+ // path, so we must not "verify" it and close the tab.
+ test("keeps a tab whose purpose cannot be reconstructed", () => {
+ const long = "a".repeat(120);
+ const plan = planTabs({
+ root: ROOT,
+ rootExists: true,
+ projects: [],
+ tabs: [tab(0, "long", projectPurpose(long))],
+ pathExists: existsOnly([]), // nothing exists
+ });
+
+ expect(plan.close).toHaveLength(0);
+ });
+});
diff --git a/cli/test/purpose.test.ts b/cli/test/purpose.test.ts
new file mode 100644
index 0000000..e036761
--- /dev/null
+++ b/cli/test/purpose.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test";
+import {
+ PURPOSE_MAX,
+ isProjectPurpose,
+ projectPurpose,
+ projectRelPath,
+ sanitizePurpose,
+} from "../src/core/purpose.ts";
+
+describe("sanitizePurpose (must match the fork)", () => {
+ test("keeps the fork's allowed set", () => {
+ expect(sanitizePurpose("a-b_c.d:e/f")).toBe("a-b_c.d:e/f");
+ });
+
+ // The fork's own doc uses this exact example.
+ test("strips shell metacharacters", () => {
+ expect(sanitizePurpose('rm -rf /; echo "x"')).toBe("rm-rf/echox");
+ });
+
+ test("strips non-ascii", () => {
+ expect(sanitizePurpose("café–π")).toBe("caf");
+ });
+
+ test("truncates to the fork's limit", () => {
+ expect(sanitizePurpose("x".repeat(200))).toHaveLength(PURPOSE_MAX);
+ });
+});
+
+describe("projectPurpose", () => {
+ test("is the plain relative path for ordinary names", () => {
+ expect(projectPurpose("sl0ppi")).toBe("project:sl0ppi");
+ expect(projectPurpose("group/nested")).toBe("project:group/nested");
+ });
+
+ test("never exceeds what the fork will store", () => {
+ for (const name of ["ok", "a".repeat(200), "wéîrd name!", "x/".repeat(50)]) {
+ expect(projectPurpose(name).length).toBeLessThanOrEqual(PURPOSE_MAX);
+ }
+ });
+
+ test("survives the fork's sanitiser unchanged (round-trip through the terminal)", () => {
+ for (const name of ["ok", "a".repeat(200), "wéîrd name!", "dots...", "x/".repeat(50)]) {
+ const purpose = projectPurpose(name);
+ expect(sanitizePurpose(purpose)).toBe(purpose);
+ }
+ });
+
+ test("distinct projects get distinct purposes, even when truncated", () => {
+ const a = projectPurpose(`${"a".repeat(80)}/one`);
+ const b = projectPurpose(`${"a".repeat(80)}/two`);
+ expect(a).not.toBe(b);
+ });
+
+ test("is stable across calls", () => {
+ expect(projectPurpose("foo")).toBe(projectPurpose("foo"));
+ expect(projectPurpose("wéîrd name!")).toBe(projectPurpose("wéîrd name!"));
+ });
+});
+
+describe("projectRelPath", () => {
+ test("reconstructs an ordinary path", () => {
+ expect(projectRelPath("project:sl0ppi")).toBe("sl0ppi");
+ expect(projectRelPath("project:group/nested")).toBe("group/nested");
+ });
+
+ test("refuses to reconstruct a truncated/hashed purpose", () => {
+ expect(projectRelPath(projectPurpose("a".repeat(200)))).toBeNull();
+ });
+
+ test("refuses non-project purposes", () => {
+ expect(projectRelPath("agent:main")).toBeNull();
+ expect(projectRelPath("project:")).toBeNull();
+ });
+
+ test("round-trips every reconstructable name", () => {
+ for (const name of ["foo", "group/nested", "dots.in.name", "a-b_c"]) {
+ expect(projectRelPath(projectPurpose(name))).toBe(name);
+ }
+ });
+});
+
+// The safety invariant, fuzzed. Closing a tab is destructive, so the one thing
+// projectRelPath must never do is return a path that is not the original.
+// Wrong answers close the wrong tab; refusing (null) only leaves a stale one.
+describe("projectRelPath never lies (fuzz)", () => {
+ const alphabet = "abzAZ09_-./: !@#\u00e9\u4e2d\"';";
+
+ function randomName(rng: () => number): string {
+ const len = 1 + Math.floor(rng() * 90);
+ let out = "";
+ for (let i = 0; i < len; i++) {
+ out += alphabet[Math.floor(rng() * alphabet.length)];
+ }
+ return out;
+ }
+
+ // Deterministic PRNG so a failure is reproducible.
+ function mulberry32(seed: number): () => number {
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+
+ test("reconstructs exactly, or refuses — never something else", () => {
+ const rng = mulberry32(0x510991);
+ for (let i = 0; i < 5000; i++) {
+ const name = randomName(rng);
+ const purpose = projectPurpose(name);
+
+ expect(purpose.length).toBeLessThanOrEqual(PURPOSE_MAX);
+ expect(sanitizePurpose(purpose)).toBe(purpose);
+
+ const back = projectRelPath(purpose);
+ if (back !== null) expect(back).toBe(name);
+ }
+ });
+
+ test("distinct names never collide on a purpose", () => {
+ const rng = mulberry32(12345);
+ const seen = new Map<string, string>();
+ for (let i = 0; i < 5000; i++) {
+ const name = randomName(rng);
+ const purpose = projectPurpose(name);
+ const prior = seen.get(purpose);
+ if (prior !== undefined) expect(prior).toBe(name);
+ seen.set(purpose, name);
+ }
+ });
+});
+
+describe("isProjectPurpose", () => {
+ test("classifies", () => {
+ expect(isProjectPurpose("project:foo")).toBe(true);
+ expect(isProjectPurpose("agent:main")).toBe(false);
+ expect(isProjectPurpose(undefined)).toBe(false);
+ });
+});