// The organization selector helpers: one table, run against packages/business/src/selector.mjs and // scripts/selector.sh through the same small parser, so the two halves keep one set of rules. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { REPO } from './helpers.mjs'; import { ID_PATTERN } from '../src/vocabulary.mjs'; import { SELECTOR_FAMILIES, isSelector, selectorScope } from '../src/selector.mjs'; // A parser with one other value option (--note), positional text and a `--` separator. function viaJs(family, args) { const scope = selectorScope({ family, valid: family === 'id' ? id => ID_PATTERN.test(id) : null, refuse: kind => { throw Object.assign(new Error(kind), { kind }); } }); const out = { value: null, note: null, positional: [] }; try { for (let i = 0; i < args.length; i++) { const a = args[i]; if (scope.owns(a)) { scope.take(a, args[i + 1]); i++; } else if (a === '--note') out.note = args[++i] ?? null; else if (a === '--') { out.positional.push(...args.slice(i + 1)); break; } else out.positional.push(a); } } catch (e) { if (e.kind) return { refused: e.kind }; throw e; } out.value = scope.value ?? null; return out; } const DRIVER = ` set -euo pipefail . "$1"; family="$2"; shift 2 refuse() { printf 'refused %s\\n' "$1"; exit 3; } valid() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } v=-; [ "$family" = id ] && v=valid note=""; has_note=0; pos=() while [ $# -gt 0 ]; do case "$family:$1" in id:--business|id:--org|file:--business-file|file:--org-file) selector_take s "$family" refuse "$v" "$1" "\${@:2:1}"; shift 2 ;; *:--note) has_note=1; note="\${2-}"; shift; [ $# -gt 0 ] && shift ;; *:--) shift; pos+=("$@"); break ;; *) pos+=("$1"); shift ;; esac done printf 'value %s\\n' "$(selector_value s)" [ "$has_note" = 1 ] && printf 'note %s\\n' "$note" for p in "\${pos[@]+"\${pos[@]}"}"; do printf 'pos %s\\n' "$p"; done `; function viaShell(family, args) { const r = spawnSync('bash', ['-c', DRIVER, 'driver', join(REPO, 'scripts/selector.sh'), family, ...args], { encoding: 'utf8' }); if (r.status === 3) return { refused: /^refused (\S+)$/.exec(r.stdout.trim())[1] }; assert.equal(r.status, 0, r.stderr); const out = { value: null, note: null, positional: [] }; for (const line of r.stdout.split('\n').filter(Boolean)) { const [, key, rest] = /^(\S+) ?(.*)$/.exec(line); if (key === 'value') out.value = rest || null; else if (key === 'note') out.note = rest; else out.positional.push(rest); } return out; } const ok = (value, more = {}) => ({ value, note: null, positional: [], ...more }); const ID_TABLE = [ ['old alone', ['--business', 'acme'], ok('acme')], ['new alone', ['--org', 'acme'], ok('acme')], ['matching, old first', ['--business', 'acme', '--org', 'acme'], ok('acme')], ['matching, new first', ['--org', 'acme', '--business', 'acme'], ok('acme')], ['unequal, old first', ['--business', 'acme', '--org', 'beta'], { refused: 'differ' }], ['unequal, new first', ['--org', 'acme', '--business', 'beta'], { refused: 'differ' }], ['old repeated', ['--business', 'acme', '--business', 'acme'], { refused: 'repeated' }], ['new repeated', ['--org', 'acme', '--org', 'acme'], { refused: 'repeated' }], ['new repeated after a match', ['--org', 'acme', '--business', 'acme', '--org', 'acme'], { refused: 'repeated' }], ['old missing', ['--business'], { refused: 'missing' }], ['new missing', ['--org'], { refused: 'missing' }], ['new empty', ['--org', ''], { refused: 'missing' }], ['old flag-shaped', ['--business', '--org', 'acme'], { refused: 'flag' }], ['new flag-shaped', ['--org', '-x'], { refused: 'flag' }], ['old invalid', ['--business', 'Acme'], { refused: 'invalid' }], ['new invalid', ['--org', 'acme/../x'], { refused: 'invalid' }], ['equal but invalid', ['--org', 'A', '--business', 'A'], { refused: 'invalid' }], ['option-like value of another option', ['--note', '--org', '--business', 'acme'], ok('acme', { note: '--org' })], ['option-like value, new spelling', ['--note', '--business', '--org', 'acme'], ok('acme', { note: '--business' })], ['text after the separator', ['--org', 'acme', '--', '--business', 'beta', '--org'], ok('acme', { positional: ['--business', 'beta', '--org'] })], ['neither', ['word'], ok(null, { positional: ['word'] })], ]; const FILE_TABLE = [ ['old alone', ['--business-file', 'a.json'], ok('a.json')], ['new alone', ['--org-file', 'a.json'], ok('a.json')], ['identical, old first', ['--business-file', 'a.json', '--org-file', 'a.json'], ok('a.json')], ['identical, new first', ['--org-file', 'a.json', '--business-file', 'a.json'], ok('a.json')], ['different paths, old first', ['--business-file', 'a.json', '--org-file', 'b.json'], { refused: 'differ' }], ['different paths, new first', ['--org-file', 'a.json', '--business-file', 'b.json'], { refused: 'differ' }], // Strings are compared as given, so two spellings of one path differ. ['same file, other spelling', ['--org-file', 'a.json', '--business-file', './a.json'], { refused: 'differ' }], ['new repeated', ['--org-file', 'a.json', '--org-file', 'a.json'], { refused: 'repeated' }], ['old repeated', ['--business-file', 'a.json', '--business-file', 'a.json'], { refused: 'repeated' }], ['new missing', ['--org-file'], { refused: 'missing' }], ['new flag-shaped', ['--org-file', '--business-file', 'a.json'], { refused: 'flag' }], ['an id spelling is data here', ['--org', 'x', '--org-file', 'a.json'], ok('a.json', { positional: ['--org', 'x'] })], ]; for (const [family, table] of [['id', ID_TABLE], ['file', FILE_TABLE]]) { for (const [name, args, expected] of table) { test(`${family} selector: ${name}`, () => { assert.deepEqual(viaJs(family, args), expected, 'selector.mjs'); assert.deepEqual(viaShell(family, args), expected, 'selector.sh'); }); } } test('selector families and spellings are fixed', () => { assert.deepEqual(SELECTOR_FAMILIES.id.spellings, ['--business', '--org']); assert.deepEqual(SELECTOR_FAMILIES.file.spellings, ['--business-file', '--org-file']); assert.equal(isSelector('--org'), true); assert.equal(isSelector('--org-file'), false); assert.equal(isSelector('--org-file', 'file'), true); assert.equal(isSelector('--organization'), false); }); test('a refuse function that returns still refuses, and scopes are separate', () => { const quiet = selectorScope({ refuse: () => {} }); assert.throws(() => quiet.take('--org', ''), /selector refusal returned/); assert.throws(() => quiet.take('--org-file', 'x'), TypeError); const refuse = kind => { throw new Error(kind); }; const outer = selectorScope({ refuse }), child = selectorScope({ refuse }); outer.take('--org', 'acme'); child.take('--org', 'beta'); assert.deepEqual([outer.value, child.value, outer.given], ['acme', 'beta', true]); assert.equal(selectorScope({ refuse }).given, false); }); test('the shell helper refuses a misuse and keeps scopes separate', () => { const run = body => spawnSync('bash', ['-c', `set -u; . "$1"; refuse() { echo "refused $1"; exit 3; }; quiet() { :; }; ${body}`, 'x', join(REPO, 'scripts/selector.sh')], { encoding: 'utf8' }); assert.equal(run('selector_take s id refuse - --org-file a').status, 70); assert.equal(run('selector_take bad-name id refuse - --org a').status, 70); assert.equal(run('selector_take s id quiet - --org ""').status, 70); const two = run('selector_take outer id refuse - --org acme; selector_take child id refuse - --business beta; echo "$(selector_value outer) $(selector_value child) [$(selector_value none)]"'); assert.equal(two.status, 0, two.stderr); assert.equal(two.stdout, 'acme beta []\n'); });