From b4b0c4e4c8bf29159477e8f00f72a880c719ab4d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Sun, 20 Sep 2026 12:24:09 +0800 Subject: [PATCH] test: exercise native Linux file handoff and document shared adapters --- .../file-applications-linux-native.spec.ts | 59 +++++++++++++++++++ .../tests/file-applications-windows.spec.ts | 16 +++-- 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/util/native-command/tests/file-applications-linux-native.spec.ts diff --git a/packages/util/native-command/tests/file-applications-linux-native.spec.ts b/packages/util/native-command/tests/file-applications-linux-native.spec.ts new file mode 100644 index 0000000000..4d40b05770 --- /dev/null +++ b/packages/util/native-command/tests/file-applications-linux-native.spec.ts @@ -0,0 +1,59 @@ +/** Real GIO discovery and desktop-entry launch in private XDG data/config roots. */ +import { execFile, spawnSync } from 'node:child_process' +import { promisify } from 'node:util' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' +import type { NativeCommandRunner } from '../src/runner.ts' + +const gioAvailable = process.platform === 'linux' && spawnSync('gio', ['help'], { stdio: 'ignore' }).error === undefined +const execute = promisify(execFile) + +/** Desktop Exec quoted-argument syntax; GIO performs its expansion. */ +function quoted(value: string): string { + return `"${value.replace(/[\\"`$]/g, match => `\\${match}`)}"` +} + +it.skipIf(!gioAvailable)('queries and launches an installed Linux desktop entry through GIO', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-gio-associations-')) + const lifetime = new AbortController() + const active = new Set>>>() + onTestFinished(async () => { + lifetime.abort() + await Promise.allSettled([...active]) + await rm(root, { recursive: true, force: true }) + }) + const data = join(root, 'data') + const config = join(root, 'config') + const empty = join(root, 'empty') + await mkdir(join(data, 'applications'), { recursive: true }) + await mkdir(config) + await mkdir(empty) + const path = join(root, "测试 ' file.txt") + const script = join(root, 'handler.cjs') + const marker = join(root, 'opened.json') + await writeFile(path, 'plain text') + await writeFile(script, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ path: process.argv[2], pid: process.pid }));\n`) + const desktop = join(data, 'applications', 'dsh-test.desktop') + await writeFile(desktop, `[Desktop Entry]\nType=Application\nName=DSH Test Handler\nExec=${quoted(process.execPath)} ${quoted(script)} %f\nMimeType=text/plain;\n`) + await writeFile(join(data, 'applications', 'mimeinfo.cache'), '[MIME Cache]\ntext/plain=dsh-test.desktop;\n') + await writeFile(join(config, 'mimeapps.list'), '[Default Applications]\ntext/plain=dsh-test.desktop;\n') + const env = { ...process.env, XDG_DATA_HOME: data, XDG_DATA_DIRS: empty, XDG_CONFIG_HOME: config, XDG_CONFIG_DIRS: empty, LC_ALL: 'C' } + const run: NativeCommandRunner = (command, args, signal) => { + const task = execute(command, [...args], { signal, env, encoding: 'utf8' }) + active.add(task) + void task.then(() => active.delete(task), () => active.delete(task)) + return task + } + const apps = await nativeFileApplications(path, lifetime.signal, { run, env }) + expect(apps).toContainEqual({ id: desktop, name: 'DSH Test Handler', default: true, icon: null }) + await openNativeFileApplication(path, desktop, lifetime.signal, { run, env }) + let opened: { path: string; pid: number } | undefined + await vi.waitFor(async () => { + opened = JSON.parse(await readFile(marker, 'utf8')) as { path: string; pid: number } + expect(opened.path).toBe(path) + }, { timeout: vi.getConfig().testTimeout }) + await vi.waitFor(() => { expect(() => process.kill(opened!.pid, 0)).toThrow() }, { timeout: vi.getConfig().testTimeout }) +}) diff --git a/packages/util/native-command/tests/file-applications-windows.spec.ts b/packages/util/native-command/tests/file-applications-windows.spec.ts index 4d309b9fc9..b0d297aa90 100644 --- a/packages/util/native-command/tests/file-applications-windows.spec.ts +++ b/packages/util/native-command/tests/file-applications-windows.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it, vi, onTestFinished } from 'vitest' import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' -import { runNativeCommand } from '../src/runner.ts' +import { runNativeCommand, type NativeCommandRunner } from '../src/runner.ts' /** Encode fixture data without placing its quotes or Unicode in executable PowerShell text. */ function literal(value: string): string { @@ -21,11 +21,19 @@ it.skipIf(process.platform !== 'win32')('queries and invokes a registered Window const marker = join(root, 'opened.json') const script = join(root, 'handler.cjs') const lifetime = new AbortController() + const active = new Set>>>() + const run: NativeCommandRunner = (command, args, signal) => { + const task = runNativeCommand(command, args, signal) + active.add(task) + void task.then(() => active.delete(task), () => active.delete(task)) + return task + } const runScript = async (source: string): Promise => { - await runNativeCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], lifetime.signal) + await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], lifetime.signal) } onTestFinished(async () => { lifetime.abort() + await Promise.allSettled([...active]) try { await runNativeCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(` $root = [Microsoft.Win32.Registry]::CurrentUser @@ -45,10 +53,10 @@ $key.SetValue('${progId}', ''); $key.Dispose() $key = $root.CreateSubKey('Software\\Classes\\${progId}\\shell\\open\\command') $key.SetValue('', ${literal(`"${process.execPath}" "${script}" "%1"`)}); $key.Dispose() `) - const applications = await nativeFileApplications(path, lifetime.signal) + const applications = await nativeFileApplications(path, lifetime.signal, { run }) const expected = applications.find(app => app.id.toLowerCase() === process.execPath.toLowerCase()) expect(expected).toMatchObject({ default: true, name: expect.any(String) }) - await openNativeFileApplication(path, expected!.id, lifetime.signal) + await openNativeFileApplication(path, expected!.id, lifetime.signal, { run }) let opened: { path: string; pid: number } | undefined await vi.waitFor(async () => { opened = JSON.parse(await readFile(marker, 'utf8')) as { path: string; pid: number }