diff --git a/scripts/check-packaged-sqlite.mjs b/scripts/check-packaged-sqlite.mjs new file mode 100644 index 00000000..fd599cad --- /dev/null +++ b/scripts/check-packaged-sqlite.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Verify that the packaged Windows Electron runtime can load the shipped + * better-sqlite3 binding and execute a real SQLite query. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentFile = fileURLToPath(import.meta.url) +const repoRoot = resolve(import.meta.dirname, '..') +const defaultDistDir = join(repoRoot, 'apps', 'desktop', 'dist') +const desktopRequire = createRequire(join(repoRoot, 'apps', 'desktop', 'package.json')) + +export function getPackagedSqlitePaths(distDir) { + const appDir = join(distDir, 'win-unpacked') + return { + executablePath: join(appDir, 'ChatLab.exe'), + bindingPath: join(appDir, 'resources', 'native', 'better_sqlite3.node'), + } +} + +function assertNonEmptyFile(filePath, label) { + if (!existsSync(filePath)) throw new Error(`${label} is missing: ${filePath}`) + if (statSync(filePath).size === 0) throw new Error(`${label} is empty: ${filePath}`) +} + +function safeChildError(result) { + return (result.stderr || result.stdout || result.error?.message || `child exited with status ${result.status}`).trim() +} + +/** + * 使用最终 ChatLab.exe 的 Electron ABI 加载最终 binding,并执行真实内存 SQLite 查询。 + * 该检查只读成品文件且只创建内存数据库,不会访问任何用户数据目录。 + */ +export function verifyPackagedSqlite({ + distDir = defaultDistDir, + betterSqliteEntry = desktopRequire.resolve('better-sqlite3'), + spawn = spawnSync, +} = {}) { + const { executablePath, bindingPath } = getPackagedSqlitePaths(distDir) + assertNonEmptyFile(executablePath, 'Packaged ChatLab executable') + assertNonEmptyFile(bindingPath, 'Packaged better-sqlite3 binding') + + const probe = ` + const Database = require(process.argv[1]); + const db = new Database(':memory:', { nativeBinding: process.argv[2] }); + const row = db.prepare('SELECT 1 AS value').get(); + db.close(); + process.stdout.write(JSON.stringify({ abi: process.versions.modules, value: row.value })); + ` + const result = spawn(executablePath, ['-e', probe, betterSqliteEntry, bindingPath], { + encoding: 'utf8', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }) + + if (result.status !== 0) { + throw new Error(`Packaged better-sqlite3 failed to load: ${safeChildError(result)}`) + } + + let parsed + try { + parsed = JSON.parse(result.stdout) + } catch (error) { + throw new Error(`Invalid packaged SQLite probe output: ${result.stdout}`, { cause: error }) + } + if (parsed.value !== 1 || typeof parsed.abi !== 'string') { + throw new Error(`Invalid packaged SQLite probe result: ${JSON.stringify(parsed)}`) + } + return { abi: parsed.abi, value: parsed.value } +} + +function main() { + try { + const result = verifyPackagedSqlite() + console.log(`[check-packaged-sqlite] OK: Electron ABI ${result.abi}, SELECT 1 = ${result.value}`) + } catch (error) { + console.error(`[check-packaged-sqlite] FAILED: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} + +if (process.argv[1] && currentFile === resolve(process.argv[1])) main() diff --git a/scripts/check-packaged-sqlite.test.mjs b/scripts/check-packaged-sqlite.test.mjs new file mode 100644 index 00000000..997ab123 --- /dev/null +++ b/scripts/check-packaged-sqlite.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import { verifyPackagedSqlite } from './check-packaged-sqlite.mjs' + +const fixtures = [] +const BETTER_SQLITE_ENTRY = '/repo/node_modules/better-sqlite3/lib/index.js' + +function makeFixture({ executable, binding }) { + const distDir = mkdtempSync(join(tmpdir(), 'chatlab-packaged-sqlite-')) + fixtures.push(distDir) + + const appDir = join(distDir, 'win-unpacked') + const resourcesDir = join(appDir, 'resources') + mkdirSync(join(resourcesDir, 'native'), { recursive: true }) + if (executable) writeFileSync(join(appDir, 'ChatLab.exe'), 'executable') + if (binding) writeFileSync(join(resourcesDir, 'native', 'better_sqlite3.node'), 'binding') + return distDir +} + +test.afterEach(() => { + for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) +}) + +test('verifyPackagedSqlite rejects a missing packaged binding', () => { + const distDir = makeFixture({ executable: true, binding: false }) + + assert.throws( + () => + verifyPackagedSqlite({ + distDir, + betterSqliteEntry: BETTER_SQLITE_ENTRY, + spawn: () => assert.fail('spawn must not run when the binding is missing'), + }), + /binding.*missing/i + ) +}) + +test('verifyPackagedSqlite reports Electron load failures', () => { + const distDir = makeFixture({ executable: true, binding: true }) + + assert.throws( + () => + verifyPackagedSqlite({ + distDir, + betterSqliteEntry: BETTER_SQLITE_ENTRY, + spawn: () => ({ status: 1, stdout: '', stderr: 'NODE_MODULE_VERSION 137 requires 133' }), + }), + /137 requires 133/ + ) +}) + +test('verifyPackagedSqlite accepts a real SQLite probe result', () => { + const distDir = makeFixture({ executable: true, binding: true }) + let invocation + + const result = verifyPackagedSqlite({ + distDir, + betterSqliteEntry: BETTER_SQLITE_ENTRY, + spawn: (executablePath, args, options) => { + invocation = { executablePath, args, options } + return { status: 0, stdout: '{"abi":"133","value":1}', stderr: '' } + }, + }) + + assert.deepEqual(result, { abi: '133', value: 1 }) + assert.equal(invocation.executablePath, join(distDir, 'win-unpacked', 'ChatLab.exe')) + assert.equal(invocation.args.at(-1), join(distDir, 'win-unpacked', 'resources', 'native', 'better_sqlite3.node')) + assert.equal(invocation.options.env.ELECTRON_RUN_AS_NODE, '1') +})