test: cover Linux associations and native Windows Shell handoff
This commit is contained in:
1 parent
a1545a9bf5
commit
a8c5ca85a1
3 files changed
+113
-2
No files matched your search
@@ -0,0 +1,51 @@
|
||||
/** Linux association parsing over isolated installed-entry fixtures and an injected OS command runner. */
|
||||
import { mkdtemp, mkdir, writeFile, rm } 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'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-file-apps-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
await mkdir(join(root, 'applications', 'nested'), { recursive: true })
|
||||
await mkdir(join(root, 'pixmaps'))
|
||||
await writeFile(join(root, 'applications', 'player.desktop'), '[Desktop Entry]\nName=Player\nName[zh_CN]=播放器\nIcon=player\nExec=player %f\n[Desktop Action Extra]\nName=Wrong\n')
|
||||
await writeFile(join(root, 'applications', 'nested', 'other.desktop'), '[Desktop Entry]\nName=Other\nExec=other %f\n')
|
||||
await writeFile(join(root, 'pixmaps', 'player.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>')
|
||||
const run = vi.fn<NativeCommandRunner>(async (command, args) => ({
|
||||
stdout: command === 'gio' && args[0] === 'info' ? ' standard::content-type: audio/mpeg\n'
|
||||
: command === 'env' ? 'Default application for audio/mpeg: player.desktop\nRegistered applications:\n player.desktop\n nested-other.desktop\n missing.desktop\nRecommended applications:\n player.desktop\n' : '', stderr: '',
|
||||
}))
|
||||
return { root, run, facts: { platform: 'linux' as const, osRelease: 'linux', env: { XDG_DATA_HOME: root, XDG_DATA_DIRS: '', LANG: 'zh_CN.UTF-8' }, run } }
|
||||
}
|
||||
|
||||
it('deduplicates GIO handlers, resolves nested ids, localizes names, and reuses desktop artwork', async () => {
|
||||
const { root, facts, run } = await fixture()
|
||||
const signal = new AbortController().signal
|
||||
const apps = await nativeFileApplications('/file.mp3', signal, facts)
|
||||
expect(apps).toEqual([
|
||||
{ id: join(root, 'applications', 'player.desktop'), name: '播放器', default: true, icon: expect.stringMatching(/^data:image\/svg\+xml;base64,/) },
|
||||
{ id: join(root, 'applications', 'nested', 'other.desktop'), name: 'Other', default: false, icon: null },
|
||||
])
|
||||
await openNativeFileApplication('/file.mp3', apps[0]!.id, signal, facts)
|
||||
expect(run).toHaveBeenLastCalledWith('gio', ['launch', apps[0]!.id, '/file.mp3'], signal)
|
||||
await expect(openNativeFileApplication('/file.mp3', '/unregistered.desktop', signal, facts)).rejects.toThrow('not registered')
|
||||
})
|
||||
|
||||
it('keeps nondefault applications when the desktop has no default and omits hidden or unnamed entries', async () => {
|
||||
const { root, facts, run } = await fixture()
|
||||
await writeFile(join(root, 'applications', 'player.desktop'), '[Desktop Entry]\nHidden=true\nName=Hidden\n')
|
||||
await writeFile(join(root, 'applications', 'unnamed.desktop'), '[Desktop Entry]\nExec=unnamed\n')
|
||||
run.mockImplementation(async command => ({ stdout: command === 'gio' ? 'standard::content-type: audio/mpeg' : 'Registered applications:\n player.desktop\n unnamed.desktop\n nested-other.desktop\n', stderr: '' }))
|
||||
expect(await nativeFileApplications('/file.mp3', new AbortController().signal, facts)).toEqual([
|
||||
{ id: join(root, 'applications', 'nested', 'other.desktop'), name: 'Other', default: false, icon: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects missing content-type metadata instead of fabricating associations', async () => {
|
||||
const { facts, run } = await fixture()
|
||||
run.mockResolvedValue({ stdout: 'no content type', stderr: '' })
|
||||
await expect(nativeFileApplications('/file.mp3', new AbortController().signal, facts)).rejects.toThrow('content type')
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Real Windows Shell discovery and invocation with a private file extension and a short-lived fixture application. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts'
|
||||
import { runNativeCommand } from '../src/runner.ts'
|
||||
|
||||
/** Encode fixture data without placing its quotes or Unicode in executable PowerShell text. */
|
||||
function literal(value: string): string {
|
||||
return `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(value).toString('base64')}'))`
|
||||
}
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('queries and invokes a registered Windows handler through the system Shell', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-windows-association-'))
|
||||
const suffix = randomUUID().replaceAll('-', '')
|
||||
const extension = `.dsh${suffix}`
|
||||
const progId = `DSH.Test.${suffix}`
|
||||
const path = join(root, `测试 ' audio${extension}`)
|
||||
const marker = join(root, 'opened.json')
|
||||
const script = join(root, 'handler.cjs')
|
||||
const lifetime = new AbortController()
|
||||
const runScript = async (source: string): Promise<void> => {
|
||||
await runNativeCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')], lifetime.signal)
|
||||
}
|
||||
await writeFile(path, 'test')
|
||||
await writeFile(script, `require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ path: process.argv[2], pid: process.pid }));\n`)
|
||||
try {
|
||||
await runScript(`$ErrorActionPreference = 'Stop'
|
||||
$root = [Microsoft.Win32.Registry]::CurrentUser
|
||||
$key = $root.CreateSubKey('Software\\Classes\\${extension}')
|
||||
$key.SetValue('', '${progId}'); $key.Dispose()
|
||||
$key = $root.CreateSubKey('Software\\Classes\\${extension}\\OpenWithProgids')
|
||||
$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 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)
|
||||
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)
|
||||
})
|
||||
// Shell invocation does not own the application's lifetime; wait for this fixture's process to exit before removing its files.
|
||||
await vi.waitFor(() => { expect(() => process.kill(opened!.pid, 0)).toThrow() })
|
||||
} finally {
|
||||
await runScript(`$root = [Microsoft.Win32.Registry]::CurrentUser
|
||||
$root.DeleteSubKeyTree('Software\\Classes\\${extension}', $false)
|
||||
$root.DeleteSubKeyTree('Software\\Classes\\${progId}', $false)
|
||||
`)
|
||||
lifetime.abort()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -39,9 +39,11 @@ describe('native file associations', () => {
|
||||
|
||||
|
||||
it('uses the production command adapter and current platform when no override is supplied', async () => {
|
||||
const run = vi.spyOn(runner, 'runNativeCommand').mockResolvedValue({ stdout: JSON.stringify([application]), stderr: '' })
|
||||
const run = vi.spyOn(runner, 'runNativeCommand').mockImplementation(async command => ({
|
||||
stdout: command === 'gio' ? 'standard::content-type: audio/mpeg' : command === 'env' ? 'No applications found' : JSON.stringify([application]), stderr: '',
|
||||
}))
|
||||
onTestFinished(() => { run.mockRestore() })
|
||||
if (process.platform !== 'linux') expect(await nativeFileApplications('/file.mp3', signal)).toEqual([application])
|
||||
expect(await nativeFileApplications('/file.mp3', signal)).toEqual(process.platform === 'linux' ? [] : [application])
|
||||
await openNativeFileApplication('/file.mp3', application.id, signal, { platform: 'darwin' })
|
||||
expect(run).toHaveBeenLastCalledWith('open', ['-a', application.id, '/file.mp3'], signal)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user