114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
|
|
import { lstatSync, mkdirSync, mkdtempSync, readlinkSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||
|
|
import { tmpdir } from 'node:os'
|
||
|
|
import { join } from 'node:path'
|
||
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
||
|
|
import { appendToUserPath, buildDshCmd, registerDshLauncher } from '../src/register-dsh.ts'
|
||
|
|
|
||
|
|
const dirs: string[] = []
|
||
|
|
|
||
|
|
/** A fake harness dir (with or without a `dsh` launcher) and a bin dir to register into. */
|
||
|
|
function fixtures(): { harnessDir: string; binDir: string } {
|
||
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-register-'))
|
||
|
|
dirs.push(root)
|
||
|
|
return { harnessDir: join(root, 'harness'), binDir: join(root, 'bin') }
|
||
|
|
}
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('registerDshLauncher', () => {
|
||
|
|
it('symlinks the harness launcher into the bin dir when absent', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(join(harnessDir, 'bin'), { recursive: true })
|
||
|
|
writeFileSync(join(harnessDir, 'dsh'), '#!/bin/sh\n')
|
||
|
|
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
|
||
|
|
status: 'registered', path: join(binDir, 'dsh'), created: true,
|
||
|
|
})
|
||
|
|
expect(readlinkSync(join(binDir, 'dsh'))).toBe(join(harnessDir, 'dsh'))
|
||
|
|
})
|
||
|
|
|
||
|
|
it('is idempotent: confirms an existing link that already points at the launcher', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(harnessDir, { recursive: true })
|
||
|
|
mkdirSync(binDir, { recursive: true })
|
||
|
|
symlinkSync(join(harnessDir, 'dsh'), join(binDir, 'dsh'))
|
||
|
|
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
|
||
|
|
status: 'registered', path: join(binDir, 'dsh'), created: false,
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('falls back to a user-writable dir when the primary is unwritable', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(harnessDir, { recursive: true })
|
||
|
|
writeFileSync(join(harnessDir, 'dsh'), '#!/bin/sh\n')
|
||
|
|
// A file occupies the primary's parent path, so creating the link there
|
||
|
|
// fails (ENOTDIR) and the fallback dir is used instead — mirroring a
|
||
|
|
// root-owned, non-writable /usr/local/bin.
|
||
|
|
mkdirSync(binDir, { recursive: true })
|
||
|
|
const fileBlocker = join(binDir, 'blocker')
|
||
|
|
writeFileSync(fileBlocker, '')
|
||
|
|
const result = registerDshLauncher(harnessDir, join(fileBlocker, 'nested'), join(binDir, 'fallback'), false)
|
||
|
|
if (result.status !== 'registered') {
|
||
|
|
throw new Error(`expected a registered link, got ${JSON.stringify(result)}`)
|
||
|
|
}
|
||
|
|
expect(result.created).toBe(true)
|
||
|
|
expect(readlinkSync(join(binDir, 'fallback', 'dsh'))).toBe(join(harnessDir, 'dsh'))
|
||
|
|
})
|
||
|
|
|
||
|
|
it('leaves an existing non-dsh entry untouched', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(harnessDir, { recursive: true })
|
||
|
|
mkdirSync(binDir, { recursive: true })
|
||
|
|
writeFileSync(join(binDir, 'dsh'), 'a real file, not ours\n')
|
||
|
|
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
|
||
|
|
status: 'already-present', path: join(binDir, 'dsh'),
|
||
|
|
})
|
||
|
|
// The file is not replaced by a symlink and its content is untouched.
|
||
|
|
expect(lstatSync(join(binDir, 'dsh')).isSymbolicLink()).toBe(false)
|
||
|
|
expect(readFileSync(join(binDir, 'dsh'), 'utf8')).toBe('a real file, not ours\n')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('leaves a symlink pointing elsewhere untouched', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(harnessDir, { recursive: true })
|
||
|
|
mkdirSync(binDir, { recursive: true })
|
||
|
|
symlinkSync('/elsewhere/dsh', join(binDir, 'dsh'))
|
||
|
|
expect(registerDshLauncher(harnessDir, binDir)).toEqual({
|
||
|
|
status: 'already-present', path: join(binDir, 'dsh'),
|
||
|
|
})
|
||
|
|
expect(readlinkSync(join(binDir, 'dsh'))).toBe('/elsewhere/dsh')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('skips when the harness has no launcher (a development checkout)', () => {
|
||
|
|
const { harnessDir, binDir } = fixtures()
|
||
|
|
mkdirSync(harnessDir, { recursive: true })
|
||
|
|
expect(registerDshLauncher(harnessDir, binDir)).toEqual({ status: 'skipped' })
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('buildDshCmd', () => {
|
||
|
|
it('runs the bundled node against the CLI entry with the caller arguments', () => {
|
||
|
|
expect(buildDshCmd()).toBe('@echo off\r\n"%~dp0bin\\node.exe" "%~dp0apps\\cli\\lib\\bin.js" %*\r\n')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('appendToUserPath', () => {
|
||
|
|
it('appends a missing directory', () => {
|
||
|
|
expect(appendToUserPath('C:\\a;C:\\b', 'C:\\harness'))
|
||
|
|
.toBe('C:\\a;C:\\b;C:\\harness')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('is case-insensitive and idempotent for a present directory', () => {
|
||
|
|
expect(appendToUserPath('C:\\a;C:\\Harness', 'c:\\harness')).toBe('C:\\a;C:\\Harness')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('appends to a trailing semicolon without doubling it', () => {
|
||
|
|
expect(appendToUserPath('C:\\a;', 'C:\\harness')).toBe('C:\\a;C:\\harness')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('appends to an empty PATH', () => {
|
||
|
|
expect(appendToUserPath('', 'C:\\harness')).toBe(';C:\\harness')
|
||
|
|
})
|
||
|
|
})
|