Testing the Loom CLI
The CLI is an Effect program over the tangler, the configuration and the store of language services, and the surest test of it is the one a user runs: the command itself, as a real process, in a real folder. Each case here makes a temporary workspace, runs loom in it, and reads back what the command printed and what it left on disk.
Running the CLI as a process
Bun runs the CLI's TypeScript source directly. So a case runs src/main.ts on the Bun that runs the tests, with the arguments it wants and from whichever directory it needs, and gets back the exit status and both output streams. A case can add environment variables, which is how the language commands are pointed at a store of their own. It names Bun by its path, so a case may replace PATH whole and still start the CLI.
A cold spawn loads the whole tangler before it does anything, and that takes a moment. Under the load of the full suite the default five seconds is not always enough, so every case allows thirty.
import { describe, expect, it as it_ } from 'bun:test'
import { effectify } from '@athrio/effect-test'
import { spawnSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const it = effectify(it_)
const cli = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const main = resolve(cli, 'src/main.ts')
const loom = (
args: ReadonlyArray<string>,
opts: { readonly cwd?: string; readonly env?: Record<string, string> } = {},
) =>
spawnSync(process.execPath, [main, ...args], {
cwd: opts.cwd ?? cli,
env: opts.env ? { ...process.env, ...opts.env } : process.env,
encoding: 'utf8',
})
const SPAWN_TIMEOUT = 30_000Looms written as lines
The cases feed the CLI looms of their own, and a loom quoted inside this loom's code would be read as part of it. A line that starts with # opens a section here, and an anchor is an anchor wherever it stands. So each fixture is written as a list of its lines, every one in quotes and indented, which no line of the book can mistake for its own. lines joins them into the text a file holds, ending on a newline as a file does, and anchor spells an anchor in two pieces for the same reason.
const lines = (...rows: ReadonlyArray<string>): string => `${rows.join('\n')}\n`
const anchor = (name: string): string => `::${'['}${name}]`A workspace to run it in
A workspace is a temporary folder with a .loom/config.yaml in it, the configuration the commands read and write. On macOS a temporary folder sits under /var, which is a link to /private/var, and the child process prints the resolved path. So workspace resolves the path the same way before a case compares anything against it.
The language commands also need a store of services. userStore makes an empty one a case can point LOOM_HOME at, and installFake stands a service in it without installing anything: a folder and a package.json where the package would be. The package's version is this Loom's own, loomVersion, unless a case names an older one. loom add then finds the service current and skips the package manager, so the cases run offline.
{{ version = ${version} }}
const loomVersion = '::[version]'
const workspace = (configYaml: string): string => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'loom-proj-')))
mkdirSync(join(dir, '.loom'), { recursive: true })
writeFileSync(join(dir, '.loom', 'config.yaml'), configYaml)
return dir
}
const configText = (dir: string): string => readFileSync(join(dir, '.loom', 'config.yaml'), 'utf8')
const userStore = (): string => realpathSync(mkdtempSync(join(tmpdir(), 'loom-home-')))
const installFake = (home: string, id: string, version: string = loomVersion): void => {
const pkg = join(home, 'services', 'node_modules', '@athrio', `loom-service-${id}`)
mkdirSync(pkg, { recursive: true })
writeFileSync(
join(pkg, 'package.json'),
`${JSON.stringify({ name: `@athrio/loom-service-${id}`, version })}\n`,
)
}
const storedVersion = (home: string, id: string): string =>
(
JSON.parse(
readFileSync(
join(home, 'services', 'node_modules', '@athrio', `loom-service-${id}`, 'package.json'),
'utf8',
),
) as { readonly version: string }
).versionTangling, and asking for help
The first case is the command Loom exists for. A loom with a section that names its file through {Tangle} and its package through the frontmatter tangles to out.ts beside it, with the greeting it anchors in place. With no arguments at all, the CLI prints its help, and the help names its commands.
const fixture = lines(
'---',
'Language: TypeScript',
'Package: out.ts',
'---',
'',
'# Greeting',
'',
'=>',
'',
'export const hi = "hello"',
'',
'# Out {Tangle}',
'',
'=>',
'',
anchor('Greeting'),
)
const tempDoc = (): { readonly dir: string; readonly doc: string } => {
const dir = mkdtempSync(join(tmpdir(), 'loom-cli-'))
const doc = join(dir, 'doc.loom')
writeFileSync(doc, fixture)
return { dir, doc }
}
describe('loom cli', () => {
it(
'tangles a file through the `tangle` command',
() => {
const { dir, doc } = tempDoc()
loom(['tangle', doc])
expect(readFileSync(join(dir, 'out.ts'), 'utf8')).toContain('export const hi = "hello"')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'prints help listing its commands given no arguments',
() => {
const { stdout } = loom([])
expect(stdout).toContain('tangle')
expect(stdout).toContain('init')
expect(stdout).toContain('add')
expect(stdout).toContain('status')
expect(stdout).toContain('doctor')
},
SPAWN_TIMEOUT,
)
})A home folder is no workspace
A workspace is a folder that holds .loom/config.yaml, and a .loom folder alone makes none. The standalone executable lives in ~/.loom/bin, so the home folder of everyone who installs it holds a .loom folder with no configuration. The case builds such a home and tangles a loose loom in a folder under it. The loom's file lands beside the loom, and so does the lock of the tangle, in a .loom folder of the loom's own. The home folder gets no lock, because it is no workspace, and loom doctor reports that no workspace holds the loom's folder.
describe('loom cli — what makes a workspace', () => {
it(
'takes no workspace from a .loom folder that holds no configuration',
() => {
const home = realpathSync(mkdtempSync(join(tmpdir(), 'loom-home-')))
mkdirSync(join(home, '.loom', 'bin'), { recursive: true })
const scratch = join(home, 'scratch')
mkdirSync(scratch)
writeFileSync(join(scratch, 'hello.loom'), fixture)
const { status, stderr } = loom(['tangle', 'hello.loom'], { cwd: scratch })
expect({ status, stderr }).toMatchObject({ status: 0 })
expect(readFileSync(join(scratch, 'out.ts'), 'utf8')).toContain('export const hi = "hello"')
expect(existsSync(join(scratch, '.loom', 'loom.lock'))).toBe(true)
expect(existsSync(join(home, '.loom', 'loom.lock'))).toBe(false)
expect(loom(['doctor'], { cwd: scratch }).stdout).toContain(`no workspace holds ${scratch}`)
rmSync(home, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})A project's corpus folder
loom init asks its questions only of a folder with no configuration yet, and a question needs a terminal to answer it. A workspace that already holds a configuration is asked nothing, so a subprocess reaches the rest of what init does.
That rest includes the corpus folder. init creates the folder the configuration names, here chapters, and a second run leaves it and the loom inside it exactly as they were. A configuration that names no corpus gets a folder called corpus.
describe('loom cli — init', () => {
it(
'creates the corpus folder the configuration names, and keeps it on a rerun',
() => {
const dir = workspace('corpus: chapters\nlanguages:\n typescript: {}\nprimary: typescript\n')
expect(existsSync(join(dir, 'chapters'))).toBe(false)
loom(['init'], { cwd: dir })
expect(existsSync(join(dir, 'chapters'))).toBe(true)
writeFileSync(join(dir, 'chapters', 'hello.loom'), lines('# Hello'))
loom(['init'], { cwd: dir })
expect(readFileSync(join(dir, 'chapters', 'hello.loom'), 'utf8')).toBe(lines('# Hello'))
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'falls back to a folder named corpus when the configuration names none',
() => {
const dir = workspace('languages:\n typescript: {}\nprimary: typescript\n')
loom(['init'], { cwd: dir })
expect(existsSync(join(dir, 'corpus'))).toBe(true)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})The width an editor wraps a loom at
init also writes the project's width into .editorconfig, as the max_line_length of a [*.loom] section. The first case starts from an .editorconfig with lines of the owner's own and runs init twice. The owner's lines survive, and the section appears once, at the default width of a hundred. The second case changes the configuration's width between two runs, and the section follows it. The third starts from a [*.loom] section of the owner's own, which init keeps exactly as it was.
describe('loom cli — init, the width of a loom', () => {
const configured = 'languages:\n typescript: {}\nprimary: typescript\n'
const editorconfigText = (dir: string): string => readFileSync(join(dir, '.editorconfig'), 'utf8')
it(
'adds a [*.loom] section at the default width, once, beside the lines already there',
() => {
const dir = workspace(configured)
writeFileSync(join(dir, '.editorconfig'), lines('root = true', '', '[*]', 'indent_size = 2'))
loom(['init'], { cwd: dir })
loom(['init'], { cwd: dir })
const text = editorconfigText(dir)
expect(text).toStartWith(lines('root = true', '', '[*]', 'indent_size = 2'))
expect(text.match(/\[\*\.loom\]/g)?.length).toBe(1)
expect(text).toContain('max_line_length = 100')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
"follows the configuration's width from run to run",
() => {
const dir = workspace(`${configured}width: 80\n`)
loom(['init'], { cwd: dir })
expect(editorconfigText(dir)).toContain('max_line_length = 80')
writeFileSync(join(dir, '.loom', 'config.yaml'), `${configured}width: 120\n`)
loom(['init'], { cwd: dir })
const text = editorconfigText(dir)
expect(text).toContain('max_line_length = 120')
expect(text).not.toContain('max_line_length = 80')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
"keeps a [*.loom] section of the owner's own as it was",
() => {
const dir = workspace(configured)
writeFileSync(join(dir, '.editorconfig'), lines('[*.loom]', 'max_line_length = 72'))
const { status, stdout } = loom(['init'], { cwd: dir })
expect(status).toBe(0)
expect(stdout).toContain('kept the [*.loom] section')
expect(editorconfigText(dir)).toBe(lines('[*.loom]', 'max_line_length = 72'))
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Languages and their store
loom status reports where the store is, the primary language, and which languages are activated. loom add records a language in the configuration, and with the fake service already in the store it does so without installing anything. loom remove takes the language out of the configuration and its package out of the store, leaving the other languages as they were.
Where the store is depends on LOOM_HOME. A case that sets it gets the store it names. A case that leaves it unset gets the workspace's own, under .loom/services.
describe('loom cli — languages', () => {
it(
'status reports the store, the primary language, and what is activated',
() => {
const home = userStore()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript')
const { stdout } = loom(['status'], { cwd: dir, env: { LOOM_HOME: home } })
expect(stdout).toContain('typescript')
expect(stdout).toContain(join(home, 'services'))
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'add records the language and skips install when the service is present',
() => {
const home = userStore()
const dir = workspace('corpus: corpus\nlanguages: {}\nprimary: typescript\n')
installFake(home, 'typescript')
const { status, stdout } = loom(['add', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home },
})
expect(status).toBe(0)
expect(stdout).toContain('activated typescript')
expect(configText(dir)).toContain('typescript')
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'remove drops the language and deletes its package from the store',
() => {
const home = userStore()
const dir = workspace(
'corpus: corpus\nlanguages:\n typescript: {}\n python: {}\nprimary: python\n',
)
installFake(home, 'typescript')
loom(['remove', 'typescript'], { cwd: dir, env: { LOOM_HOME: home } })
const text = configText(dir)
expect(text).toContain('python')
expect(text).not.toContain('typescript')
expect(
existsSync(join(home, 'services', 'node_modules', '@athrio', 'loom-service-typescript')),
).toBe(false)
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'status uses the workspace .loom/services store when LOOM_HOME is unset',
() => {
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
mkdirSync(
join(dir, '.loom', 'services', 'node_modules', '@athrio', 'loom-service-typescript'),
{ recursive: true },
)
const { stdout } = loom(['status'], { cwd: dir })
expect(stdout).toContain(join(dir, '.loom', 'services'))
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Changing one value of the configuration
loom set and loom add each change one value of the configuration, and the rest of the file must come through as it was. The configuration in these cases opens with a comment and holds a key no Loom knows, future, both of which a decoded and rewritten file would lose. After set the file is the original with the one version changed and nothing else. After add the comment and the unknown key are still there beside the new language.
const handWritten = lines(
'# written by hand',
'corpus: corpus',
'future: kept',
'primary: typescript',
'variables:',
' version: 0.0.1',
)
describe('loom cli — changing one value', () => {
it(
'set changes the one variable and keeps the rest of the file as written',
() => {
const dir = workspace(handWritten)
mkdirSync(join(dir, 'corpus'))
const { status } = loom(['set', 'version', '0.0.2'], { cwd: dir })
expect(status).toBe(0)
expect(configText(dir)).toBe(handWritten.replace('0.0.1', '0.0.2'))
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'add records the language and keeps a key it does not know',
() => {
const home = userStore()
const dir = workspace(handWritten)
installFake(home, 'typescript')
loom(['add', 'typescript'], { cwd: dir, env: { LOOM_HOME: home } })
const text = configText(dir)
expect(text).toContain('# written by hand')
expect(text).toContain('future: kept')
expect(text).toContain('typescript: {}')
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Keeping a service current
loom add and loom update both bring a service to this Loom's version, so a case must see what the package manager is asked to do without the registry behind it. fakeNpm makes a folder holding a script named npm, and a case puts that folder first on PATH. The script records each call it receives in calls.log, then writes the package it was asked for at the version it was asked for, where a real install would put it.
Turning a language on installs its service. A service an older release left in the store is replaced, and loom add names both versions. A current service is left alone, and the package manager is never called. A service the store lacks is installed.
A language that is on already is a different case. loom add changes nothing for it and names loom update, even when the store holds its service at an older version. loom update with the language's name replaces that service, and loom update --all brings every active language's service up to date, installing the ones the store lacks. loom update refuses a language that is not on, pointing to loom add, and refuses to run with neither languages nor --all, pointing to loom upgrade for a reader who meant Loom itself. loom status marks a stale service with the version the store holds and the loom update that replaces it.
const fakeNpm = (): { readonly bin: string; readonly calls: string; readonly path: string } => {
const bin = realpathSync(mkdtempSync(join(tmpdir(), 'loom-npm-')))
const calls = join(bin, 'calls.log')
writeFileSync(
join(bin, 'npm'),
`${[
'#!/bin/sh',
`echo "$*" >> '${calls}'`,
'spec="$2"',
'prefix=""',
'while [ "$#" -gt 0 ]; do',
' if [ "$1" = "--prefix" ]; then prefix="$2"; fi',
' shift',
'done',
'name="${spec%@*}"',
'version="${spec##*@}"',
'mkdir -p "$prefix/node_modules/$name"',
`printf '{"name":"%s","version":"%s"}\\n' "$name" "$version" > "$prefix/node_modules/$name/package.json"`,
].join('\n')}\n`,
{ mode: 0o755 },
)
return { bin, calls, path: `${bin}:${process.env.PATH ?? ''}` }
}
describe('loom cli — keeping a service current', () => {
it(
'add turns a language on and replaces a service an older release left in the store',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages: {}\nprimary: typescript\n')
installFake(home, 'typescript', '0.0.1')
const { status, stdout } = loom(['add', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain(
`updated @athrio/loom-service-typescript from 0.0.1 to ${loomVersion}`,
)
expect(readFileSync(npm.calls, 'utf8')).toContain(
`@athrio/loom-service-typescript@${loomVersion}`,
)
expect(storedVersion(home, 'typescript')).toBe(loomVersion)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'add leaves a current service alone and never calls the package manager',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages: {}\nprimary: typescript\n')
installFake(home, 'typescript')
const { status, stdout } = loom(['add', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain('is already in the store')
expect(existsSync(npm.calls)).toBe(false)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'add installs a service the store lacks',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages: {}\nprimary: typescript\n')
const { status, stdout } = loom(['add', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain(`installed @athrio/loom-service-typescript ${loomVersion}`)
expect(storedVersion(home, 'typescript')).toBe(loomVersion)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'add changes nothing for a language that is on, and names update for its stale service',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript', '0.0.1')
const { status, stdout } = loom(['add', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain('typescript is already activated')
expect(stdout).toContain('loom update typescript')
expect(existsSync(npm.calls)).toBe(false)
expect(storedVersion(home, 'typescript')).toBe('0.0.1')
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'update replaces the stale service of a language that is on',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript', '0.0.1')
const { status, stdout } = loom(['update', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain(
`updated @athrio/loom-service-typescript from 0.0.1 to ${loomVersion}`,
)
expect(storedVersion(home, 'typescript')).toBe(loomVersion)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'update --all replaces a stale service and installs a missing one',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace(
'corpus: corpus\nlanguages:\n typescript: {}\n python: {}\nprimary: typescript\n',
)
installFake(home, 'typescript', '0.0.1')
const { status, stdout } = loom(['update', '--all'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(0)
expect(stdout).toContain(
`updated @athrio/loom-service-typescript from 0.0.1 to ${loomVersion}`,
)
expect(stdout).toContain(`installed @athrio/loom-service-python ${loomVersion}`)
expect(storedVersion(home, 'typescript')).toBe(loomVersion)
expect(storedVersion(home, 'python')).toBe(loomVersion)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'update refuses a language that is not on and points to add',
() => {
const home = userStore()
const npm = fakeNpm()
const dir = workspace('corpus: corpus\nlanguages: {}\nprimary: typescript\n')
const { status, stderr } = loom(['update', 'typescript'], {
cwd: dir,
env: { LOOM_HOME: home, PATH: npm.path },
})
expect(status).toBe(1)
expect(stderr).toContain('loom add typescript')
expect(existsSync(npm.calls)).toBe(false)
rmSync(home, { recursive: true, force: true })
rmSync(npm.bin, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'update with neither languages nor --all prints its usage',
() => {
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
const { status, stderr } = loom(['update'], { cwd: dir })
expect(status).toBe(1)
expect(stderr).toContain('usage: loom update')
expect(stderr).toContain('loom upgrade')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'status marks a service an older release installed',
() => {
const home = userStore()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript', '0.0.1')
const { stdout } = loom(['status'], { cwd: dir, env: { LOOM_HOME: home } })
expect(stdout).toContain('0.0.1 is installed')
expect(stdout).toContain('loom update typescript')
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Upgrading Loom from a package manager
The tests run the CLI from its source, the way the npm package runs it, and a package manager owns the files of an npm install. So loom upgrade here must leave every file alone and name the package manager's command instead, without a request to GitHub. The source sits in no .bun or pnpm folder, so the command it names is npm's. The case points LOOM_RELEASES at an address where nothing answers, so a request to it would fail the command and the case with it. The standalone executable replaces itself, and the smoke test of the executable covers that path.
describe('loom cli — upgrade', () => {
it(
'names the package manager\'s command when Loom came from npm',
() => {
const { status, stdout } = loom(['upgrade'], {
env: { LOOM_RELEASES: 'http://127.0.0.1:9/unreachable' },
})
expect(status).toBe(0)
expect(stdout).toContain('npm install -g @athrio/loom@latest')
},
SPAWN_TIMEOUT,
)
})A check-up from the command line
loom doctor reads PATH, so each case hands it a PATH of its own, built from folders laid out the way each installer lays them out. machine makes a temporary folder to hold such installs. program writes a file a shell may run. linkedLoom installs a fake loom the way an installer does. It writes the program where the installer keeps its copy, links a loom to it from a folder of commands, and returns that folder for the case to put on PATH.
const machine = (): string => realpathSync(mkdtempSync(join(tmpdir(), 'loom-machine-')))
const program = (file: string): void => {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, lines('#!/bin/sh'), { mode: 0o755 })
}
const linkedLoom = (root: string, folder: string, copy: string): string => {
program(join(root, copy))
mkdirSync(join(root, folder), { recursive: true })
symlinkSync(join(root, copy), join(root, folder, 'loom'))
return join(root, folder)
}The first case puts four other looms on PATH, none of them this one. Homebrew's copy sits under its Cellar. Bun's sits in a .bun folder. A project's sits in the project's node_modules, beside a package.json and Bun's lockfile. The last is a lone file that no installer owns. The doctor reports that a terminal runs Homebrew's copy instead of this Loom, and names the command that removes each copy. Homebrew and Bun each get their own uninstall, the project gets Bun's removal run in its folder, and the lone file gets rm.
The tests run the CLI from its source, which Loom takes for an npm install, as loom upgrade does. So in the second case a loom that npm installed reads as this one. That loom is the only one on PATH, and the workspace's store holds its service at this Loom's version, so the doctor finds no problem and exits zero. The third case changes one thing, a service an older release left in the store. The doctor names the version it found and loom update, counts one problem, and exits non-zero.
describe('loom cli — doctor', () => {
it(
'names each other loom on PATH with the command that removes it',
() => {
const root = machine()
const homebrew = linkedLoom(root, 'homebrew/bin', 'homebrew/Cellar/loom/0.0.1/bin/loom')
const bun = linkedLoom(
root,
'.bun/bin',
'.bun/install/global/node_modules/@athrio/loom/dist/main.js',
)
const project = join(root, 'project')
const projectBin = linkedLoom(
root,
'project/node_modules/.bin',
'project/node_modules/@athrio/loom/dist/main.js',
)
writeFileSync(join(project, 'package.json'), '{}\n')
writeFileSync(join(project, 'bun.lock'), '')
const lone = join(root, 'local', 'bin')
program(join(lone, 'loom'))
const { status, stdout } = loom(['doctor'], {
cwd: root,
env: { PATH: [homebrew, bun, projectBin, lone].join(':') },
})
expect(status).toBe(1)
expect(stdout).toContain(
`a terminal runs ${join(homebrew, 'loom')} instead, installed by Homebrew`,
)
expect(stdout).toContain('brew uninstall loom')
expect(stdout).toContain('bun remove -g @athrio/loom')
expect(stdout).toContain(`cd "${project}" && bun remove @athrio/loom`)
expect(stdout).toContain(`rm "${join(lone, 'loom')}"`)
rmSync(root, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'reads a loom npm installed as this one, and passes a workspace in order',
() => {
const root = machine()
const npm = linkedLoom(root, 'npm/bin', 'npm/lib/node_modules/@athrio/loom/dist/main.js')
const home = userStore()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript')
const { status, stdout } = loom(['doctor'], { cwd: dir, env: { PATH: npm, LOOM_HOME: home } })
expect(status).toBe(0)
expect(stdout).toContain('a terminal runs this loom')
expect(stdout).toContain('no problems found')
rmSync(root, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'reports a stale service with the update that fixes it',
() => {
const root = machine()
const npm = linkedLoom(root, 'npm/bin', 'npm/lib/node_modules/@athrio/loom/dist/main.js')
const home = userStore()
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
installFake(home, 'typescript', '0.0.1')
const { status, stdout } = loom(['doctor'], { cwd: dir, env: { PATH: npm, LOOM_HOME: home } })
expect(status).toBe(1)
expect(stdout).toContain(
`the store holds typescript's service at 0.0.1, and this Loom needs ${loomVersion}`,
)
expect(stdout).toContain('loom update typescript')
expect(stdout).toContain('1 problem found')
rmSync(root, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Explicit blocks
An explicit arrow, ~>, holds its code until a tilde closes it, so a file whose own lines look like Loom's marks tangles whole: a Markdown file with its headings, a script with its comments. These cases run the real tangle over a small book in a workspace, the way a project uses it. explicitWorkspace sets one up, a book chapter and the chapter under test in its corpus.
The first case tangles a Markdown file whose headings, fence and comment all stay inside the block, while the prose after the block is prose again. The second leaves a block open, which Loom refuses rather than swallowing the rest of the file, and writes nothing. The third makes sure a plain block is untouched by any of this: a line that starts with a tilde is still code there.
const explicitWorkspace = (chapter: string): string => {
const dir = workspace('corpus: corpus\nlanguages:\n typescript: {}\nprimary: typescript\n')
mkdirSync(join(dir, 'corpus'), { recursive: true })
writeFileSync(
join(dir, 'corpus', 'book.loom'),
lines('---', 'Part I: Probe', '---', '', '# Probe'),
)
writeFileSync(join(dir, 'corpus', '01-chapter.loom'), chapter)
return dir
}
describe('loom cli — explicit blocks', () => {
it(
'tangles a Markdown file whose headings and fences stay inside the block',
() => {
const dir = explicitWorkspace(
lines(
'---',
'Part I, Chapter 1: A skill',
'Package: ./',
'Language: Markdown',
'---',
'',
'# A skill',
'',
'## The rule',
'',
'=>',
'',
'Write for the reader.',
'',
'## The skill {Tangle}[SKILL.md]',
'',
'~>',
'',
'---',
'name: probe',
'---',
'',
'# Probe',
'',
'## The rule',
'',
anchor('The rule'),
'',
'```bash',
'# a shell comment',
'ls ~/bin',
'```',
'',
'~',
'',
'## After the block',
'',
'Prose that follows the closed block is prose again.',
),
)
const { status, stderr } = loom(['tangle', 'corpus'], { cwd: dir })
expect(stderr).not.toContain('refusing')
expect(status).toBe(0)
const written = readFileSync(join(dir, 'SKILL.md'), 'utf8')
expect(written).toContain('name: probe')
expect(written).toContain('# Probe')
expect(written).toContain('## The rule')
expect(written).toContain('Write for the reader.')
expect(written).toContain('# a shell comment')
expect(written).toContain('ls ~/bin')
expect(written).not.toContain('Prose that follows')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'refuses an explicit block no tilde closes, and writes nothing',
() => {
const dir = explicitWorkspace(
lines(
'---',
'Part I, Chapter 1: Open',
'Package: ./',
'Language: Markdown',
'---',
'',
'# Open',
'',
'## The file {Tangle}[OPEN.md]',
'',
'~>',
'',
'# swallowed heading',
),
)
const { status, stderr } = loom(['tangle', 'corpus'], { cwd: dir })
expect(status).not.toBe(0)
expect(stderr).toContain('Unclosed explicit block')
expect(existsSync(join(dir, 'OPEN.md'))).toBe(false)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'keeps a line that starts with a tilde as code in a plain block',
() => {
const dir = explicitWorkspace(
lines(
'---',
'Part I, Chapter 1: Paths',
'Package: ./',
'Language: TypeScript',
'---',
'',
'# Paths',
'',
'## The paths {Tangle}[paths.ts]',
'',
'=>',
'',
'export const home =',
'~/bin',
'export const done = true',
),
)
const { status } = loom(['tangle', 'corpus'], { cwd: dir })
expect(status).toBe(0)
const written = readFileSync(join(dir, 'paths.ts'), 'utf8')
expect(written).toContain('~/bin')
expect(written).toContain('export const done = true')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Orphans past a broken fixture
loom orphans tangles every loom the project holds before it lists anything, so a loom that should stand outside the project is the one it trips on. A fixture broken on purpose is the plainest case. These cases build a project with a real orphan and a broken fixture beside it.
projectWith makes a workspace with projectConfig, a configuration that names a corpus and a primary language, and writes each loom it is given at its path. fileLoom gives a loom whose one section tangles a single file to the project's root. brokenLoom anchors a section that does not exist, so any tangle that reaches it refuses. lockOf reads back the paths the lock records.
orphanedProject sets the scene the first cases share. Tangling the whole workspace with loom tangle . writes one.ts and two.ts and passes the broken fixture by. Then the loom that wrote two.ts is deleted, which leaves two.ts on disk with nothing behind it. orphans lists exactly that file. --prune deletes it, and leaves the lock holding what the project still produces. When a loom the project holds is broken, orphans refuses and prints no path, so a pipe into rm deletes nothing.
const projectConfig = 'corpus: corpus\nprimary: typescript\n'
const projectWith = (looms: Record<string, string>, config: string = projectConfig): string => {
const dir = workspace(config)
Object.entries(looms).forEach(([path, text]) => {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), text)
})
return dir
}
const fileLoom = (file: string): string =>
lines(`# Writing ${file} {Tangle}[${file}]`, '', '=>', '', `export const file = '${file}'`)
const brokenLoom = lines('# A broken file {Tangle}[broken.ts]', '', '=>', '', anchor('Nowhere'))
const lockOf = (dir: string): ReadonlyArray<string> =>
JSON.parse(readFileSync(join(dir, '.loom', 'loom.lock'), 'utf8')).tangled
const orphanedProject = (): string => {
const dir = projectWith({
'corpus/one.loom': fileLoom('one.ts'),
'corpus/two.loom': fileLoom('two.ts'),
'test/broken.fixture.loom': brokenLoom,
})
const { status, stderr } = loom(['tangle', '.'], { cwd: dir })
expect(stderr).not.toContain('refusing')
expect(status).toBe(0)
expect(lockOf(dir)).toEqual(['one.ts', 'two.ts'])
rmSync(join(dir, 'corpus', 'two.loom'))
return dir
}
describe('loom cli — orphans', () => {
it(
'lists the file whose loom is gone, past a broken fixture',
() => {
const dir = orphanedProject()
const { status, stdout, stderr } = loom(['orphans'], { cwd: dir })
expect(stderr).not.toContain('refusing')
expect(status).toBe(0)
expect(stdout).toBe(`${join(dir, 'two.ts')}\n`)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'prunes that file and rewrites the lock to what the project produces',
() => {
const dir = orphanedProject()
const { status } = loom(['orphans', '--prune'], { cwd: dir })
expect(status).toBe(0)
expect(existsSync(join(dir, 'two.ts'))).toBe(false)
expect(existsSync(join(dir, 'one.ts'))).toBe(true)
expect(lockOf(dir)).toEqual(['one.ts'])
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'refuses, and lists nothing, when a loom the project holds is broken',
() => {
const dir = orphanedProject()
writeFileSync(join(dir, 'corpus', 'broken.loom'), brokenLoom)
const { status, stdout, stderr } = loom(['orphans'], { cwd: dir })
expect(status).not.toBe(0)
expect(stdout).toBe('')
expect(stderr).toContain('refusing')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Every rule that leaves a loom out
Each remaining rule gets a project of its own: a corpus with one loom that tangles cleanly, and a broken loom placed where the rule under test should leave it out. orphans exits cleanly only if that rule held, since any broken loom it reached would make it refuse. cleanOrphans runs it and checks exactly that.
Git's rules need a real repository. git runs a Git command in a folder, configured so that a person's own Git settings, such as signing every commit, cannot get in the way. The first case ignores one folder through a .gitignore and another through the repository's info/exclude. The second makes another checkout with git worktree and runs orphans inside it. That checkout shares the main checkout's info/exclude and keeps none of its own.
The configuration's own patterns come next. One case lists a folder under ignore:. Another brings fixtures back with a ! pattern, after which orphans reaches the broken fixture and refuses it. Then comes a nested project, a folder holding its own .loom, whose looms belong to it rather than to the project around it. Last comes weaving: the woven book leaves out a chapter the project ignores, since weave discovers its corpus by the same rule.
const cleanOrphans = (dir: string): void => {
const { status, stderr } = loom(['orphans'], { cwd: dir })
expect(stderr).not.toContain('refusing')
expect(status).toBe(0)
}
const git = (dir: string, ...args: ReadonlyArray<string>) =>
spawnSync(
'git',
[
'-c',
'user.name=Loom',
'-c',
'[email protected]',
'-c',
'commit.gpgsign=false',
'-c',
'core.hooksPath=/dev/null',
...args,
],
{ cwd: dir, encoding: 'utf8' },
)
describe('loom cli — the looms a project leaves out', () => {
it(
'leaves out what Git ignores, through .gitignore and info/exclude',
() => {
const dir = projectWith({
'corpus/one.loom': fileLoom('one.ts'),
'scratch/broken.loom': brokenLoom,
'checkouts/copy/broken.loom': brokenLoom,
})
git(dir, 'init', '-q')
writeFileSync(join(dir, '.gitignore'), 'scratch/\n')
mkdirSync(join(dir, '.git', 'info'), { recursive: true })
writeFileSync(join(dir, '.git', 'info', 'exclude'), 'checkouts/\n')
cleanOrphans(dir)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'reads the info/exclude a worktree shares with its main checkout',
() => {
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'loom-repo-')))
git(repo, 'init', '-q')
git(repo, 'commit', '-q', '--allow-empty', '-m', 'start')
mkdirSync(join(repo, '.git', 'info'), { recursive: true })
writeFileSync(join(repo, '.git', 'info', 'exclude'), 'scratch/\n')
const checkout = `${repo}-checkout`
git(repo, 'worktree', 'add', '-q', '--detach', checkout)
mkdirSync(join(checkout, '.loom'), { recursive: true })
writeFileSync(join(checkout, '.loom', 'config.yaml'), projectConfig)
mkdirSync(join(checkout, 'corpus'), { recursive: true })
writeFileSync(join(checkout, 'corpus', 'one.loom'), fileLoom('one.ts'))
mkdirSync(join(checkout, 'scratch'), { recursive: true })
writeFileSync(join(checkout, 'scratch', 'broken.loom'), brokenLoom)
cleanOrphans(checkout)
rmSync(checkout, { recursive: true, force: true })
rmSync(repo, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'leaves out a folder the configuration lists under ignore',
() => {
const dir = projectWith(
{
'corpus/one.loom': fileLoom('one.ts'),
'drafts/broken.loom': brokenLoom,
},
`${projectConfig}ignore:\n - drafts/\n`,
)
cleanOrphans(dir)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'brings fixtures back with a ! pattern, and then refuses a broken one',
() => {
const dir = projectWith(
{
'corpus/one.loom': fileLoom('one.ts'),
'test/broken.fixture.loom': brokenLoom,
},
`${projectConfig}ignore:\n - '!*.fixture.loom'\n`,
)
const { status, stderr } = loom(['orphans'], { cwd: dir })
expect(status).not.toBe(0)
expect(stderr).toContain('broken.fixture.loom')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'leaves a nested project to itself',
() => {
const dir = projectWith({
'corpus/one.loom': fileLoom('one.ts'),
'inner/.loom/config.yaml': projectConfig,
'inner/broken.loom': brokenLoom,
})
cleanOrphans(dir)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'weaves a book without the chapters the project leaves out',
() => {
const dir = projectWith({
'corpus/book.loom': lines('---', 'Part I: Probe', '---', '', '# Probe'),
'corpus/01-kept.loom': lines(
'---',
'Part I, Chapter 1: A kept chapter',
'Language: Prose',
'---',
'',
'# A kept chapter',
'',
'Kept.',
),
'corpus/02-draft.fixture.loom': lines(
'---',
'Part I, Chapter 2: A left-out chapter',
'Language: Prose',
'---',
'',
'# A left-out chapter',
'',
'Left out.',
),
})
const { status } = loom(['weave', 'corpus/book.loom', 'woven.json'], { cwd: dir })
expect(status).toBe(0)
const woven = readFileSync(join(dir, 'woven.json'), 'utf8')
expect(woven).toContain('A kept chapter')
expect(woven).not.toContain('A left-out chapter')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})A loom named directly
A loom the project leaves out still tangles when a person names it, since naming a loom is asking for it. Its files are not the project's, though, so the lock does not take them in, and orphans neither lists them nor prunes them.
describe('loom cli — an ignored loom named directly', () => {
it(
'tangles it, and keeps its files out of the lock',
() => {
const dir = projectWith({
'corpus/one.loom': fileLoom('one.ts'),
'test/sample.fixture.loom': fileLoom('sample.ts'),
})
loom(['tangle', 'corpus'], { cwd: dir })
const { status } = loom(['tangle', 'test/sample.fixture.loom'], { cwd: dir })
expect(status).toBe(0)
expect(readFileSync(join(dir, 'test', 'sample.ts'), 'utf8')).toContain("'sample.ts'")
expect(lockOf(dir)).toEqual(['one.ts'])
expect(loom(['orphans'], { cwd: dir }).stdout).toBe('')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Formatting looms
loom format works on a workspace that activates no language, so the cases need no store and format the prose alone. A loom whose paragraph runs over two lines is the fixture. With --check the command names that loom, exits non-zero, and leaves the file as it was. Without it the command joins the paragraph onto one line and reports the loom it rewrote, and a second run finds nothing left to do.
describe('loom cli — format', () => {
const wrapped = lines('# Notes', '', 'A paragraph that', 'runs over two lines.')
it(
'names a loom that would change, and leaves it as it was',
() => {
const dir = workspace('corpus: corpus\n')
mkdirSync(join(dir, 'corpus'), { recursive: true })
writeFileSync(join(dir, 'corpus', 'notes.loom'), wrapped)
const { status, stdout } = loom(['format', '--check', 'corpus'], { cwd: dir })
expect(status).toBe(1)
expect(stdout).toContain('would format')
expect(readFileSync(join(dir, 'corpus', 'notes.loom'), 'utf8')).toBe(wrapped)
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'joins the paragraph, and finds nothing to do the second time',
() => {
const dir = workspace('corpus: corpus\n')
mkdirSync(join(dir, 'corpus'), { recursive: true })
writeFileSync(join(dir, 'corpus', 'notes.loom'), wrapped)
const first = loom(['format', 'corpus'], { cwd: dir })
expect(first.status).toBe(0)
expect(first.stdout).toContain('formatted')
expect(readFileSync(join(dir, 'corpus', 'notes.loom'), 'utf8')).toBe(
lines('# Notes', '', 'A paragraph that runs over two lines.'),
)
expect(loom(['format', 'corpus'], { cwd: dir }).stdout).toContain('every loom is formatted')
rmSync(dir, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Setting up Helix
loom helix writes into Helix's configuration folder, which it finds through XDG_CONFIG_HOME, or APPDATA on Windows. A case points both at a temporary folder, so the command writes there and never into the configuration of the person running the tests. helixHome makes that folder with a languages.toml already in it, holding the lines the case starts from.
The first case runs the command twice over a file that already configures another language. The other language survives, Loom appears exactly once, Loom's entry soft-wraps at the width Loom formats to, and the compiled grammar and the queries sit in the runtime folder. The compile needs a C compiler, which every machine that builds Loom already has. The second case starts from a file that sets Loom up by hand: the command refuses, says why, and leaves the file as it was.
const helixHome = (
...rows: ReadonlyArray<string>
): { readonly config: string; readonly helix: string; readonly env: Record<string, string> } => {
const config = realpathSync(mkdtempSync(join(tmpdir(), 'loom-helix-')))
const helix = join(config, 'helix')
mkdirSync(helix, { recursive: true })
writeFileSync(join(helix, 'languages.toml'), lines(...rows))
return { config, helix, env: { XDG_CONFIG_HOME: config, APPDATA: config } }
}
describe('loom cli — helix', () => {
it(
'builds the grammar, copies the queries, and adds Loom to languages.toml once',
() => {
const { config, helix, env } = helixHome('[[language]]', 'name = "rust"')
expect(loom(['helix'], { env }).status).toBe(0)
expect(loom(['helix'], { env }).status).toBe(0)
const languages = readFileSync(join(helix, 'languages.toml'), 'utf8')
expect(languages).toContain('name = "rust"')
expect(languages.match(/name = "loom"/g)?.length).toBe(1)
expect(languages).toContain('soft-wrap = { enable = true, wrap-at-text-width = true }')
expect(languages).toContain('text-width = 100')
const library = process.platform === 'win32' ? 'loom.dll' : 'loom.so'
expect(existsSync(join(helix, 'runtime', 'grammars', library))).toBe(true)
expect(existsSync(join(helix, 'runtime', 'queries', 'loom', 'highlights.scm'))).toBe(true)
rmSync(config, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
'leaves a languages.toml that sets Loom up by hand as it was',
() => {
const { config, helix, env } = helixHome('[[language]]', 'name = "loom"')
const { status, stderr } = loom(['helix'], { env })
expect(status).toBe(1)
expect(stderr).toContain('already sets up Loom')
expect(readFileSync(join(helix, 'languages.toml'), 'utf8')).toBe(
lines('[[language]]', 'name = "loom"'),
)
rmSync(config, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})Setting up Neovim
loom nvim writes into the site folder of Neovim's data folder, which it finds through XDG_DATA_HOME, or LOCALAPPDATA on Windows, and names after NVIM_APPNAME. A case points both folders at a temporary one and fixes the name, so the command writes there and never into the setup of the person running the tests. nvimHome makes that folder and says where its site folder is.
The first case runs the command twice. The parser and the queries sit in the site folder, and the plugin is the one loom nvim writes, turning the language server on and the two auto-wrap options off. The second case starts from a plugin/loom.lua of the person's own: the command refuses, says why, and leaves the file as it was.
const nvimHome = (): {
readonly data: string
readonly site: string
readonly env: Record<string, string>
} => {
const data = realpathSync(mkdtempSync(join(tmpdir(), 'loom-nvim-')))
const site = join(data, process.platform === 'win32' ? 'nvim-data' : 'nvim', 'site')
return { data, site, env: { XDG_DATA_HOME: data, LOCALAPPDATA: data, NVIM_APPNAME: 'nvim' } }
}
describe('loom cli — nvim', () => {
it(
'builds the parser, copies the queries, and writes the plugin, run after run',
() => {
const { data, site, env } = nvimHome()
expect(loom(['nvim'], { env }).status).toBe(0)
expect(loom(['nvim'], { env }).status).toBe(0)
expect(existsSync(join(site, 'parser', 'loom.so'))).toBe(true)
expect(existsSync(join(site, 'queries', 'loom', 'highlights.scm'))).toBe(true)
const plugin = readFileSync(join(site, 'plugin', 'loom.lua'), 'utf8')
expect(plugin).toStartWith('-- Loom: written by `loom nvim`')
expect(plugin).toContain("vim.lsp.enable('loom')")
expect(plugin).toContain("vim.opt_local.formatoptions:remove({ 't', 'c' })")
rmSync(data, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
it(
"leaves a plugin/loom.lua of the person's own as it was",
() => {
const { data, site, env } = nvimHome()
mkdirSync(join(site, 'plugin'), { recursive: true })
writeFileSync(join(site, 'plugin', 'loom.lua'), lines("print('mine')"))
const { status, stderr } = loom(['nvim'], { env })
expect(status).toBe(1)
expect(stderr).toContain('was not written by `loom nvim`')
expect(readFileSync(join(site, 'plugin', 'loom.lua'), 'utf8')).toBe(lines("print('mine')"))
expect(existsSync(join(site, 'parser', 'loom.so'))).toBe(false)
rmSync(data, { recursive: true, force: true })
},
SPAWN_TIMEOUT,
)
})The test file
::[Running the CLI as a process]
::[Looms written as lines]
::[A workspace to run it in]
::[Tangling, and asking for help]
::[A home folder is no workspace]
::[A project's corpus folder]
::[The width an editor wraps a loom at]
::[Languages and their store]
::[Changing one value of the configuration]
::[Keeping a service current]
::[Upgrading Loom from a package manager]
::[A check-up from the command line]
::[Explicit blocks]
::[Orphans past a broken fixture]
::[Every rule that leaves a loom out]
::[A loom named directly]
::[Formatting looms]
::[Setting up Helix]
::[Setting up Neovim]