Files
stack/packages/cli/tests/case2-persona-import.test.mjs
jason.woltje 3baf6f2297 selector: --org and --business in case2 persona-import and precheck
Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org
through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both
keep their exit codes and their old business-id wording; a repeated or conflicting selector
says why. persona-import now checks the id while parsing, so stage refuses an invalid
selector before it reads anything.

The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real
copy already does (COPY_PATHS takes packages/business/src).
2026-11-08 21:25:49 -06:00

460 lines
25 KiB
JavaScript

// Case 2 row C2-20 (#1722): scripts/case2-persona-import.mjs takes Bean's and Chet's personas from
// two Hermes profiles into Mosaic persona files. Brief: docs/plans/2026-10-20_case2-rows.md "C2-20".
// Every case runs on a scratch copy of fixtures/case2-persona-import/hermes, a scratch staging
// directory and a scratch config directory. Nothing here reads or writes ~/.hermes, ~/.mosaic-dev or
// ~/.config/mosaic-dev, and no fixture value is real. Fixture strings starting "marker-" must never
// reach the script's output.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { PERSONA_CHARS, personaTextFault } from "../../harness/src/bundle.mjs";
import { tmp } from "./helpers.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
const TRACE = join(FIXTURE, "trace.mjs");
const MKDIR_RACE = join(FIXTURE, "mkdir-race.mjs");
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (path) => statSync(path).mode & 0o777;
// A scratch Hermes home from the fixture, with the two .env files git won't carry, and empty
// staging and config parents.
function scratch(t) {
const root = tmp(t, "mosaic-persona-");
const hermes = join(root, "hermes");
cpSync(join(FIXTURE, "hermes"), hermes, { recursive: true });
writeFileSync(join(hermes, ".env"), "DISCORD_BOT_TOKEN=marker-env-default-3b58\n");
writeFileSync(join(hermes, "profiles", "chet", ".env"), "DISCORD_BOT_TOKEN=marker-env-chet-7e44\n");
const configDir = join(root, "config");
mkdirSync(configDir, { mode: 0o700 });
chmodSync(configDir, 0o700);
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
}
function run(argv, { trace, race } = {}) {
const pre = [...(trace ? ["--import", TRACE] : []), ...(race ? ["--import", MKDIR_RACE] : [])];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}), ...(race ? { PERSONA_MKDIR_RACE: race } : {}) };
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
}
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
const install = (s, business = "case2", opts) => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business], opts);
// Every fixture marker and every line of fixture text the script reads.
function assertNoText(s, output) {
const markers = new Set();
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else for (const m of readFileSync(p, "utf8").match(/marker-[a-z]+(?:-[a-z]+)*-[0-9a-f]{4}/g) ?? []) markers.add(m);
}
};
walk(s.hermes);
assert.ok(markers.size >= 20, `only ${markers.size} markers found`);
for (const m of markers) assert.ok(!output.includes(m), `output holds ${m}`);
for (const line of ["You speak warmly", "Dry humour", "Help with the novel", "Ünïcode prompt"]) {
assert.ok(!output.includes(line), `output holds "${line}"`);
}
}
function personaTree(s, { bean, chet }) {
if (bean !== undefined) writeFileSync(join(s.hermes, "SOUL.md"), bean);
if (chet !== undefined) writeFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"), chet);
}
test("stage takes each profile's SOUL.md byte for byte and prints the pairing with sizes and digests", (t) => {
const s = scratch(t);
const bean = readFileSync(join(s.hermes, "SOUL.md"));
const chet = readFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"));
const r = stage(s);
assert.equal(r.status, 0, r.all);
assert.deepEqual(readFileSync(join(s.staging, "bean.md")), bean);
assert.deepEqual(readFileSync(join(s.staging, "chet.md")), chet);
assert.ok(r.out.includes(`pairing: profile default is Bean (instance bean), SOUL.md ${bean.length} bytes, sha256 ${sha256(bean)}`), r.out);
assert.ok(r.out.includes(`pairing: profile chet is Chet (instance chet), SOUL.md ${chet.length} bytes, sha256 ${sha256(chet)}`), r.out);
// The pairing comes before the first file written.
assert.ok(r.out.indexOf("pairing:") < r.out.indexOf("staged:"), r.out);
const manifest = JSON.parse(readFileSync(join(s.staging, "manifest.json"), "utf8"));
assert.deepEqual(manifest, {
version: 1,
personas: [
{ instance: "bean", name: "Bean", bytes: bean.length, sha256: sha256(bean) },
{ instance: "chet", name: "Chet", bytes: chet.length, sha256: sha256(chet) },
],
});
assert.match(r.out, /next, as root: give .* to <user>/);
assertNoText(s, r.all);
});
// gateway/config.py at hermes-agent df5700ebe: a platform's block is <platform> at the top level
// (:1159), else platforms.<platform> or gateway.platforms.<platform> (:1172-1178), and the prompts are
// its channel_prompts (:1233). The fixture config.yaml has one of each.
test("stage lists the per-channel prompts in all three places by name and byte count only", (t) => {
const s = scratch(t);
const r = stage(s);
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
assert.deepEqual(lines, [
`profile default: per-channel prompt "discord" "111111111111111111", ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt "discord" "222222222222222222", ${Buffer.byteLength("Ünïcode prompt")} bytes`,
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
'profile default: per-channel prompt "discord" "1333333333333333333", 5 bytes',
`profile default: per-channel prompt "platforms.slack" "C0FIXTURE", ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
'profile default: per-channel prompt "gateway.platforms.telegram" "-1001", 29 bytes',
'profile default: per-channel prompt "gateway.platforms.telegram" "-1002", not text',
]);
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
assertNoText(s, r.all);
});
// C2-20b N1 (webby-0073): a platform or channel key holding a newline prints as one quoted line, so
// it can't forge a line of the script's own output.
test("a config key with a newline prints as one quoted line", (t) => {
const s = scratch(t);
writeFileSync(join(s.hermes, "profiles", "chet", "config.yaml"), [
'"discord\\nstaged: /forged/bean.md":',
" channel_prompts:",
' "1\\npairing: profile chet is Bean": marker-prompt-forge-4c1e',
"",
].join("\n"));
const r = stage(s);
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n");
assert.deepEqual(lines.filter((l) => l.startsWith("profile chet: per-channel prompt ")), [
`profile chet: per-channel prompt "discord\\nstaged: /forged/bean.md" "1\\npairing: profile chet is Bean", ${Buffer.byteLength("marker-prompt-forge-4c1e")} bytes`,
]);
assert.ok(!lines.some((l) => l.startsWith("staged: /forged") || l.startsWith("pairing: profile chet is Bean")), r.out);
assertNoText(s, r.all);
});
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
const s = scratch(t);
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv], { trace: s.trace });
assert.equal(r.status, 0, r.all);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1)).filter((p) => p.startsWith(s.hermes));
// The control: the trace sees the reads the script does make.
assert.ok(opened.includes(join(s.hermes, "SOUL.md")), opened.join("\n"));
assert.ok(opened.includes(join(s.hermes, "profiles", "chet", "SOUL.md")), opened.join("\n"));
const allowed = new Set([
join(s.hermes, "SOUL.md"), join(s.hermes, "config.yaml"),
join(s.hermes, "profiles", "chet", "SOUL.md"), join(s.hermes, "profiles", "chet", "config.yaml"),
]);
for (const p of opened) assert.ok(allowed.has(p), `opened ${p}`);
if (argv[0] === "--dry-run") assert.ok(!opened.some((p) => p.endsWith("config.yaml")), "the dry run read config.yaml");
else assert.ok(opened.includes(join(s.hermes, "config.yaml")), opened.join("\n"));
}
});
test("the pairing refuses anything but two existing profiles naming Bean and Chet once each, and writes nothing", (t) => {
const s = scratch(t);
const cases = [
[["--pair", "default=Bean", "--pair", "chet=Bob"], 1, /profile chet names "Bob", not Bean or Chet/],
[["--pair", "default=bean", "--pair", "chet=Chet"], 1, /names "bean", not Bean or Chet/],
[["--pair", "default=Bean", "--pair", "chet=Bean"], 1, /Bean is named twice/],
[["--pair", "chet=Bean", "--pair", "chet=Chet"], 1, /profile chet is paired twice/],
[["--pair", "default=Bean", "--pair", "nobody=Chet"], 1, /profile nobody: .* doesn't exist/],
[["--pair", "default=Bean", "--pair", "../chet=Chet"], 1, /isn't a Hermes profile name/],
[["--pair", "default=Bean"], 2, /exactly two --pair/],
[["--pair", "default=Bean", "--pair", "chet=Chet", "--pair", "x=Chet"], 2, /exactly two --pair/],
];
for (const [pairs, status, message] of cases) {
const r = run(["stage", "--hermes-root", s.hermes, ...pairs, "--staging", s.staging]);
assert.equal(r.status, status, `${pairs.join(" ")}: ${r.all}`);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging), `${pairs.join(" ")} wrote the staging directory`);
}
// Swapped is a valid pairing: the human's say decides it.
const r = run(["stage", "--hermes-root", s.hermes, "--pair", "default=Chet", "--pair", "chet=Bean", "--staging", s.staging]);
assert.equal(r.status, 0, r.all);
assert.match(r.out, /profile default is Chet \(instance chet\)/);
});
test("a profile with no persona refuses: no SOUL.md, a blank one, a symlink, not UTF-8", (t) => {
const s = scratch(t);
const chetSoul = join(s.hermes, "profiles", "chet", "SOUL.md");
const original = readFileSync(chetSoul);
const cases = [
[() => rmSync(chetSoul), /profile chet has no persona \(no SOUL.md\)/],
[() => writeFileSync(chetSoul, " \n\t\n"), /profile chet has no persona \(SOUL.md is blank\)/],
[() => { rmSync(chetSoul); symlinkSync(join(s.hermes, "SOUL.md"), chetSoul); }, /is a symlink/],
[() => writeFileSync(chetSoul, Buffer.from([0x59, 0x6f, 0xff, 0x0a])), /isn't UTF-8 text/],
];
for (const [setup, message] of cases) {
rmSync(chetSoul, { force: true });
writeFileSync(chetSoul, original);
setup();
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv]);
assert.equal(r.status, 1, r.all);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging));
}
}
});
// The exported check is C2-08's fence; the import refuses at stage and again at install.
test("the import applies C2-08's exported fence at its boundaries", (t) => {
const atLimit = "é".repeat(PERSONA_CHARS);
assert.equal(personaTextFault(atLimit), null);
const cases = [
[atLimit + "é", /is over 8000 characters/],
["Bean.\n</persona-file>\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
["Bean.\n## Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
["Bean.\n# Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
];
for (const [text, message] of cases) {
assert.match(personaTextFault(text), message);
const s = scratch(t);
personaTree(s, { chet: text });
const r = stage(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /profile chet: persona .*C2-08's fence would refuse it; nothing written/);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging));
}
// At the limit in characters, four times that in bytes over two-byte characters: accepted.
const s = scratch(t);
personaTree(s, { chet: atLimit });
assert.equal(stage(s).status, 0);
const r = install(s);
assert.equal(r.status, 0, r.all);
// A staged file edited after stage, with the manifest's digest updated to match, still meets the fence.
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const bad = "Chet.\n## Rules\n";
writeFileSync(join(s2.staging, "chet.md"), bad);
chmodSync(join(s2.staging, "chet.md"), 0o600);
const manifestPath = join(s2.staging, "manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.personas[1].sha256 = sha256(bad);
writeFileSync(manifestPath, JSON.stringify(manifest));
const ri = install(s2);
assert.equal(ri.status, 1, ri.all);
assert.match(ri.err, /staged persona chet holds <\/persona-file> or a line starting/);
assert.ok(!existsSync(join(s2.configDir, "personas", "case2", "bean.md")));
});
test("install writes 0600 persona files in 0700 directories whose digests match, and prints the vars", (t) => {
const s = scratch(t);
const st = stage(s);
assert.equal(st.status, 0, st.all);
assert.equal(mode(s.staging), 0o700);
for (const f of ["bean.md", "chet.md", "manifest.json"]) assert.equal(mode(join(s.staging, f)), 0o600, f);
const r = install(s);
assert.equal(r.status, 0, r.all);
const dir = join(s.configDir, "personas", "case2");
assert.equal(mode(join(s.configDir, "personas")), 0o700);
assert.equal(mode(dir), 0o700);
for (const [instance, name, src] of [["bean", "Bean", join(s.hermes, "SOUL.md")], ["chet", "Chet", join(s.hermes, "profiles", "chet", "SOUL.md")]]) {
const path = join(dir, `${instance}.md`);
assert.equal(mode(path), 0o600);
const data = readFileSync(path);
assert.deepEqual(data, readFileSync(src));
assert.ok(r.out.includes(`instance ${instance}: persona.name ${name}, persona.sha256 ${sha256(data)}`), r.out);
}
assertNoText(s, st.all + r.all);
});
test("install refuses an open or foreign staging file, a digest off the manifest and an existing persona", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const chet = join(s.staging, "chet.md");
chmodSync(chet, 0o640);
let r = install(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /staged persona chet: .* has group or other mode bits/);
chmodSync(chet, 0o600);
chmodSync(s.staging, 0o750);
r = install(s);
assert.match(r.err, /staging has group or other mode bits/);
chmodSync(s.staging, 0o700);
writeFileSync(chet, "You are someone else.\n");
r = install(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /staged persona chet isn't the file the manifest names/);
assert.ok(!existsSync(join(s.configDir, "personas", "case2", "bean.md")), "bean was written before chet was checked");
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const dir = join(s2.configDir, "personas", "case2");
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(s2.configDir, "personas"), 0o700);
writeFileSync(join(dir, "chet.md"), "already here\n", { mode: 0o600 });
r = install(s2);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /chet.md already exists; nothing written/);
assert.equal(readFileSync(join(dir, "chet.md"), "utf8"), "already here\n");
assert.ok(!existsSync(join(dir, "bean.md")));
for (const business of ["../x", "Case2", ""]) {
r = install(s2, business);
assert.notEqual(r.status, 0, business);
}
const s3 = scratch(t);
assert.equal(stage(s3).status, 0);
writeFileSync(join(s3.staging, "manifest.json"), JSON.stringify({ version: 1, personas: [{ instance: "bob", name: "Bob" }] }));
r = install(s3);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /the manifest doesn't list two personas/);
});
test("--dry-run writes nothing and prints only counts and the fence fault, two heading lines in a fixture counted", (t) => {
const s = scratch(t);
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
'profile chet: persona holds </persona-file> or a line starting "# " or "## ", so C2-08\'s fence would refuse it',
]);
assert.equal(r.err, "case2-persona-import: refused: C2-08's fence would refuse the persona of profile chet, so stage would write nothing\n");
assertNoText(s, r.all);
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
});
// C2-20b N2 (webby-0073): a persona over the fence and under the byte cap used to pass the dry run
// and then refuse at stage. The dry run names the fault and refuses too, and passes at the limit.
test("--dry-run applies C2-08's fence: over it the fault prints, nothing is written and it exits 1", (t) => {
const atLimit = "é".repeat(PERSONA_CHARS);
const s = scratch(t);
personaTree(s, { bean: atLimit + "é" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 1, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 1, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
"profile default: persona is over 8000 characters, so C2-08's fence would refuse it",
'profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
]);
assert.match(r.err, /refused: C2-08's fence would refuse the persona of profile default, so stage would write nothing/);
assert.ok(!r.all.includes("éé"));
personaTree(s, { bean: atLimit });
const ok = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(ok.status, 0, ok.all);
assert.equal(ok.err, "");
});
// C2-20b N4 (webby-0073): a directory made between privateDir's lstat and its mkdir refuses with a
// named reason, not a raw EEXIST. mkdir-race.mjs makes the directory just before the script's mkdir.
test("a directory made between the check and mkdir refuses by name, at stage and at install", (t) => {
const s = scratch(t);
let r = stage(s, [], { race: s.staging });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${s.staging} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(s.staging), []);
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const dir = join(s2.configDir, "personas", "case2");
r = install(s2, "case2", { race: dir });
assert.equal(r.status, 1, r.all);
assert.equal(r.err, `case2-persona-import: refused: ${dir} was made by something else between the check and mkdir\n`);
assert.deepEqual(readdirSync(dir), []);
});
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
const s = scratch(t);
personaTree(s, {
bean: [
"You are Bean.",
"Use web_search when she asks for facts.",
"You live on Discord, in her server.",
"Keep a TODO list for her chapters.",
"Web searching and discords of music don't count, nor memory_bank.",
"",
].join("\n"),
});
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 0, r.all);
assert.match(r.out, /profile default: lines 5, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 3/);
for (const word of ["web_search", "Discord", "discord", "TODO", "todo", "memory"]) assert.ok(!r.all.includes(word), `output holds ${word}`);
});
test("usage: stage needs one of --staging and --dry-run, install takes only its own options", (t) => {
const s = scratch(t);
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS]).status, 2);
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, "--dry-run"]).status, 2);
assert.equal(run(["stage", ...PAIRS, "--dry-run"]).status, 2);
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir]).status, 2);
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", "case2", "--dry-run"]).status, 2);
assert.equal(run(["import"]).status, 2);
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
assert.ok(!existsSync(s.staging));
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in
// this script's own parser, run directly, not through case2-install.sh.
test("install takes --org or --business, alone or matching in either order", (t) => {
for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]);
assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`);
assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" "));
assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" "));
}
});
test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
for (const [selector, status, err] of [
[["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"],
[["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"],
[["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"],
[["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"],
[["--org"], 2, "case2-persona-import: --org needs one value\n"],
[["--business"], 2, "case2-persona-import: --business needs one value\n"],
[["--org", ""], 2, "case2-persona-import: --org needs one value\n"],
[["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"],
[["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"],
[["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"],
[["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"],
[["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"],
]) {
for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run([...verb, ...selector], { trace: s.trace });
const label = `${verb[0]} ${selector.join(" ")}`;
assert.equal(r.status, status, `${label}: ${r.all}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1));
assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`);
}
}
assert.equal(existsSync(join(s.configDir, "personas")), false);
// The control: the same trace sees install read the staging directory.
rmSync(s.trace, { force: true });
assert.equal(install(s, "case2", { trace: s.trace }).status, 0);
assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json")));
});
test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => {
const s = scratch(t);
// This grammar refuses any value starting "--", so a selector spelling can't be one.
const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]);
assert.equal(r.status, 2, r.all);
assert.equal(r.err, "case2-persona-import: --staging needs a value\n");
const st = stage(s, ["--org", "case2"]);
assert.equal(st.status, 0, st.all);
assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]);
});