mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
chore: version packages
Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input). Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204): node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every helper, prebuilds/ included, then verifies by really opening a PTY; the blind Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already broken install on the first failed spawn. Adds the phone home screen (session overview under 430px, per-device `mobileOverviewEnabled`, default ON) and a guided Tailscale path in install.sh, plus `install.sh tailscale` to retrofit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
// Port: none (pure model + static markup assertions — no browser, no server).
|
||||
//
|
||||
// The phone home screen (src/web/public/mobile-overview.js) replaces the welcome
|
||||
// overlay under 430px. Its grouping logic is the part that can silently go wrong:
|
||||
// a session blocked on a permission prompt landing in "idle" is exactly the bug
|
||||
// this surface exists to prevent. buildMobileOverviewModel() is pure for that
|
||||
// reason, so it can be exercised here against plain objects.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function loadOverviewApp(overrides: Record<string, any> = {}) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: {},
|
||||
document: { getElementById: () => null },
|
||||
MobileDetection: { getDeviceType: () => 'mobile' },
|
||||
});
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
|
||||
filename: 'mobile-overview.js',
|
||||
});
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
||||
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
Object.assign(app, overrides);
|
||||
return app;
|
||||
}
|
||||
|
||||
const CASES = [
|
||||
{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' },
|
||||
{ name: 'beta', path: '/home/arkon/codeman-cases/beta', location: 'local' },
|
||||
{ name: 'boxed', path: '/srv/boxed', location: 'docker' },
|
||||
];
|
||||
|
||||
function session(over: Record<string, any>) {
|
||||
return { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
|
||||
}
|
||||
|
||||
describe('mobile overview model', () => {
|
||||
it('routes a session with a pending permission prompt into NEEDS YOU, not idle', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle' })],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
|
||||
expect(model.current).toHaveLength(0);
|
||||
expect(model.needsYou[0].state).toBe('needs');
|
||||
expect(model.needsYou[0].pill).toBe('needs you');
|
||||
});
|
||||
|
||||
it('ranks an action hook above an idle hook above a stale busy status', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
// An idle_prompt hook on a session the server still calls 'busy': the hook
|
||||
// is the newer signal, so it must win.
|
||||
sessions: [
|
||||
session({ id: 'busy-with-idle-hook', status: 'busy' }),
|
||||
session({ id: 'elicit', status: 'busy' }),
|
||||
session({ id: 'plain-busy', status: 'busy' }),
|
||||
],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([
|
||||
['busy-with-idle-hook', new Set(['idle_prompt'])],
|
||||
['elicit', new Set(['elicitation_dialog'])],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['elicit', 'busy-with-idle-hook']);
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['plain-busy']);
|
||||
});
|
||||
|
||||
it('buckets busy / idle / stopped / error and labels each pill', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'w', status: 'busy' }),
|
||||
session({ id: 'i', status: 'idle' }),
|
||||
session({ id: 'd', status: 'stopped' }),
|
||||
session({ id: 'e', status: 'error' }),
|
||||
],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
// Everything that is not blocked on you shares one "current" section,
|
||||
// most demanding first.
|
||||
expect(model.current.map((r: any) => [r.id, r.pill])).toEqual([
|
||||
['w', 'working'],
|
||||
['i', 'idle'],
|
||||
['d', 'done'],
|
||||
]);
|
||||
expect(model.needsYou.map((r: any) => r.pill)).toEqual(['error']);
|
||||
expect(model.sessionCount).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the user tab order as the tiebreak inside a section', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
||||
cases: CASES,
|
||||
sessionOrder: ['third', 'first', 'second'],
|
||||
});
|
||||
|
||||
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
||||
});
|
||||
|
||||
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [
|
||||
session({ id: 'sub', workingDir: '/home/arkon/default/claudeman/src/web' }),
|
||||
session({ id: 'outside', workingDir: '/tmp/scratch' }),
|
||||
],
|
||||
cases: [...CASES, { name: 'claudeman-web', path: '/home/arkon/default/claudeman/src/web' }],
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.caseName]));
|
||||
expect(rows.sub).toBe('claudeman-web');
|
||||
expect(rows.outside).toBe('');
|
||||
});
|
||||
|
||||
it('lists past conversations newest first and never repeats a live session', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'live-1' })],
|
||||
cases: CASES,
|
||||
history: [
|
||||
// Same id as the running session: the unified list includes live rows,
|
||||
// and showing one in both sections would be a duplicate.
|
||||
{ sessionId: 'live-1', workingDir: '/home/arkon/default/claudeman', lastActivityAt: 500 },
|
||||
{
|
||||
sessionId: 'old-a',
|
||||
workingDir: '/home/arkon/codeman-cases/beta',
|
||||
firstPrompt: 'fix the mobile header',
|
||||
claudeSessionId: 'claude-uuid-a',
|
||||
lastActivityAt: 100,
|
||||
},
|
||||
{
|
||||
sessionId: 'old-b',
|
||||
workingDir: '/home/arkon/default/claudeman',
|
||||
name: 'w4-claudeman',
|
||||
lastActivityAt: 400,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.past.map((r: any) => r.id)).toEqual(['old-b', 'old-a']);
|
||||
expect(model.past[1]).toMatchObject({
|
||||
title: 'fix the mobile header',
|
||||
caseName: 'beta',
|
||||
claudeSessionId: 'claude-uuid-a',
|
||||
workingDir: '/home/arkon/codeman-cases/beta',
|
||||
});
|
||||
// A row with no prompt falls back to its name, so it is never a bare UUID.
|
||||
expect(model.past[0].title).toBe('w4-claudeman');
|
||||
});
|
||||
|
||||
it('does not title a past row with the transcript reader placeholder', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [],
|
||||
cases: CASES,
|
||||
history: [
|
||||
{ sessionId: 'blank', workingDir: '/home/arkon/default/claudeman', firstPrompt: '(no content)' },
|
||||
{ sessionId: 'spaces', workingDir: '/home/arkon/codeman-cases/beta', firstPrompt: ' ' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.past.map((r: any) => r.title)).toEqual(['claudeman', 'beta']);
|
||||
});
|
||||
|
||||
it('accepts the live Map as-is and survives an empty state', () => {
|
||||
const app = loadOverviewApp();
|
||||
const fromMap = app.buildMobileOverviewModel({
|
||||
sessions: new Map([['a', session({ id: 'a' })]]),
|
||||
cases: CASES,
|
||||
});
|
||||
expect(fromMap.current.map((r: any) => r.id)).toEqual(['a']);
|
||||
|
||||
const empty = app.buildMobileOverviewModel({});
|
||||
expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 });
|
||||
});
|
||||
|
||||
it('no longer builds a spaces section', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES });
|
||||
expect(model.spaces).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview gate', () => {
|
||||
it('is phone-width only, off in solo windows, and off when explicitly disabled', () => {
|
||||
expect(loadOverviewApp().shouldUseMobileOverview()).toBe(true);
|
||||
expect(loadOverviewApp({ isSoloWindow: true }).shouldUseMobileOverview()).toBe(false);
|
||||
expect(
|
||||
loadOverviewApp({
|
||||
loadAppSettingsFromStorage: () => ({ mobileOverviewEnabled: false }),
|
||||
}).shouldUseMobileOverview()
|
||||
).toBe(false);
|
||||
// An unset value must read as ON: phones that already have saved settings
|
||||
// from before this feature existed have no key for it.
|
||||
expect(loadOverviewApp({ loadAppSettingsFromStorage: () => ({ skin: 'og' }) }).shouldUseMobileOverview()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview wiring', () => {
|
||||
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
|
||||
const moduleSrc = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
|
||||
|
||||
it('speaks the same status language as the session tabs', () => {
|
||||
// A session that is fine reads green on the tabs; anything else here would
|
||||
// mean two meanings for one color on the same screen.
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-dot--idle\s*\{\s*background:\s*var\(--green\)/);
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-dot--working\s*\{[^}]*var\(--green\)[^}]*animation:\s*pulse/);
|
||||
// Waiting-for-input blinks yellow, asked-a-question blinks red, same as
|
||||
// tab-alert-idle / tab-alert-action.
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-row--waiting\s*\{[^}]*animation:\s*mobile-overview-blink-yellow/);
|
||||
expect(mobileCss).toMatch(/\.mobile-overview-row--needs\s*\{[^}]*animation:\s*mobile-overview-blink-red/);
|
||||
expect(mobileCss).toContain('@keyframes mobile-overview-blink-red');
|
||||
expect(mobileCss).toContain('@keyframes mobile-overview-blink-yellow');
|
||||
// The alert must survive reduced-motion as a held color, not vanish.
|
||||
expect(mobileCss).toMatch(/prefers-reduced-motion[^}]*\}[\s\S]*?\.mobile-overview-row--needs/);
|
||||
});
|
||||
|
||||
it('reuses the toolbar Run button classes instead of its own palette', () => {
|
||||
// The per-backend gradient lives in styles.css keyed on
|
||||
// `.btn-toolbar.btn-run.mode-<backend>` (and light skins override exactly
|
||||
// those); carrying the same classes keeps both Run buttons identical.
|
||||
expect(moduleSrc).toContain('btn-toolbar btn-run mode-');
|
||||
expect(moduleSrc).toContain('btn-toolbar btn-run-gear mode-');
|
||||
// The two button rules (not the dropdown below them) must set no color at
|
||||
// all, or they would win over the mode gradient.
|
||||
const buttonRules = mobileCss.match(/\.mobile-overview-run(-caret)?\s*\{[^}]*\}/g) || [];
|
||||
expect(buttonRules.length).toBe(2);
|
||||
for (const rule of buttonRules) {
|
||||
expect(rule).not.toMatch(/\b(background|color)\s*:/);
|
||||
}
|
||||
});
|
||||
|
||||
it('ships the container hidden and loads the module', () => {
|
||||
expect(html).toMatch(/<div class="mobile-overview" id="mobileOverview" hidden><\/div>/);
|
||||
expect(html).toContain('<script defer src="mobile-overview.js"></script>');
|
||||
});
|
||||
|
||||
it('never gives .mobile-overview a bare display rule', () => {
|
||||
// Desktop does not load mobile.css at all, so the [hidden] attribute is the
|
||||
// only thing keeping the overview off desktop. A bare
|
||||
// `.mobile-overview { display: … }` rule would beat the UA [hidden] rule.
|
||||
const bareDisplay = /\.mobile-overview\s*\{[^}]*display\s*:/;
|
||||
expect(bareDisplay.test(mobileCss)).toBe(false);
|
||||
expect(mobileCss).toContain('.mobile-overview.visible {');
|
||||
});
|
||||
|
||||
it('styles the overview from skin tokens rather than hardcoded colors', () => {
|
||||
// Skins re-point the :root tokens, so a hex literal here is a rule that
|
||||
// silently stays dark on the four light skins.
|
||||
const rules = mobileCss.match(/\.mobile-overview[^{}]*\{[^}]*\}/g) || [];
|
||||
expect(rules.length).toBeGreaterThan(10);
|
||||
const hardcoded = rules.flatMap((rule) => rule.match(/:\s*#[0-9a-f]{3,8}\b/gi) || []);
|
||||
expect(hardcoded).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Regression tests for the node-pty spawn-helper repair (issues #6, #204).
|
||||
*
|
||||
* node-pty@1.1.0 publishes `prebuilds/darwin-<arch>/spawn-helper` with mode 0644,
|
||||
* so on macOS every PTY spawn dies with `posix_spawnp failed.`. These tests pin
|
||||
* the two things the old fix got wrong: it looked ONLY in `build/Release` (which
|
||||
* does not exist on macOS, where the prebuilt binary is used), and it never
|
||||
* checked whether the repair actually worked.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
isSpawnHelperFailure,
|
||||
listSpawnHelpers,
|
||||
repairSpawnHelperPermissions,
|
||||
spawnPtyWithHelperRepair,
|
||||
resetSpawnHelperRepairState,
|
||||
SPAWN_HELPER_FIX_HINT,
|
||||
} from '../src/utils/node-pty-repair.js';
|
||||
|
||||
/** Builds a fake node-pty tree; each entry is a directory that gets a spawn-helper. */
|
||||
function makeFakePtyDir(helpers: Array<{ dir: string; mode: number }>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-node-pty-'));
|
||||
for (const { dir, mode } of helpers) {
|
||||
const full = join(root, dir);
|
||||
mkdirSync(full, { recursive: true });
|
||||
const helper = join(full, 'spawn-helper');
|
||||
writeFileSync(helper, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(helper, mode);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function modeOf(path: string): number {
|
||||
return statSync(path).mode & 0o777;
|
||||
}
|
||||
|
||||
describe('node-pty spawn-helper repair', () => {
|
||||
const created: string[] = [];
|
||||
|
||||
beforeEach(() => resetSpawnHelperRepairState());
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(helpers: Array<{ dir: string; mode: number }>): string {
|
||||
const root = makeFakePtyDir(helpers);
|
||||
created.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe('isSpawnHelperFailure', () => {
|
||||
it('matches the native error node-pty throws on macOS', () => {
|
||||
expect(isSpawnHelperFailure(new Error('posix_spawnp failed.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches errors that name the helper directly', () => {
|
||||
expect(isSpawnHelperFailure(new Error('ENOENT: no such file, spawn-helper'))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated spawn failures', () => {
|
||||
expect(isSpawnHelperFailure(new Error('cwd does not exist'))).toBe(false);
|
||||
expect(isSpawnHelperFailure(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSpawnHelpers', () => {
|
||||
it('finds the prebuilt helper, which is the ONLY one that exists on macOS', () => {
|
||||
// A stock macOS install has no build/ directory at all: node-pty ships a
|
||||
// darwin prebuild, so node-gyp never runs.
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
expect(listSpawnHelpers(root)).toEqual([join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')]);
|
||||
});
|
||||
|
||||
it('finds helpers across build/Release, build/Debug and every prebuilds arch', () => {
|
||||
const root = fixture([
|
||||
{ dir: 'build/Release', mode: 0o755 },
|
||||
{ dir: 'build/Debug', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||
]);
|
||||
expect(listSpawnHelpers(root).sort()).toEqual(
|
||||
[
|
||||
join(root, 'build', 'Release', 'spawn-helper'),
|
||||
join(root, 'build', 'Debug', 'spawn-helper'),
|
||||
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
|
||||
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('returns nothing for a Linux install, which has no spawn-helper at all', () => {
|
||||
const root = fixture([]);
|
||||
mkdirSync(join(root, 'build', 'Release'), { recursive: true });
|
||||
writeFileSync(join(root, 'build', 'Release', 'pty.node'), 'stub');
|
||||
expect(listSpawnHelpers(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairSpawnHelperPermissions', () => {
|
||||
it('adds the execute bit to the 0644 prebuilt helper', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
const repaired = repairSpawnHelperPermissions(root);
|
||||
|
||||
expect(repaired).toEqual([helper]);
|
||||
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it('is a no-op on an already-executable helper', () => {
|
||||
const root = fixture([{ dir: 'build/Release', mode: 0o755 }]);
|
||||
expect(repairSpawnHelperPermissions(root)).toEqual([]);
|
||||
expect(modeOf(join(root, 'build', 'Release', 'spawn-helper'))).toBe(0o755);
|
||||
});
|
||||
|
||||
it('repairs every copy, not just the first one found', () => {
|
||||
const root = fixture([
|
||||
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||
]);
|
||||
expect(repairSpawnHelperPermissions(root)).toHaveLength(2);
|
||||
for (const arch of ['darwin-arm64', 'darwin-x64']) {
|
||||
expect(modeOf(join(root, 'prebuilds', arch, 'spawn-helper')) & 0o111).toBe(0o111);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the non-execute permission bits it was given', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o640 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
repairSpawnHelperPermissions(root);
|
||||
expect(modeOf(helper)).toBe(0o755 | 0o640);
|
||||
});
|
||||
|
||||
it('returns nothing when node-pty has no helper to repair', () => {
|
||||
expect(repairSpawnHelperPermissions(fixture([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnPtyWithHelperRepair', () => {
|
||||
it('passes the spawn result straight through when nothing is wrong', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o755 }]);
|
||||
expect(spawnPtyWithHelperRepair(() => 'pty', root)).toBe('pty');
|
||||
});
|
||||
|
||||
it('repairs and retries once after a posix_spawnp failure', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
let attempts = 0;
|
||||
const result = spawnPtyWithHelperRepair(() => {
|
||||
attempts++;
|
||||
// Mirror the real failure: node-pty only throws while the helper is 0644.
|
||||
if ((modeOf(helper) & 0o111) !== 0o111) throw new Error('posix_spawnp failed.');
|
||||
return 'pty';
|
||||
}, root);
|
||||
|
||||
expect(result).toBe('pty');
|
||||
expect(attempts).toBe(2);
|
||||
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it('rethrows unrelated errors untouched, without chmodding anything', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('cwd does not exist');
|
||||
}, root)
|
||||
).toThrow('cwd does not exist');
|
||||
|
||||
expect(modeOf(helper)).toBe(0o644);
|
||||
});
|
||||
|
||||
it('surfaces the manual fix command when the retry still fails', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
}, root)
|
||||
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
});
|
||||
|
||||
it('surfaces the fix command when there is no helper to repair', () => {
|
||||
const root = fixture([]);
|
||||
|
||||
expect(() =>
|
||||
spawnPtyWithHelperRepair(() => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
}, root)
|
||||
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
});
|
||||
|
||||
it('does not chmod-storm: only the first failure triggers a repair attempt', () => {
|
||||
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
const boom = () => {
|
||||
throw new Error('posix_spawnp failed.');
|
||||
};
|
||||
|
||||
expect(() => spawnPtyWithHelperRepair(boom, root)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
|
||||
// Second call: repair already attempted, so it fails fast with the hint and
|
||||
// never re-walks the tree.
|
||||
const untouched = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||
expect(() => spawnPtyWithHelperRepair(boom, untouched)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||
expect(modeOf(join(untouched, 'prebuilds', 'darwin-arm64', 'spawn-helper'))).toBe(0o644);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user