import { describe, expect, test } from "bun:test"; import { DEFAULT_FRAGMENT, kdlString, sessionLayoutKdl, singleTabLayoutKdl, tabKdl, } from "../src/core/layout.ts"; const spec = { name: "foo", relPath: "foo", cwd: "/home/user/dev/foo" }; describe("kdlString", () => { test("escapes quotes and backslashes", () => { expect(kdlString('a"b')).toBe('"a\\"b"'); expect(kdlString("a\\b")).toBe('"a\\\\b"'); }); }); describe("tabKdl", () => { test("carries name, purpose and cwd", () => { const kdl = tabKdl(spec); expect(kdl).toContain('name="foo"'); expect(kdl).toContain('purpose="project:foo"'); expect(kdl).toContain('cwd="/home/user/dev/foo"'); }); // Single-line KDL needs `;` between nodes; multi-line does not. Emitting // multi-line is how we avoid the whole class of parse error. test("is always multi-line", () => { const kdl = tabKdl({ ...spec, fragment: "pane\npane" }); expect(kdl.split("\n").length).toBeGreaterThan(3); expect(kdl).not.toMatch(/\{[^\n]*\}/); // no braces opened and closed on one line }); test("uses the default fragment when a project ships none", () => { expect(tabKdl(spec)).toContain(DEFAULT_FRAGMENT); }); test("keeps a project's fragment verbatim, just indented", () => { const fragment = 'pane split_direction="vertical" {\n pane\n pane\n}'; const kdl = tabKdl({ ...spec, fragment }); for (const line of fragment.split("\n")) { expect(kdl).toContain(line.trim()); } }); test("a directory with a quote in its name cannot break out of the string", () => { const kdl = tabKdl({ name: 'ev"il', relPath: 'ev"il', cwd: '/tmp/ev"il' }); expect(kdl).toContain('name="ev\\"il"'); expect(kdl).toContain('cwd="/tmp/ev\\"il"'); // The name does not survive the fork's sanitiser, so the purpose takes the // lossy-marked form rather than silently becoming a *different* valid path // (`project:evil`) that could collide with a real directory called "evil". expect(kdl).toMatch(/purpose="project:\.evil\.[0-9a-f]{8}"/); // Nothing escaped the quoting: on the tab's own line, once escaped quotes // are removed, exactly six delimiters remain (name, purpose, cwd). const head = kdl.split("\n")[0] as string; expect((head.replace(/\\"/g, "").match(/"/g) ?? []).length).toBe(6); }); }); describe("layout wrappers", () => { test("session layout holds every tab", () => { const kdl = sessionLayoutKdl([ spec, { name: "bar", relPath: "bar", cwd: "/home/user/dev/bar" }, ]); expect(kdl.startsWith("layout {\n")).toBe(true); expect(kdl.trimEnd().endsWith("}")).toBe(true); expect(kdl).toContain('purpose="project:foo"'); expect(kdl).toContain('purpose="project:bar"'); }); test("single tab layout holds exactly one", () => { const kdl = singleTabLayoutKdl(spec); expect(kdl.match(/\btab name=/g)).toHaveLength(1); }); test("braces balance", () => { const kdl = sessionLayoutKdl([spec, { ...spec, relPath: "bar", name: "bar" }]); const open = (kdl.match(/\{/g) ?? []).length; const close = (kdl.match(/\}/g) ?? []).length; expect(open).toBe(close); }); }); describe("extractDefaultTabTemplate", () => { // Without the template a generated session has no tab bar at all, so a root // with several projects looks identical to a root with one. That is what // "shouldn't there be a proj_b tab?" turned out to be. test("pulls the block out of a layout file", async () => { const { extractDefaultTabTemplate } = await import("../src/core/layout.ts"); const kdl = `layout { default_tab_template { children pane size=1 borderless=true { plugin location="file:~/x.wasm" } } tab name="a" { pane } }`; const template = extractDefaultTabTemplate(kdl); expect(template).toContain("children"); expect(template).toContain("x.wasm"); expect(template).not.toContain('tab name="a"'); }); // The real zjstatus config is full of {index} / {name} inside quoted format // strings. Counting those as braces truncates the block mid-way and yields a // layout that will not parse. test("ignores braces inside strings and comments", async () => { const { extractDefaultTabTemplate } = await import("../src/core/layout.ts"); const kdl = `layout { default_tab_template { children pane { plugin location="file:~/z.wasm" { tab_normal "#[bg=$bg] {index} {name} {floating_indicator} " // a comment with an unbalanced { brace tab_active "#[fg=$bg]{index}}}{name}" } } } tab name="real" { pane } }`; const template = extractDefaultTabTemplate(kdl); expect(template).toBeDefined(); expect(template).toContain("tab_active"); // the whole template, and nothing past it expect(template).not.toContain('tab name="real"'); expect(template!.startsWith("default_tab_template {")).toBe(true); expect(template!.trimEnd().endsWith("}")).toBe(true); // (a raw brace count would be meaningless here: the format strings contain // {index} and }}} on purpose, which is exactly what the scanner must skip) }); test("returns undefined when there is no template", async () => { const { extractDefaultTabTemplate } = await import("../src/core/layout.ts"); expect(extractDefaultTabTemplate('layout {\n tab name="a" { pane }\n}')).toBeUndefined(); }); test("returns undefined rather than a truncated block when unbalanced", async () => { const { extractDefaultTabTemplate } = await import("../src/core/layout.ts"); expect(extractDefaultTabTemplate("layout {\n default_tab_template {\n children\n")).toBeUndefined(); }); // This used to assert the opposite -- that the template was INLINED into each // tab -- because a templated tab came back with purpose=None and purpose is // identity here. The fork now keeps a tab's own purpose through templating // (sl0p_a_templated_tab_keeps_its_own_purpose), and inlining had a cost: a // tab created at runtime (`new-tab`, or the bar's + button) had no template // to take a status bar from and came up bare. test("the session layout emits the template, and the tabs keep their identity", () => { const template = 'default_tab_template {\n children\n pane size=1 { plugin location="file:~/z.wasm" }\n}'; const kdl = sessionLayoutKdl([spec], template); expect(kdl).toContain("default_tab_template"); // runtime tabs get a bar expect(kdl).toContain('purpose="project:foo"'); // identity survives expect(kdl).toContain("z.wasm"); // status bar came along expect(kdl).toContain('purpose="agent:main"'); // the project's own panes // the template is declared before the tabs that use it expect(kdl.indexOf("default_tab_template")).toBeLessThan(kdl.indexOf('name="foo"')); // ...and exactly once, however many tabs there are const twoTabs = sessionLayoutKdl([spec, { ...spec, name: "bar", relPath: "bar" }], template); expect(twoTabs.split("z.wasm").length - 1).toBe(1); }); // A layout string handed to a RUNNING session is a different case: the // session's own template applies to the new tab, so emitting another one // would give it two status bars. test("a single-tab layout string still inlines", () => { const template = 'default_tab_template {\n children\n pane size=1 { plugin location="file:~/z.wasm" }\n}'; const kdl = singleTabLayoutKdl(spec, template); expect(kdl).not.toContain("default_tab_template"); expect(kdl).toContain("z.wasm"); expect(kdl).toContain('purpose="project:foo"'); }); test("a template without a children node is skipped rather than mangled", async () => { const { applyTemplate } = await import("../src/core/layout.ts"); expect(applyTemplate("pane", "default_tab_template {\n pane\n}")).toBeUndefined(); }); }); // REGRESSION: the shipped layout moved the status bar into a `pane_template` // (so the collapsed layout could reuse it at a different height) and every // `sl0ppi up` died with: // // × Failed to parse Zellij configuration // 43 │ sl0pbar size=3 // · ╰── Invalid tab property: sl0pbar // // The generated layout is its own file: a name the template body refers to has // to be defined in it, not merely in layouts/default.kdl. describe("pane templates travel with the generated layout", () => { const layoutWithPaneTemplate = `layout { pane_template name="sl0pbar" borderless=true { plugin location="file:~/.config/sl0ppij/plugins/zjstatus.wasm" { format_left "{mode} {tabs} {new_tab}" tab_active "#[fg=$bg]{index}}}{name}" // a comment with an unbalanced { brace } } default_tab_template { children sl0pbar size=3 } }`; test("extracts the definition, braces and strings and all", async () => { const { extractPaneTemplates } = await import("../src/core/layout.ts"); const templates = extractPaneTemplates(layoutWithPaneTemplate); expect(templates).toHaveLength(1); expect(templates[0]).toContain('pane_template name="sl0pbar"'); expect(templates[0]).toContain("zjstatus.wasm"); expect(templates[0]).not.toContain("default_tab_template"); }); test("every name the tabs use is defined in the same layout", async () => { const { extractDefaultTabTemplate, extractPaneTemplates } = await import( "../src/core/layout.ts" ); const kdl = sessionLayoutKdl( [spec], extractDefaultTabTemplate(layoutWithPaneTemplate), extractPaneTemplates(layoutWithPaneTemplate), ); expect(kdl).toContain("sl0pbar size=3"); // the tab still uses it expect(kdl).toContain('pane_template name="sl0pbar"'); // and it is defined here expect(kdl.indexOf("pane_template")).toBeLessThan(kdl.indexOf("sl0pbar size=3")); expect(kdl).toContain('purpose="project:foo"'); // identity still survives }); test("single-tab layouts carry them too, for `new-tab --layout-string`", async () => { const { extractDefaultTabTemplate, extractPaneTemplates } = await import( "../src/core/layout.ts" ); const kdl = singleTabLayoutKdl( spec, extractDefaultTabTemplate(layoutWithPaneTemplate), extractPaneTemplates(layoutWithPaneTemplate), ); expect(kdl).toContain('pane_template name="sl0pbar"'); }); test("no pane templates means no preamble, as before", () => { const kdl = sessionLayoutKdl([spec], undefined, []); expect(kdl).not.toContain("pane_template"); expect(kdl.trimStart().startsWith("layout {")).toBe(true); }); // The one that would actually have caught it: our own shipped layout. test("THE SHIPPED LAYOUT generates a self-contained session layout", async () => { const { readFileSync } = await import("node:fs"); const { extractDefaultTabTemplate, extractPaneTemplates } = await import( "../src/core/layout.ts" ); const shipped = readFileSync( new URL("../../config/layouts/default.kdl", import.meta.url), "utf8", ); const kdl = sessionLayoutKdl( [spec], extractDefaultTabTemplate(shipped), extractPaneTemplates(shipped), ); // every bare identifier a tab body uses must be defined in this file const used = [...kdl.matchAll(/^\s{8}([a-z_][a-z_0-9]*)\s/gm)] .map((match) => match[1] as string) .filter((name) => !["pane", "children", "plugin", "tab"].includes(name)); for (const name of new Set(used)) { expect(kdl).toContain(`pane_template name="${name}"`); } }); });