mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const electronState = vi.hoisted(() => ({
|
|
isPackaged: false,
|
|
appPath: '/workspace/clawx',
|
|
}));
|
|
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
get isPackaged() {
|
|
return electronState.isPackaged;
|
|
},
|
|
getAppPath: () => electronState.appPath,
|
|
getFileIcon: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import {
|
|
HANDLER_ID_MAX_LENGTH,
|
|
HANDLER_NAME_MAX_LENGTH,
|
|
NATIVE_PATH_MAX_LENGTH,
|
|
PROCESS_MAX_BUFFER_BYTES,
|
|
createAttachmentOpenWithService,
|
|
type AttachmentOpenWithDependencies,
|
|
} from '@electron/services/attachment-open-with';
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
// Native smoke tests include cold PowerShell startup, Add-Type compilation,
|
|
// and Windows Defender scanning on a fresh CI runner. Production still keeps
|
|
// its separate five-second process bound, covered by mocked service tests.
|
|
const NATIVE_SMOKE_TIMEOUT_MS = 30_000;
|
|
|
|
function hasControlCharacters(value: string): boolean {
|
|
return [...value].some((character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
});
|
|
}
|
|
|
|
type WindowsNativeHandlerRecord = {
|
|
nativeId: string;
|
|
name: string;
|
|
applicationPath?: string;
|
|
iconSourcePath?: string;
|
|
isDefault: boolean;
|
|
};
|
|
|
|
function isOptionalNativePath(value: unknown): value is string | undefined {
|
|
return value === undefined || (
|
|
typeof value === 'string'
|
|
&& value.length > 0
|
|
&& value.length <= NATIVE_PATH_MAX_LENGTH
|
|
&& !hasControlCharacters(value)
|
|
);
|
|
}
|
|
|
|
function isWindowsNativeHandlerRecord(value: unknown): value is WindowsNativeHandlerRecord {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
return typeof record.nativeId === 'string'
|
|
&& record.nativeId.length > 0
|
|
&& record.nativeId.length <= HANDLER_ID_MAX_LENGTH
|
|
&& !hasControlCharacters(record.nativeId)
|
|
&& typeof record.name === 'string'
|
|
&& record.name.length > 0
|
|
&& record.name.length <= HANDLER_NAME_MAX_LENGTH
|
|
&& !hasControlCharacters(record.name)
|
|
&& typeof record.isDefault === 'boolean'
|
|
&& isOptionalNativePath(record.applicationPath)
|
|
&& isOptionalNativePath(record.iconSourcePath);
|
|
}
|
|
|
|
async function temporaryTextFile(): Promise<string> {
|
|
const directory = await mkdtemp(join(tmpdir(), 'clawx-open-with-'));
|
|
temporaryDirectories.push(directory);
|
|
const filePath = join(directory, 'native smoke.txt');
|
|
await writeFile(filePath, 'attachment open-with native smoke\n', 'utf8');
|
|
return filePath;
|
|
}
|
|
|
|
function sourceHelperPath(): string {
|
|
return join(import.meta.dirname, '..', '..', 'resources', 'scripts', 'attachment-open-with.ps1');
|
|
}
|
|
|
|
function windowsHelperArgs(...args: string[]): string[] {
|
|
return [
|
|
'-NoLogo',
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-File',
|
|
sourceHelperPath(),
|
|
...args,
|
|
];
|
|
}
|
|
|
|
async function runWindowsHelper(...args: string[]): Promise<{
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}> {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = nodeSpawn('powershell.exe', windowsHelperArgs(...args), {
|
|
windowsHide: true,
|
|
shell: false,
|
|
stdio: 'pipe',
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let outputBytes = 0;
|
|
let settled = false;
|
|
const timeout = setTimeout(() => {
|
|
child.kill();
|
|
finish(new Error('native Windows helper timeout'));
|
|
}, NATIVE_SMOKE_TIMEOUT_MS);
|
|
const finish = (error?: Error, code: number | null = null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
if (error) reject(error);
|
|
else resolve({ code, stdout, stderr });
|
|
};
|
|
const collect = (stream: 'stdout' | 'stderr', chunk: Buffer) => {
|
|
outputBytes += chunk.length;
|
|
if (outputBytes > PROCESS_MAX_BUFFER_BYTES) {
|
|
child.kill();
|
|
finish(new Error('native Windows helper output exceeded bound'));
|
|
return;
|
|
}
|
|
if (stream === 'stdout') stdout += chunk.toString();
|
|
else stderr += chunk.toString();
|
|
};
|
|
child.stdout.on('data', (chunk: Buffer) => collect('stdout', chunk));
|
|
child.stderr.on('data', (chunk: Buffer) => collect('stderr', chunk));
|
|
child.on('error', (error) => finish(error));
|
|
child.on('close', (code) => finish(undefined, code));
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
electronState.isPackaged = false;
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, {
|
|
recursive: true,
|
|
force: true,
|
|
})));
|
|
});
|
|
|
|
describe('attachment open-with native bridges', () => {
|
|
it.runIf(process.platform === 'darwin')('executes the real macOS JXA application enumeration', async () => {
|
|
const filePath = await temporaryTextFile();
|
|
const service = createAttachmentOpenWithService({
|
|
platform: 'darwin',
|
|
loadIcon: async () => ({ isEmpty: () => true, toPNG: () => Buffer.alloc(0) }),
|
|
});
|
|
|
|
const handlers = await service.list(filePath);
|
|
|
|
expect(handlers.length).toBeGreaterThan(0);
|
|
expect(handlers.some((handler) => handler.isDefault)).toBe(true);
|
|
const nativeIcons = handlers.flatMap((handler) => handler.iconDataUrl ? [handler.iconDataUrl] : []);
|
|
expect(nativeIcons.length).toBeGreaterThan(0);
|
|
if (nativeIcons.length > 1) {
|
|
expect(new Set(nativeIcons).size).toBeGreaterThan(1);
|
|
}
|
|
for (const handler of handlers) {
|
|
expect(handler.id.length).toBeGreaterThan(0);
|
|
expect(handler.id.length).toBeLessThanOrEqual(HANDLER_ID_MAX_LENGTH);
|
|
expect(handler.name.length).toBeGreaterThan(0);
|
|
expect(handler.name.length).toBeLessThanOrEqual(HANDLER_NAME_MAX_LENGTH);
|
|
expect(hasControlCharacters(handler.id)).toBe(false);
|
|
expect(hasControlCharacters(handler.name)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform === 'win32')('retains a real Windows COM handler through ready and exits cleanly on cancel', async () => {
|
|
const filePath = await realpath(await temporaryTextFile());
|
|
const listResult = await runWindowsHelper('list', filePath);
|
|
expect(listResult.code, listResult.stderr).toBe(0);
|
|
const parsed = JSON.parse(listResult.stdout) as unknown;
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
const records = parsed as unknown[];
|
|
expect(records.length).toBeGreaterThan(0);
|
|
const validRecords = records.filter(isWindowsNativeHandlerRecord);
|
|
expect(validRecords).toHaveLength(records.length);
|
|
const defaultHandlers = validRecords.filter((record) => record.isDefault);
|
|
expect(defaultHandlers).toHaveLength(1);
|
|
const defaultHandler = defaultHandlers[0];
|
|
expect(defaultHandler).toBeDefined();
|
|
if (!defaultHandler) throw new Error('Windows .txt association has no valid default handler');
|
|
expect(validRecords).toContain(defaultHandler);
|
|
const nativeId = defaultHandler.nativeId;
|
|
const publicId = createHash('sha256').update(`win32\0${nativeId}`, 'utf8').digest('hex');
|
|
|
|
const matchedResult = await new Promise<{ code: number | null; lines: string[] }>((resolve, reject) => {
|
|
const child = nodeSpawn('powershell.exe', windowsHelperArgs(
|
|
'prepare-open',
|
|
filePath,
|
|
publicId,
|
|
), {
|
|
windowsHide: true,
|
|
shell: false,
|
|
stdio: 'pipe',
|
|
});
|
|
let settled = false;
|
|
let outputBytes = 0;
|
|
let stdoutBuffer = '';
|
|
let cancelSent = false;
|
|
const lines: string[] = [];
|
|
const timeout = setTimeout(() => {
|
|
child.kill();
|
|
finish(new Error('native matched prepare-open timeout'));
|
|
}, NATIVE_SMOKE_TIMEOUT_MS);
|
|
const finish = (error?: Error, code: number | null = null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
if (error) reject(error);
|
|
else resolve({ code, lines });
|
|
};
|
|
const account = (chunk: Buffer) => {
|
|
outputBytes += chunk.length;
|
|
if (outputBytes > PROCESS_MAX_BUFFER_BYTES) {
|
|
child.kill();
|
|
finish(new Error('native matched prepare-open output exceeded bound'));
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
if (!account(chunk)) return;
|
|
stdoutBuffer += chunk.toString();
|
|
let newline = stdoutBuffer.indexOf('\n');
|
|
while (newline >= 0) {
|
|
const line = stdoutBuffer.slice(0, newline).replace(/\r$/, '');
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
lines.push(line);
|
|
const message = JSON.parse(line) as Record<string, unknown>;
|
|
if (!cancelSent && message.ready === true && Object.keys(message).length === 1) {
|
|
cancelSent = true;
|
|
child.stdin.end(`${JSON.stringify({ command: 'cancel' })}\n`, 'utf8');
|
|
}
|
|
newline = stdoutBuffer.indexOf('\n');
|
|
}
|
|
});
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
account(chunk);
|
|
});
|
|
child.on('error', (error) => finish(error));
|
|
child.on('close', (code) => {
|
|
if (!cancelSent) {
|
|
finish(new Error('matched helper exited before ready'));
|
|
return;
|
|
}
|
|
finish(undefined, code);
|
|
});
|
|
});
|
|
|
|
expect(matchedResult.lines).toEqual(['{"ready":true}']);
|
|
expect(matchedResult.code).toBe(0);
|
|
|
|
const nonmatchingResult = await runWindowsHelper('prepare-open', filePath, '0'.repeat(64));
|
|
expect(nonmatchingResult.code).not.toBe(0);
|
|
expect(nonmatchingResult.stdout).not.toContain('{"ready":true}');
|
|
}, NATIVE_SMOKE_TIMEOUT_MS * 4);
|
|
|
|
it('resolves and executes the exact helper staged in a packaged resources tree', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'clawx-packaged-open-with-'));
|
|
temporaryDirectories.push(root);
|
|
const resourcesPath = join(root, 'resources');
|
|
const stagedHelper = join(resourcesPath, 'resources', 'scripts', 'attachment-open-with.ps1');
|
|
await mkdir(join(resourcesPath, 'resources', 'scripts'), { recursive: true });
|
|
await copyFile(sourceHelperPath(), stagedHelper);
|
|
const expectedSource = await readFile(sourceHelperPath(), 'utf8');
|
|
const originalResourcesPath = process.resourcesPath;
|
|
const execFile = vi.fn((...args: unknown[]) => {
|
|
const helperPath = (args[1] as string[])[6];
|
|
const callback = args[3] as (error: Error | null, stdout: string, stderr: string) => void;
|
|
void readFile(helperPath, 'utf8').then((content) => {
|
|
expect(helperPath).toBe(stagedHelper);
|
|
expect(content).toBe(expectedSource);
|
|
callback(null, '[]', '');
|
|
}, (error: unknown) => callback(error as Error, '', ''));
|
|
return {};
|
|
}) as unknown as NonNullable<AttachmentOpenWithDependencies['execFile']>;
|
|
|
|
try {
|
|
electronState.isPackaged = true;
|
|
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath });
|
|
const service = createAttachmentOpenWithService({ platform: 'win32', execFile });
|
|
|
|
await expect(service.list('C:\\Users\\Me\\packaged.txt')).resolves.toEqual([]);
|
|
expect(execFile).toHaveBeenCalledOnce();
|
|
} finally {
|
|
Object.defineProperty(process, 'resourcesPath', {
|
|
configurable: true,
|
|
value: originalResourcesPath,
|
|
});
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== 'win32')('keeps the Windows bridge smoke explicitly platform-gated', () => {
|
|
expect(process.platform).not.toBe('win32');
|
|
});
|
|
|
|
it.runIf(process.platform !== 'darwin')('keeps the macOS bridge smoke explicitly platform-gated', () => {
|
|
expect(process.platform).not.toBe('darwin');
|
|
});
|
|
});
|