From 7d156040f86804c6b20283f87c867540cc0ffbb1 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 16:59:24 +0800 Subject: [PATCH] fix(runtime): contain and clean temporary workspaces --- apps/cli/src/http/index.ts | 1 + apps/cli/src/http/routes/web/import.ts | 33 +- apps/cli/src/http/routes/web/index.ts | 3 +- apps/cli/src/import/importer.test.ts | 2 +- apps/cli/src/import/stream-import.test.ts | 2 +- apps/cli/src/runtime-compat-startup.test.ts | 2 +- apps/cli/src/runtime-compat.test.ts | 2 +- apps/cli/src/sync/adapters.test.ts | 2 +- apps/cli/src/sync/adapters.ts | 9 +- apps/cli/src/sync/routes.test.ts | 2 +- apps/desktop/main/app/bootstrap.ts | 5 +- apps/desktop/main/database/core.test.ts | 2 +- apps/desktop/main/database/migrations.test.ts | 2 +- .../main/database/startup-migration.test.ts | 2 +- .../main/import/archive-source-runtime.ts | 7 +- apps/desktop/main/ipc/demo.ts | 14 +- apps/desktop/main/paths/legacy-migration.ts | 9 +- apps/desktop/main/paths/locations.ts | 3 +- apps/desktop/main/paths/paths.test.ts | 23 +- apps/desktop/main/runtime/compat.test.ts | 2 +- .../main/worker/import/pushImport.test.ts | 6 +- apps/desktop/main/worker/import/tempDb.ts | 11 +- apps/desktop/scripts/ensure-native.mjs | 6 +- package.json | 2 +- .../src/routes/rest/imports.test.ts | 2 +- .../src/routes/shared-routes.test.ts | 29 +- .../src/routes/web/ai-semantic-index.test.ts | 2 +- .../src/routes/web/analytics-cache.test.ts | 2 +- .../src/routes/web/analytics.test.ts | 2 +- .../http-routes/src/routes/web/cache.test.ts | 83 ++--- packages/http-routes/src/routes/web/merge.ts | 6 +- packages/mcp-server/src/bin.test.ts | 2 +- packages/node-runtime/package.json | 1 + .../src/cache/session-cache.test.ts | 2 +- .../node-runtime/src/data-dir-compat.test.ts | 2 +- .../node-runtime/src/data-dir-switch.test.ts | 2 +- .../node-runtime/src/database-manager.test.ts | 2 +- .../src/import/archive/source-manager.test.ts | 23 ++ .../src/import/archive/source-manager.ts | 3 +- .../src/import/auto-import-matcher.test.ts | 2 +- .../src/import/import-lock.test.ts | 2 +- .../src/import/incremental-importer.test.ts | 2 +- .../src/import/streaming-importer.test.ts | 2 +- packages/node-runtime/src/index.ts | 9 + .../src/logging/app-logger.test.ts | 2 +- .../electron-data-migration.test.ts | 44 +++ .../src/migrations/electron-data-migration.ts | 4 +- .../node-runtime/src/node-path-provider.ts | 3 +- .../semantic-index/chat-db/adapters.test.ts | 2 +- .../src/semantic-index/config.test.ts | 2 +- .../retrieval/hybrid-search.test.ts | 2 +- .../src/semantic-index/service.test.ts | 285 +++++++++++------- .../session-state-store.test.ts | 2 +- .../semantic-index/store-mutations.test.ts | 2 +- .../src/semantic-index/store.test.ts | 2 +- .../src/semantic-index/warmup/runner.test.ts | 2 +- .../src/semantic-index/worker-client.test.ts | 5 +- .../adapters/database-manager-adapter.test.ts | 2 +- .../src/services/contacts/facts-cache.test.ts | 2 +- .../src/services/contacts/service.test.ts | 2 +- .../src/services/contacts/signature.test.ts | 2 +- .../src/services/contacts/snapshot.test.ts | 2 +- .../services/owner-profile-service.test.ts | 2 +- .../people/relationships/compute.test.ts | 7 +- .../people/relationships/facts-cache.test.ts | 7 +- .../people/relationships/service.test.ts | 7 +- .../src/services/push-importer.test.ts | 2 +- .../node-runtime/src/temp-workspace.test.ts | 91 ++++++ packages/node-runtime/src/temp-workspace.ts | 93 ++++++ .../src/formats/shuakami-qq-preprocessor.ts | 10 +- scripts/bench-weflow-parser.mts | 6 +- scripts/chatlab-temp.mjs | 39 +++ scripts/run-tests.mjs | 34 ++- scripts/run-tests.test.mjs | 73 ++++- tests/e2e/run-smoke.js | 7 +- 75 files changed, 801 insertions(+), 276 deletions(-) create mode 100644 packages/node-runtime/src/migrations/electron-data-migration.test.ts create mode 100644 packages/node-runtime/src/temp-workspace.test.ts create mode 100644 packages/node-runtime/src/temp-workspace.ts create mode 100644 scripts/chatlab-temp.mjs diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index 8349df94..48d879d5 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -149,6 +149,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ initAppLogger(pathProvider.getLogsDir()) initServerAiLogger(pathProvider.getLogsDir()) + appLogger.info('temp-workspace', 'Temporary workspace initialized', { root: pathProvider.getTempDir() }) appLogger.info('server', `HTTP server starting on ${host}:${port}`) // 记录 Rust native parser 可用性(导入是否走 Rust 内核),便于按日志排查回退原因 logNativeParserStatus() diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts index 4019bc7a..50e3444e 100644 --- a/apps/cli/src/http/routes/web/import.ts +++ b/apps/cli/src/http/routes/web/import.ts @@ -1,10 +1,15 @@ import * as fs from 'fs' -import * as os from 'os' import * as path from 'path' import { randomUUID } from 'node:crypto' import { pipeline } from 'node:stream/promises' import type { FastifyInstance } from 'fastify' -import { ArchiveImportError, ArchiveImportSourceManager, type DatabaseManager } from '@openchatlab/node-runtime' +import { + ArchiveImportError, + ArchiveImportSourceManager, + createChatLabTempDir, + getChatLabTempScopeDir, + type DatabaseManager, +} from '@openchatlab/node-runtime' import { autoImport, streamImport, @@ -52,11 +57,7 @@ export function registerImportRoutes( dbManager: DatabaseManager, options: ImportRouteOptions = {} ): void { - const sourceManager = - options.sourceManager ?? - new ArchiveImportSourceManager({ - tempRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-import-sources-')), - }) + const sourceManager = options.sourceManager ?? new ArchiveImportSourceManager() const runAutoImport = options.runAutoImport ?? autoImport.bind(null, dbManager) const runPreparedImport = options.runPreparedImport ?? @@ -79,7 +80,7 @@ export function registerImportRoutes( }) if (!data) return reply.code(400).send({ success: false, error: 'error.no_file_selected' }) - const uploadPath = path.join(os.tmpdir(), `chatlab-archive-${randomUUID()}.zip`) + const uploadPath = path.join(getChatLabTempScopeDir('imports'), `archive-${randomUUID()}.zip`) try { await pipeline(data.file, fs.createWriteStream(uploadPath, { flags: 'wx' })) if (data.file.truncated) { @@ -151,7 +152,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-detect-')) + const tmpDir = createChatLabTempDir('imports', 'detect-') const tmpPath = path.join(tmpDir, data.filename || 'upload') try { @@ -173,7 +174,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-scan-')) + const tmpDir = createChatLabTempDir('imports', 'scan-') const tmpPath = path.join(tmpDir, data.filename || 'upload') try { @@ -194,7 +195,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-import-')) + const tmpDir = createChatLabTempDir('imports', 'upload-') const tmpPath = path.join(tmpDir, data.filename || 'upload') const chunks: Buffer[] = [] @@ -245,7 +246,7 @@ export function registerImportRoutes( const parts = (request as any).parts() if (!parts) return reply.code(400).send({ error: 'No files uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-dir-import-')) + const tmpDir = createChatLabTempDir('imports', 'directory-') const relativePaths: string[] = [] const fileBuffers: { data: Buffer; filename: string }[] = [] @@ -320,7 +321,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-inc-')) + const tmpDir = createChatLabTempDir('imports', 'incremental-') const tmpPath = path.join(tmpDir, data.filename || 'upload') const chunks: Buffer[] = [] @@ -362,7 +363,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-analyze-')) + const tmpDir = createChatLabTempDir('imports', 'analyze-') const tmpPath = path.join(tmpDir, data.filename || 'upload') const chunks: Buffer[] = [] @@ -386,7 +387,7 @@ export function registerImportRoutes( const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-analyze-')) + const tmpDir = createChatLabTempDir('imports', 'analyze-') const tmpPath = path.join(tmpDir, data.filename || 'upload') const chunks: Buffer[] = [] @@ -428,7 +429,7 @@ export function registerImportRoutes( reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) } - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-demo-')) + const tmpDir = createChatLabTempDir('imports', 'cli-demo-') try { const localPaths: string[] = [] diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index a4f16f7f..6bf3dbd7 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -37,6 +37,7 @@ import { createNodeDataDirSwitch, getDefaultNodeUserDataDir, getPendingNodeDataDirMigration, + getChatLabTempScopeDir, AnalyticsService, MergeSessionCache, type SemanticIndexRuntime, @@ -87,7 +88,7 @@ export function registerWebRoutes( getAiDataDir: () => path.join(os.homedir(), '.chatlab', 'ai'), getSettingsDir: () => path.join(os.homedir(), '.chatlab', 'settings'), getCacheDir: () => path.join(os.homedir(), '.chatlab', 'cache'), - getTempDir: () => path.join(os.homedir(), '.chatlab', 'temp'), + getTempDir: () => getChatLabTempScopeDir('runtime'), getLogsDir: () => path.join(os.homedir(), '.chatlab', 'logs'), getDownloadsDir: () => path.join(os.homedir(), 'Downloads'), } diff --git a/apps/cli/src/import/importer.test.ts b/apps/cli/src/import/importer.test.ts index 1330a39b..8fa0cdd4 100644 --- a/apps/cli/src/import/importer.test.ts +++ b/apps/cli/src/import/importer.test.ts @@ -17,7 +17,7 @@ import { analyzeAutoImport, autoImport, streamImport } from './stream-import' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-import-')) } diff --git a/apps/cli/src/import/stream-import.test.ts b/apps/cli/src/import/stream-import.test.ts index bad4ef96..75a83e8b 100644 --- a/apps/cli/src/import/stream-import.test.ts +++ b/apps/cli/src/import/stream-import.test.ts @@ -15,7 +15,7 @@ import { analyzeIncrementalImport, incrementalImport } from './stream-import' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-stream-import-')) } diff --git a/apps/cli/src/runtime-compat-startup.test.ts b/apps/cli/src/runtime-compat-startup.test.ts index fe8bc088..8f47a154 100644 --- a/apps/cli/src/runtime-compat-startup.test.ts +++ b/apps/cli/src/runtime-compat-startup.test.ts @@ -7,7 +7,7 @@ import { startHttpServer, stopHttpServer } from './http' import { initMcpRuntime } from './mcp' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-startup-')) } diff --git a/apps/cli/src/runtime-compat.test.ts b/apps/cli/src/runtime-compat.test.ts index d947b4ce..59d3cbf9 100644 --- a/apps/cli/src/runtime-compat.test.ts +++ b/apps/cli/src/runtime-compat.test.ts @@ -7,7 +7,7 @@ import type { PathProvider } from '@openchatlab/core' import { assertCliDataDirCompatible } from './runtime-compat' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-compat-')) } diff --git a/apps/cli/src/sync/adapters.test.ts b/apps/cli/src/sync/adapters.test.ts index be84875a..0ba28a1d 100644 --- a/apps/cli/src/sync/adapters.test.ts +++ b/apps/cli/src/sync/adapters.test.ts @@ -17,7 +17,7 @@ import { DirectImporter } from './adapters' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-sync-')) } diff --git a/apps/cli/src/sync/adapters.ts b/apps/cli/src/sync/adapters.ts index 68de909a..7707b173 100644 --- a/apps/cli/src/sync/adapters.ts +++ b/apps/cli/src/sync/adapters.ts @@ -7,19 +7,22 @@ */ import * as fs from 'fs' -import * as os from 'os' import * as path from 'path' import * as crypto from 'crypto' import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams, SyncLogger } from '@openchatlab/sync' import { NOOP_LOGGER } from '@openchatlab/sync' import { buildPullUrl } from '@openchatlab/sync' import type { DatabaseManager } from '@openchatlab/node-runtime' -import { DataDirCompatibilityError, IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime' +import { + DataDirCompatibilityError, + getChatLabTempScopeDir, + IMPORT_IN_PROGRESS_ERROR_KEY, +} from '@openchatlab/node-runtime' import { streamImport, incrementalImport } from '../import/stream-import' function getTempFilePath(ext: string): string { const id = crypto.randomBytes(8).toString('hex') - return path.join(os.tmpdir(), `chatlab-pull-${id}${ext}`) + return path.join(getChatLabTempScopeDir('sync'), `pull-${id}${ext}`) } // ==================== NodeFetcher ==================== diff --git a/apps/cli/src/sync/routes.test.ts b/apps/cli/src/sync/routes.test.ts index dc77cf07..86bc3539 100644 --- a/apps/cli/src/sync/routes.test.ts +++ b/apps/cli/src/sync/routes.test.ts @@ -8,7 +8,7 @@ import { DataSourceManager } from '@openchatlab/sync' import { registerAutomationRoutes, type AutomationRouteContext } from '@openchatlab/http-routes' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-sync-routes-')) } diff --git a/apps/desktop/main/app/bootstrap.ts b/apps/desktop/main/app/bootstrap.ts index 35470940..1b9289d4 100644 --- a/apps/desktop/main/app/bootstrap.ts +++ b/apps/desktop/main/app/bootstrap.ts @@ -1,6 +1,6 @@ import { app, dialog } from 'electron' import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' -import { logNativeParserStatus } from '@openchatlab/node-runtime' +import { appLogger, logNativeParserStatus } from '@openchatlab/node-runtime' import type { RuntimeIdentity } from '@openchatlab/node-runtime/data-dir-compat' import { migrateAllDatabases, checkMigrationNeeded } from '../database/core' import { @@ -18,7 +18,7 @@ import { needsLegacyMigration, migrateFromLegacyDir, } from '../paths/legacy-migration' -import { ensureAppDirs, getSystemDataDir, getAiDataDir } from '../paths/locations' +import { ensureAppDirs, getSystemDataDir, getAiDataDir, getTempDir } from '../paths/locations' import { getPathProvider } from '../paths/provider' import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime/compat' @@ -35,6 +35,7 @@ export async function prepareDesktopRuntime(isTestMode: boolean): Promise { @@ -42,12 +41,7 @@ async function downloadFile(url: string, destPath: string): Promise { function cleanupDemoTemp(tempDir: string): void { try { - if (fs.existsSync(tempDir)) { - for (const file of fs.readdirSync(tempDir)) { - fs.unlinkSync(path.join(tempDir, file)) - } - fs.rmdirSync(tempDir) - } + fs.rmSync(tempDir, { recursive: true, force: true }) } catch { // best-effort cleanup } diff --git a/apps/desktop/main/paths/legacy-migration.ts b/apps/desktop/main/paths/legacy-migration.ts index 1a9f81e8..93e03c80 100644 --- a/apps/desktop/main/paths/legacy-migration.ts +++ b/apps/desktop/main/paths/legacy-migration.ts @@ -125,7 +125,8 @@ function migrateDirectory( /** * 执行从 Documents/ChatLab 到新目录的数据迁移 - * 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件 + * 迁移整个目录的持久内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件。 + * 旧 temp 目录只包含可重建的临时数据,不复制到新的正式数据目录。 * 只有在所有数据都成功迁移后才删除旧目录 */ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string[]; error?: string } { @@ -139,7 +140,9 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string // 获取旧目录下的所有子目录和文件 const entries = fs.readdirSync(legacyDir, { withFileTypes: true }) - const dirsToMigrate = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) + const dirsToMigrate = entries + .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'temp') + .map((e) => e.name) const filesToMigrate = entries.filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name) const result = migrateDirectory(legacyDir, newDir, dirsToMigrate) @@ -207,7 +210,7 @@ export function removeLegacyDir(): boolean { // ==================== Electron 旧目录结构 → 新目录结构迁移 ==================== -const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'temp', 'nlp'] +const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'nlp'] /** * 检测是否需要从 Electron 旧目录结构迁移到新的双根目录结构 diff --git a/apps/desktop/main/paths/locations.ts b/apps/desktop/main/paths/locations.ts index 09fa0ffe..1a1380a1 100644 --- a/apps/desktop/main/paths/locations.ts +++ b/apps/desktop/main/paths/locations.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { loadConfig, writeConfigField } from '@openchatlab/config' +import { getChatLabTempScopeDir } from '@openchatlab/node-runtime/temp-workspace' import { ensureMarkerFile } from '../utils/pathUtils' let _systemDataDir: string | null = null @@ -104,7 +105,7 @@ export function getCacheDir(): string { } export function getTempDir(): string { - return path.join(getSystemDataDir(), 'temp') + return getChatLabTempScopeDir('runtime') } export function getLogsDir(): string { diff --git a/apps/desktop/main/paths/paths.test.ts b/apps/desktop/main/paths/paths.test.ts index 4e5d7d11..a1dd53db 100644 --- a/apps/desktop/main/paths/paths.test.ts +++ b/apps/desktop/main/paths/paths.test.ts @@ -6,7 +6,7 @@ import { mock, test } from 'node:test' import * as dataDirSwitch from '../../../../packages/node-runtime/src/data-dir-switch' test('desktop paths preserve data across configured and legacy directory migrations', async () => { - const tempRoot = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const tempRoot = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const root = fs.mkdtempSync(path.join(tempRoot, 'chatlab-desktop-paths-')) const originalHome = process.env.HOME const originalDataDir = process.env.CHATLAB_DATA_DIR @@ -44,6 +44,9 @@ test('desktop paths preserve data across configured and legacy directory migrati }, }) await mock.module('@openchatlab/node-runtime', { namedExports: dataDirSwitch }) + await mock.module('@openchatlab/node-runtime/temp-workspace', { + namedExports: { getChatLabTempScopeDir: () => path.join(root, 'chatlab-temp', 'runtime') }, + }) try { const paths = { @@ -56,6 +59,7 @@ test('desktop paths preserve data across configured and legacy directory migrati assert.equal(paths.getUserDataDir(), currentDir) assert.equal(fs.existsSync(path.join(currentDir, '.chatlab')), true) assert.equal(fs.existsSync(path.join(root, '.chatlab', 'logs')), true) + assert.equal(fs.existsSync(path.join(root, 'chatlab-temp', 'runtime')), true) const switchResult = paths.setCustomDataDir(targetDir, true) assert.deepEqual(switchResult, { @@ -75,13 +79,30 @@ test('desktop paths preserve data across configured and legacy directory migrati const legacyDir = path.join(documentsDir, 'ChatLab') fs.mkdirSync(path.join(legacyDir, 'databases'), { recursive: true }) + fs.mkdirSync(path.join(legacyDir, 'temp'), { recursive: true }) fs.writeFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'legacy', 'utf-8') + fs.writeFileSync(path.join(legacyDir, 'temp', 'stale.tmp'), 'temporary', 'utf-8') assert.equal(paths.needsLegacyMigration(), true) const legacyResult = paths.migrateFromLegacyDir() assert.equal(legacyResult.success, true) assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') + assert.equal(fs.existsSync(path.join(targetDir, 'temp', 'stale.tmp')), false) assert.equal(fs.existsSync(legacyDir), false) + + const oldElectronDataDir = path.join(electronUserDataDir, 'data') + fs.mkdirSync(path.join(oldElectronDataDir, 'databases'), { recursive: true }) + fs.mkdirSync(path.join(oldElectronDataDir, 'ai'), { recursive: true }) + fs.mkdirSync(path.join(oldElectronDataDir, 'temp'), { recursive: true }) + fs.writeFileSync(path.join(oldElectronDataDir, 'databases', 'desktop.db'), 'sqlite', 'utf-8') + fs.writeFileSync(path.join(oldElectronDataDir, 'ai', 'assistant.json'), '{}', 'utf-8') + fs.writeFileSync(path.join(oldElectronDataDir, 'temp', 'stale.tmp'), 'temporary', 'utf-8') + + assert.equal(paths.needsUnifiedDirMigration(), true) + assert.equal(paths.migrateToUnifiedDirs().success, true) + assert.equal(fs.readFileSync(path.join(root, '.chatlab', 'ai', 'assistant.json'), 'utf-8'), '{}') + assert.equal(fs.existsSync(path.join(root, '.chatlab', 'temp', 'stale.tmp')), false) + assert.equal(fs.readFileSync(path.join(oldElectronDataDir, 'temp', 'stale.tmp'), 'utf-8'), 'temporary') } finally { if (originalHome === undefined) delete process.env.HOME else process.env.HOME = originalHome diff --git a/apps/desktop/main/runtime/compat.test.ts b/apps/desktop/main/runtime/compat.test.ts index f443194c..754cb4ae 100644 --- a/apps/desktop/main/runtime/compat.test.ts +++ b/apps/desktop/main/runtime/compat.test.ts @@ -7,7 +7,7 @@ import type { PathProvider } from '@openchatlab/core' import { assertDesktopDataDirCompatible, resolveDesktopAppVersion } from './compat' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-compat-')) } diff --git a/apps/desktop/main/worker/import/pushImport.test.ts b/apps/desktop/main/worker/import/pushImport.test.ts index d544fe21..cb1b3fb8 100644 --- a/apps/desktop/main/worker/import/pushImport.test.ts +++ b/apps/desktop/main/worker/import/pushImport.test.ts @@ -15,7 +15,7 @@ import { analyzePushImport, pushImport } from './pushImport' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') test('Desktop worker push analysis uses shared semantics without creating a database', async (t) => { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const root = fs.mkdtempSync(path.join(baseDir, 'chatlab-worker-push-analysis-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) @@ -34,7 +34,7 @@ test('Desktop worker push analysis uses shared semantics without creating a data }) test('Desktop worker push import uses the shared writer and canonical database directory', async (t) => { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const root = fs.mkdtempSync(path.join(baseDir, 'chatlab-worker-push-import-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) @@ -62,7 +62,7 @@ test('Desktop worker push import uses the shared writer and canonical database d }) test('Desktop worker push import refreshes caches after duplicate-only member updates', async (t) => { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const root = fs.mkdtempSync(path.join(baseDir, 'chatlab-worker-push-cache-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) diff --git a/apps/desktop/main/worker/import/tempDb.ts b/apps/desktop/main/worker/import/tempDb.ts index f17f06f0..f917f608 100644 --- a/apps/desktop/main/worker/import/tempDb.ts +++ b/apps/desktop/main/worker/import/tempDb.ts @@ -6,20 +6,13 @@ import type Database from 'better-sqlite3' import * as fs from 'fs' import * as path from 'path' +import { getChatLabTempScopeDir } from '@openchatlab/node-runtime/temp-workspace' // 直接从 dbCore 导入:core/index 会连带 perfLogger → @openchatlab/node-runtime, // 而本模块被纯 Node 单测导入,需保持依赖面最小。 import { openRawDatabase } from '../core/dbCore' -// 在 Worker 线程中,无法直接使用 electron 的 app 模块 -// 需要通过其他方式获取临时目录 function getTempDir(): string { - // 在 worker 中,使用系统临时目录 - const tmpDir = process.env.TMPDIR || process.env.TMP || '/tmp' - const chatLabTmp = path.join(tmpDir, 'chatlab-temp') - if (!fs.existsSync(chatLabTmp)) { - fs.mkdirSync(chatLabTmp, { recursive: true }) - } - return chatLabTmp + return getChatLabTempScopeDir('merge') } /** diff --git a/apps/desktop/scripts/ensure-native.mjs b/apps/desktop/scripts/ensure-native.mjs index a509a4cd..b3860abb 100644 --- a/apps/desktop/scripts/ensure-native.mjs +++ b/apps/desktop/scripts/ensure-native.mjs @@ -13,11 +13,11 @@ */ import { createRequire } from 'node:module' -import { existsSync, mkdirSync, mkdtempSync, rmSync, copyFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, mkdirSync, rmSync, copyFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' +import { createChatLabTempDir } from '../../../scripts/chatlab-temp.mjs' const currentFile = fileURLToPath(import.meta.url) const desktopDir = dirname(dirname(currentFile)) @@ -67,7 +67,7 @@ function rebuild(electronVersion) { const sqliteVersion = desktopRequire('better-sqlite3/package.json').version log(`Fetching better-sqlite3@${sqliteVersion} for Electron ${electronVersion}...`) - const tempDir = mkdtempSync(join(tmpdir(), 'chatlab-desktop-native-')) + const tempDir = createChatLabTempDir('build', 'desktop-native-') try { const installOk = run( 'npm', diff --git a/package.json b/package.json index 964f3821..6eeda7ca 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test:unit": "node scripts/run-tests.mjs", "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", "test:agent-context": "node --experimental-strip-types --test apps/desktop/main/ai/context/sessionLog.test.mjs", - "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", + "test:e2e:launcher": "node scripts/run-tests.mjs tests/e2e/helpers/app-launcher.test.js", "test:e2e:smoke": "node tests/e2e/run-smoke.js" }, "dependencies": { diff --git a/packages/http-routes/src/routes/rest/imports.test.ts b/packages/http-routes/src/routes/rest/imports.test.ts index 7226b4d7..6152fc76 100644 --- a/packages/http-routes/src/routes/rest/imports.test.ts +++ b/packages/http-routes/src/routes/rest/imports.test.ts @@ -13,7 +13,7 @@ type ImportRouteContext = Parameters[1] const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-import-route-')) } diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index cc861b40..e1c9d7d1 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -22,6 +22,7 @@ import { registerAutomationRoutes } from './web/automation' import { registerSessionRoutes } from './web/sessions' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') +const testSystemDir = path.join(process.env.CHATLAB_TEST_TMPDIR ?? os.tmpdir(), `chatlab-shared-routes-${process.pid}`) class SqlitePreparedStatement implements PreparedStatement { readonly?: boolean @@ -131,16 +132,16 @@ function createSessionDb(): TestSqliteDb { function createTestContext(dbs: Map = new Map()): HttpRouteContext { const pathProvider: PathProvider = { - getSystemDir: () => '/tmp/chatlab-test', - getUserDataDir: () => '/tmp/chatlab-test/data', - getDatabaseDir: () => '/tmp/chatlab-test/databases', - getVectorDir: () => '/tmp/chatlab-test/vector', - getAiDataDir: () => '/tmp/chatlab-test/ai', - getSettingsDir: () => '/tmp/chatlab-test/settings', - getCacheDir: () => '/tmp/chatlab-test/cache', - getTempDir: () => '/tmp/chatlab-test/temp', - getLogsDir: () => '/tmp/chatlab-test/logs', - getDownloadsDir: () => '/tmp/chatlab-test/downloads', + getSystemDir: () => testSystemDir, + getUserDataDir: () => path.join(testSystemDir, 'data'), + getDatabaseDir: () => path.join(testSystemDir, 'databases'), + getVectorDir: () => path.join(testSystemDir, 'vector'), + getAiDataDir: () => path.join(testSystemDir, 'ai'), + getSettingsDir: () => path.join(testSystemDir, 'settings'), + getCacheDir: () => path.join(testSystemDir, 'cache'), + getTempDir: () => path.join(testSystemDir, 'temp'), + getLogsDir: () => path.join(testSystemDir, 'logs'), + getDownloadsDir: () => path.join(testSystemDir, 'downloads'), } const dbManager = { @@ -184,7 +185,11 @@ describe('registerSharedRoutes smoke tests', () => { }) after(async () => { - await app.close() + try { + await app.close() + } finally { + fs.rmSync(testSystemDir, { recursive: true, force: true }) + } }) it('GET /api/v1/status returns 200 with version', async () => { @@ -426,7 +431,7 @@ describe('registerSharedRoutes smoke tests', () => { it('POST /_web/ai/logs/show does not open files for remote web clients', async () => { const ctx = createTestContext() - ctx.getCurrentAiLogPath = () => '/tmp/chatlab-test/logs/ai/ai_current.log' + ctx.getCurrentAiLogPath = () => path.join(testSystemDir, 'logs', 'ai', 'ai_current.log') let didOpen = false ctx.showInFolder = async () => { didOpen = true diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts index f50f688d..7ff82283 100644 --- a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts +++ b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts @@ -149,7 +149,7 @@ describe('semantic-index routes (degraded: service absent, aiDataDir present)', let aiDataDir: string before(async () => { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) aiDataDir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-route-')) app = Fastify() const ctx = { diff --git a/packages/http-routes/src/routes/web/analytics-cache.test.ts b/packages/http-routes/src/routes/web/analytics-cache.test.ts index 7e880848..57e6afe2 100644 --- a/packages/http-routes/src/routes/web/analytics-cache.test.ts +++ b/packages/http-routes/src/routes/web/analytics-cache.test.ts @@ -60,7 +60,7 @@ describe('withAnalyticsCache', () => { } beforeEach(() => { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) root = fs.mkdtempSync(path.join(baseDir, 'chatlab-analytics-cache-')) dbPath = path.join(root, 'session.db') fs.writeFileSync(dbPath, 'db-v1') diff --git a/packages/http-routes/src/routes/web/analytics.test.ts b/packages/http-routes/src/routes/web/analytics.test.ts index eb430109..85d9ac25 100644 --- a/packages/http-routes/src/routes/web/analytics.test.ts +++ b/packages/http-routes/src/routes/web/analytics.test.ts @@ -68,7 +68,7 @@ describe('analytics routes caching', () => { let app: FastifyInstance beforeEach(async () => { - const base = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const base = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) root = fs.mkdtempSync(path.join(base, 'chatlab-analytics-routes-')) dbFile = path.join(root, `${SESSION_ID}.db`) raw = new Database(dbFile, { nativeBinding }) diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts index c0a115ff..5e8a4d84 100644 --- a/packages/http-routes/src/routes/web/cache.test.ts +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from 'node:test' +import { after, describe, it, type TestContext } from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' @@ -9,25 +9,36 @@ import { registerCacheRoutes } from './cache' type CacheRouteContext = Parameters[1] +const testSystemDir = fs.mkdtempSync( + path.join(process.env.CHATLAB_TEST_TMPDIR ?? os.tmpdir(), 'chatlab-cache-routes-suite-') +) +const testUserDataDir = path.join(testSystemDir, 'data') +const testDefaultDataDir = path.join(testSystemDir, 'default-data') +const testNewDataDir = path.join(testSystemDir, 'new-data') + +after(() => fs.rmSync(testSystemDir, { recursive: true, force: true })) + function createPathProvider(overrides: Partial = {}): PathProvider { return { - getSystemDir: () => '/tmp/chatlab-test', - getUserDataDir: () => '/tmp/chatlab-test/data', - getDatabaseDir: () => '/tmp/chatlab-test/databases', - getVectorDir: () => '/tmp/chatlab-test/vector', - getAiDataDir: () => '/tmp/chatlab-test/ai', - getSettingsDir: () => '/tmp/chatlab-test/settings', - getCacheDir: () => '/tmp/chatlab-test/cache', - getTempDir: () => '/tmp/chatlab-test/temp', - getLogsDir: () => '/tmp/chatlab-test/logs', - getDownloadsDir: () => '/tmp/chatlab-test/downloads', + getSystemDir: () => testSystemDir, + getUserDataDir: () => testUserDataDir, + getDatabaseDir: () => path.join(testSystemDir, 'databases'), + getVectorDir: () => path.join(testSystemDir, 'vector'), + getAiDataDir: () => path.join(testSystemDir, 'ai'), + getSettingsDir: () => path.join(testSystemDir, 'settings'), + getCacheDir: () => path.join(testSystemDir, 'cache'), + getTempDir: () => path.join(testSystemDir, 'temp'), + getLogsDir: () => path.join(testSystemDir, 'logs'), + getDownloadsDir: () => path.join(testSystemDir, 'downloads'), ...overrides, } } -function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() - return fs.mkdtempSync(path.join(baseDir, 'chatlab-cache-routes-')) +function makeTempDir(t: TestContext): string { + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-cache-routes-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + return dir } describe('registerCacheRoutes data directory routes', () => { @@ -45,10 +56,10 @@ describe('registerCacheRoutes data directory routes', () => { rootPath: dir.rootPath, })), [ - { id: 'databases', scope: 'user-data', rootPath: '/tmp/chatlab-test/data' }, - { id: 'ai', scope: 'system-data', rootPath: '/tmp/chatlab-test' }, - { id: 'cache', scope: 'system-data', rootPath: '/tmp/chatlab-test' }, - { id: 'logs', scope: 'system-data', rootPath: '/tmp/chatlab-test' }, + { id: 'databases', scope: 'user-data', rootPath: testUserDataDir }, + { id: 'ai', scope: 'system-data', rootPath: testSystemDir }, + { id: 'cache', scope: 'system-data', rootPath: testSystemDir }, + { id: 'logs', scope: 'system-data', rootPath: testSystemDir }, ] ) @@ -69,7 +80,7 @@ describe('registerCacheRoutes data directory routes', () => { const infoResponse = await app.inject({ method: 'GET', url: '/_web/cache/info' }) assert.equal(infoResponse.statusCode, 200) - assert.equal(infoResponse.json().baseDir, '/tmp/chatlab-test') + assert.equal(infoResponse.json().baseDir, testSystemDir) const baseResponse = await app.inject({ method: 'POST', @@ -84,7 +95,7 @@ describe('registerCacheRoutes data directory routes', () => { payload: { cacheId: 'userData' }, }) assert.equal(userDataResponse.statusCode, 200) - assert.deepEqual(openedDirs, ['/tmp/chatlab-test', '/tmp/chatlab-test/data']) + assert.deepEqual(openedDirs, [testSystemDir, testUserDataDir]) await app.close() }) @@ -93,12 +104,12 @@ describe('registerCacheRoutes data directory routes', () => { const app = Fastify() const ctx: CacheRouteContext = { pathProvider: createPathProvider(), - defaultUserDataDir: '/tmp/chatlab-test/default-data', + defaultUserDataDir: testDefaultDataDir, isCustomDataDir: true, canSetDataDir: true, getPendingDataDirMigration: () => ({ - from: '/tmp/chatlab-test/data', - to: '/tmp/chatlab-test/new-data', + from: testUserDataDir, + to: testNewDataDir, migrate: true, deleteSourceOnSuccess: false, createdAt: '2026-06-02T00:00:00.000Z', @@ -111,16 +122,16 @@ describe('registerCacheRoutes data directory routes', () => { const response = await app.inject({ method: 'GET', url: '/_web/cache/data-dir' }) assert.equal(response.statusCode, 200) assert.deepEqual(response.json(), { - path: '/tmp/chatlab-test/data', - defaultPath: '/tmp/chatlab-test/default-data', + path: testUserDataDir, + defaultPath: testDefaultDataDir, isCustom: true, canSetDataDir: true, managedScope: 'chat-databases', managedDescription: 'settings.storage.dataLocation.managedDescription', hasLegacyDataAtDefaultDir: false, pendingMigration: { - from: '/tmp/chatlab-test/data', - to: '/tmp/chatlab-test/new-data', + from: testUserDataDir, + to: testNewDataDir, createdAt: '2026-06-02T00:00:00.000Z', }, }) @@ -128,8 +139,8 @@ describe('registerCacheRoutes data directory routes', () => { await app.close() }) - it('reports legacy data at default directory only when default databases contain db files', async () => { - const root = makeTempDir() + it('reports legacy data at default directory only when default databases contain db files', async (t) => { + const root = makeTempDir(t) const currentDir = path.join(root, 'custom-data') const defaultDir = path.join(root, 'default-data') fs.mkdirSync(path.join(defaultDir, 'databases'), { recursive: true }) @@ -173,8 +184,8 @@ describe('registerCacheRoutes data directory routes', () => { calls.push({ path: dirPath, migrate }) return { success: true, - from: '/tmp/chatlab-test/data', - to: dirPath ?? '/tmp/chatlab-test/default-data', + from: testUserDataDir, + to: dirPath ?? testDefaultDataDir, requiresRelaunch: true, } }, @@ -186,15 +197,15 @@ describe('registerCacheRoutes data directory routes', () => { const response = await app.inject({ method: 'POST', url: '/_web/cache/data-dir', - payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + payload: { path: testNewDataDir, migrate: true }, }) assert.equal(response.statusCode, 200) - assert.deepEqual(calls, [{ path: '/tmp/chatlab-test/new-data', migrate: true }]) + assert.deepEqual(calls, [{ path: testNewDataDir, migrate: true }]) assert.deepEqual(response.json(), { success: true, - from: '/tmp/chatlab-test/data', - to: '/tmp/chatlab-test/new-data', + from: testUserDataDir, + to: testNewDataDir, requiresRelaunch: true, }) @@ -209,7 +220,7 @@ describe('registerCacheRoutes data directory routes', () => { const response = await app.inject({ method: 'POST', url: '/_web/cache/data-dir', - payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + payload: { path: testNewDataDir, migrate: true }, }) assert.equal(response.statusCode, 501) diff --git a/packages/http-routes/src/routes/web/merge.ts b/packages/http-routes/src/routes/web/merge.ts index 3c32f424..6a27233d 100644 --- a/packages/http-routes/src/routes/web/merge.ts +++ b/packages/http-routes/src/routes/web/merge.ts @@ -7,7 +7,6 @@ */ import * as fs from 'fs' -import * as os from 'os' import * as path from 'path' import type { FastifyInstance } from 'fastify' import type { MergeRouteContext } from '../../context/merge' @@ -20,6 +19,7 @@ import { exportSessionToJson, TempDbReader, TempDbWriter, + createChatLabTempDir, } from '@openchatlab/node-runtime' import type { MergerDataSource } from '@openchatlab/node-runtime' import { sessionNotFound } from '../../errors' @@ -48,7 +48,7 @@ export function registerMergeRoutes(server: FastifyInstance, ctx: MergeRoutesCon const data = await (request as any).file() if (!data) return reply.code(400).send({ error: 'No file uploaded' }) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merge-')) + const tmpDir = createChatLabTempDir('merge', 'upload-') const tmpPath = path.join(tmpDir, data.filename || 'upload') const chunks: Buffer[] = [] for await (const chunk of data.file) { @@ -159,7 +159,7 @@ export function registerMergeRoutes(server: FastifyInstance, ctx: MergeRoutesCon return reply.code(501).send({ error: 'Import not available on this server' }) } const jsonData = JSON.stringify(merged.chatLabData) - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merged-')) + const tmpDir = createChatLabTempDir('merge', 'import-') const safeName = path.basename(outputName).replace(/[/\\?%*:|"<>]/g, '_') || 'merged' const tmpPath = path.join(tmpDir, `${safeName}.json`) fs.writeFileSync(tmpPath, jsonData, 'utf-8') diff --git a/packages/mcp-server/src/bin.test.ts b/packages/mcp-server/src/bin.test.ts index 252e9df1..96fda8a4 100644 --- a/packages/mcp-server/src/bin.test.ts +++ b/packages/mcp-server/src/bin.test.ts @@ -7,7 +7,7 @@ import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openc import { initStandaloneMcpRuntime } from './standalone-runtime' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-mcp-bin-')) } diff --git a/packages/node-runtime/package.json b/packages/node-runtime/package.json index 5c4300b7..ef06c2ef 100644 --- a/packages/node-runtime/package.json +++ b/packages/node-runtime/package.json @@ -10,6 +10,7 @@ ".": "./src/index.ts", "./data-dir-compat": "./src/data-dir-compat.ts", "./data-dir-switch": "./src/data-dir-switch.ts", + "./temp-workspace": "./src/temp-workspace.ts", "./import/archive/source-manager": "./src/import/archive/source-manager.ts", "./src/*": "./src/*.ts" }, diff --git a/packages/node-runtime/src/cache/session-cache.test.ts b/packages/node-runtime/src/cache/session-cache.test.ts index 5fecbfd9..7ba838ab 100644 --- a/packages/node-runtime/src/cache/session-cache.test.ts +++ b/packages/node-runtime/src/cache/session-cache.test.ts @@ -25,7 +25,7 @@ import { const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-session-cache-')) } diff --git a/packages/node-runtime/src/data-dir-compat.test.ts b/packages/node-runtime/src/data-dir-compat.test.ts index 927fa9cd..2ea98491 100644 --- a/packages/node-runtime/src/data-dir-compat.test.ts +++ b/packages/node-runtime/src/data-dir-compat.test.ts @@ -12,7 +12,7 @@ import { } from './data-dir-compat' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-compat-')) } diff --git a/packages/node-runtime/src/data-dir-switch.test.ts b/packages/node-runtime/src/data-dir-switch.test.ts index b76f97ab..3d9243c7 100644 --- a/packages/node-runtime/src/data-dir-switch.test.ts +++ b/packages/node-runtime/src/data-dir-switch.test.ts @@ -14,7 +14,7 @@ import { import { applyPendingNodeDataDirMigrationIfNeeded, NodePathProvider } from './node-path-provider' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-switch-')) } diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index fe6fb3e1..a2e3a371 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -12,7 +12,7 @@ import { DatabaseManager } from './database-manager' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-manager-')) } diff --git a/packages/node-runtime/src/import/archive/source-manager.test.ts b/packages/node-runtime/src/import/archive/source-manager.test.ts index 97c90fc0..9378e93e 100644 --- a/packages/node-runtime/src/import/archive/source-manager.test.ts +++ b/packages/node-runtime/src/import/archive/source-manager.test.ts @@ -7,6 +7,7 @@ import { describe, it } from 'node:test' import { ArchiveImportError } from './errors' import { ArchiveImportSourceManager } from './source-manager' import { writeZipFixture } from './test-utils' +import { resolveChatLabTempRoot } from '../../temp-workspace' function createMinimalTakeout(zipPath: string): void { writeZipFixture(zipPath, [ @@ -33,6 +34,28 @@ function createMinimalTakeout(zipPath: string): void { } describe('ArchiveImportSourceManager', () => { + it('uses the centralized imports scope by default and removes its owned root on close', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-source-default-root-')) + try { + const zipPath = join(dir, 'takeout.zip') + createMinimalTakeout(zipPath) + const manager = new ArchiveImportSourceManager() + const source = await manager.prepareLocalArchive(zipPath) + let managerRoot = '' + + await manager.withMaterializedChat(source.sourceId, 'Groups/DM sample', async (manifestPath) => { + managerRoot = dirname(dirname(manifestPath)) + assert.equal(dirname(managerRoot), join(resolveChatLabTempRoot(), 'imports')) + }) + + assert.equal(existsSync(managerRoot), true) + await manager.close() + assert.equal(existsSync(managerRoot), false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('keeps local archives and deletes owned archives on release', async () => { const dir = mkdtempSync(join(tmpdir(), 'chatlab-source-ownership-')) try { diff --git a/packages/node-runtime/src/import/archive/source-manager.ts b/packages/node-runtime/src/import/archive/source-manager.ts index 749c0dfb..80a293c8 100644 --- a/packages/node-runtime/src/import/archive/source-manager.ts +++ b/packages/node-runtime/src/import/archive/source-manager.ts @@ -5,6 +5,7 @@ import { ZipArchiveReader } from './archive-reader' import { ArchiveImportError } from './errors' import { GoogleChatTakeoutResolver } from './google-chat-resolver' import type { ArchiveResolver, PreparedImportSource } from './types' +import { createChatLabTempDir } from '../../temp-workspace' const DEFAULT_TTL_MS = 30 * 60 * 1000 @@ -32,7 +33,7 @@ export class ArchiveImportSourceManager { private readonly resolvers: ArchiveResolver[] constructor(options: ArchiveImportSourceManagerOptions = {}) { - this.tempRoot = options.tempRoot ?? path.join(process.cwd(), '.chatlab-import-sources') + this.tempRoot = options.tempRoot ?? createChatLabTempDir('imports', 'sources-') this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS this.now = options.now ?? Date.now this.resolvers = options.resolvers ?? [new GoogleChatTakeoutResolver()] diff --git a/packages/node-runtime/src/import/auto-import-matcher.test.ts b/packages/node-runtime/src/import/auto-import-matcher.test.ts index 9600810f..adf4db20 100644 --- a/packages/node-runtime/src/import/auto-import-matcher.test.ts +++ b/packages/node-runtime/src/import/auto-import-matcher.test.ts @@ -24,7 +24,7 @@ interface SourceMember { } function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-auto-import-match-')) } diff --git a/packages/node-runtime/src/import/import-lock.test.ts b/packages/node-runtime/src/import/import-lock.test.ts index b17318cc..37405dbe 100644 --- a/packages/node-runtime/src/import/import-lock.test.ts +++ b/packages/node-runtime/src/import/import-lock.test.ts @@ -6,7 +6,7 @@ import test from 'node:test' import { ImportInProgressError, withDataDirImportLock } from './import-lock' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-import-lock-')) } diff --git a/packages/node-runtime/src/import/incremental-importer.test.ts b/packages/node-runtime/src/import/incremental-importer.test.ts index 544adb78..ea769d0a 100644 --- a/packages/node-runtime/src/import/incremental-importer.test.ts +++ b/packages/node-runtime/src/import/incremental-importer.test.ts @@ -10,7 +10,7 @@ import { analyzeIncrementalImport, incrementalImport, type IncrementalImportDeps const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-incremental-import-')) } diff --git a/packages/node-runtime/src/import/streaming-importer.test.ts b/packages/node-runtime/src/import/streaming-importer.test.ts index 1b9c42f9..b5c3980e 100644 --- a/packages/node-runtime/src/import/streaming-importer.test.ts +++ b/packages/node-runtime/src/import/streaming-importer.test.ts @@ -11,7 +11,7 @@ import { analyzeNewImport, streamingImport } from './streaming-importer' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-streaming-import-')) } diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 328c2e11..87ae699b 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -6,6 +6,15 @@ */ export { BetterSqliteAdapter, openBetterSqliteDatabase } from './better-sqlite3-adapter' +export { + CHATLAB_TEMP_ROOT_ENV, + createChatLabTempDir, + ensureChatLabTempRoot, + getChatLabTempScopeDir, + removeChatLabTempDir, + resolveChatLabTempRoot, +} from './temp-workspace' +export type { ChatLabTempRootOptions, ChatLabTempScope } from './temp-workspace' // Import data writing + perf logging + streaming importer + incremental importer export { diff --git a/packages/node-runtime/src/logging/app-logger.test.ts b/packages/node-runtime/src/logging/app-logger.test.ts index e629d622..431f4412 100644 --- a/packages/node-runtime/src/logging/app-logger.test.ts +++ b/packages/node-runtime/src/logging/app-logger.test.ts @@ -6,7 +6,7 @@ import test from 'node:test' import { initAppLogger, appLogger } from './app-logger' function makeTempDir(): string { - const base = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const base = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(base, 'applog-')) } diff --git a/packages/node-runtime/src/migrations/electron-data-migration.test.ts b/packages/node-runtime/src/migrations/electron-data-migration.test.ts new file mode 100644 index 00000000..0cf7e5b9 --- /dev/null +++ b/packages/node-runtime/src/migrations/electron-data-migration.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mock, test } from 'node:test' + +test('Electron migration moves persistent system data but leaves legacy temp data untouched', async (t) => { + const root = fs.mkdtempSync(path.join(process.env.CHATLAB_TEST_TMPDIR ?? os.tmpdir(), 'chatlab-electron-migration-')) + const originalHome = process.env.HOME + const electronUserData = path.join(root, 'Library', 'Application Support', 'ChatLab') + const electronDataDir = path.join(electronUserData, 'data') + const systemDir = path.join(root, 'system') + const configWrites: Array<{ section: string; key: string; value: unknown }> = [] + t.after(() => { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + fs.rmSync(root, { recursive: true, force: true }) + }) + + process.env.HOME = root + fs.mkdirSync(path.join(electronDataDir, 'databases'), { recursive: true }) + fs.mkdirSync(path.join(electronDataDir, 'ai'), { recursive: true }) + fs.mkdirSync(path.join(electronDataDir, 'temp'), { recursive: true }) + fs.writeFileSync(path.join(electronDataDir, 'databases', 'session.db'), 'sqlite') + fs.writeFileSync(path.join(electronDataDir, 'ai', 'settings.json'), '{}') + fs.writeFileSync(path.join(electronDataDir, 'temp', 'stale.tmp'), 'temporary') + + await mock.module('@openchatlab/config', { + namedExports: { + writeConfigField(section: string, key: string, value: unknown) { + configWrites.push({ section, key, value }) + }, + }, + }) + + const { migrateFromElectronIfNeeded } = await import('./electron-data-migration.js') + const result = migrateFromElectronIfNeeded(systemDir) + + assert.equal(result.migrated, true) + assert.deepEqual(configWrites, [{ section: 'data', key: 'user_data_dir', value: electronDataDir }]) + assert.equal(fs.readFileSync(path.join(systemDir, 'ai', 'settings.json'), 'utf-8'), '{}') + assert.equal(fs.existsSync(path.join(systemDir, 'temp')), false) + assert.equal(fs.readFileSync(path.join(electronDataDir, 'temp', 'stale.tmp'), 'utf-8'), 'temporary') +}) diff --git a/packages/node-runtime/src/migrations/electron-data-migration.ts b/packages/node-runtime/src/migrations/electron-data-migration.ts index 5e82e5c7..7d749b31 100644 --- a/packages/node-runtime/src/migrations/electron-data-migration.ts +++ b/packages/node-runtime/src/migrations/electron-data-migration.ts @@ -8,7 +8,7 @@ * Migration behavior: * 1. Detect Electron legacy data directory (platform-specific) * 2. If databases exist there, write user_data_dir to config.toml pointing to old path - * 3. Copy system data (ai, settings, cache, logs, temp, nlp) from old path to ~/.chatlab/ + * 3. Copy persistent system data (ai, settings, cache, logs, nlp) from old path to ~/.chatlab/ */ import * as fs from 'fs' @@ -16,7 +16,7 @@ import * as os from 'os' import * as path from 'path' import { writeConfigField } from '@openchatlab/config' -const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'temp', 'nlp'] +const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'nlp'] const STORAGE_CONFIG_FILE = 'storage.json' interface StorageConfig { diff --git a/packages/node-runtime/src/node-path-provider.ts b/packages/node-runtime/src/node-path-provider.ts index ae2fedd3..44238166 100644 --- a/packages/node-runtime/src/node-path-provider.ts +++ b/packages/node-runtime/src/node-path-provider.ts @@ -21,6 +21,7 @@ import type { PathProvider } from '@openchatlab/core' import { writeConfigField, loadConfig } from '@openchatlab/config' import { migrateFromElectronIfNeeded } from './migrations/electron-data-migration' import { applyPendingNodeDataDirMigration } from './data-dir-switch' +import { getChatLabTempScopeDir } from './temp-workspace' const SYSTEM_DIR = path.join(os.homedir(), '.chatlab') @@ -77,7 +78,7 @@ export class NodePathProvider implements PathProvider { } getTempDir(): string { - return path.join(this.systemDir, 'temp') + return getChatLabTempScopeDir('runtime') } getLogsDir(): string { diff --git a/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts b/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts index cddf6b24..2b1a1c4a 100644 --- a/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts +++ b/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts @@ -11,7 +11,7 @@ import { createChatDbMessageRangeReader } from './message-range-reader' import { createChatDbFtsSearcher, extractFtsKeywords } from './fts-searcher' function makeChatDb(): { db: BetterSqliteAdapter; dir: string } { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-chatdb-')) const db = openBetterSqliteDatabase(path.join(dir, 'chat.db')) db.exec(CHAT_DB_SCHEMA) diff --git a/packages/node-runtime/src/semantic-index/config.test.ts b/packages/node-runtime/src/semantic-index/config.test.ts index 47e79585..900ca74e 100644 --- a/packages/node-runtime/src/semantic-index/config.test.ts +++ b/packages/node-runtime/src/semantic-index/config.test.ts @@ -12,7 +12,7 @@ import { } from './config' function tempConfigPath(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-config-')) return path.join(dir, 'ai', 'semantic-index-config.json') } diff --git a/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts index 6b16c1ea..6f5f332e 100644 --- a/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts +++ b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts @@ -14,7 +14,7 @@ const MODEL = 'fake' const DIM = 4 function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-hybrid-')) } diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts index dfe954d6..ba23abbf 100644 --- a/packages/node-runtime/src/semantic-index/service.test.ts +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import test from 'node:test' +import test, { type TestContext } from 'node:test' import { CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA } from '@openchatlab/core' import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' import { buildFtsIndex } from '../fts' @@ -20,21 +20,82 @@ import type { SessionRuntimeAdapter } from '../services/adapters' import type { FeatureExtractFn, LocalPipelineFactory } from './embedding/local' const SESSION_ID = 'sess1' +const SERVICE_TEMP_DIR_PREFIX = 'chatlab-si-svc-' // 真实秒级基准时间(2023-11-14),用于让证据 snippet 渲染出真实年份, // 暴露「毫秒被当作秒再 *1000」导致五位数年份(如 56150)的回归。 const BASE_TS_SECONDS = 1_700_000_000 -function setup(opts?: { +interface SetupOptions { embedDelayMs?: number failFirstPipelineCreation?: boolean failPipelineCreationForModelIds?: ReadonlySet onEmbedBatchStarted?: () => void localPipelineFactory?: LocalPipelineFactory -}) { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() - const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-svc-')) +} + +interface ServiceFixture { + service: SemanticIndexService + chatDbPath: string + dir: string + db: ReturnType + authProfiles: Map + getEmbedCount: () => number + cleanup: ServiceFixtureCleanup +} + +interface ServiceFixtureCleanup { + dir: string + service?: SemanticIndexService + db?: ReturnType + disposePromise?: Promise +} + +function serviceTempBaseDir(): string { + return process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) +} + +function removeOwnedServiceTempDir(dir: string): void { + const baseDir = path.resolve(serviceTempBaseDir()) + const resolvedDir = path.resolve(dir) + if (path.dirname(resolvedDir) !== baseDir || !path.basename(resolvedDir).startsWith(SERVICE_TEMP_DIR_PREFIX)) { + throw new Error(`Refusing to remove non-owned semantic-index test directory: ${resolvedDir}`) + } + fs.rmSync(resolvedDir, { recursive: true, force: true }) +} + +async function disposeFixture(cleanup: ServiceFixtureCleanup): Promise { + if (cleanup.disposePromise) return cleanup.disposePromise + cleanup.disposePromise = (async () => { + try { + // Stop queued/running work first so SQLite files are no longer used when the directory is removed. + if (cleanup.service) { + cleanup.service.setConfig({ ...cleanup.service.getConfig(), enabled: false }) + await cleanup.service.whenIdle() + } + } finally { + try { + cleanup.service?.close() + } finally { + try { + if (cleanup.db?.open) cleanup.db.close() + } finally { + removeOwnedServiceTempDir(cleanup.dir) + } + } + } + })() + return cleanup.disposePromise +} + +function setup(t: TestContext, opts?: SetupOptions): ServiceFixture { + const baseDir = serviceTempBaseDir() + const dir = fs.mkdtempSync(path.join(baseDir, SERVICE_TEMP_DIR_PREFIX)) + const cleanup: ServiceFixtureCleanup = { dir } + t.after(() => disposeFixture(cleanup)) + const chatDbPath = path.join(dir, `${SESSION_ID}.db`) const db = openBetterSqliteDatabase(chatDbPath) + cleanup.db = db db.exec(CHAT_DB_SCHEMA) db.exec(FTS_TABLE_SCHEMA) db.exec(` @@ -103,10 +164,11 @@ function setup(opts?: { resolveApiKey: (_provider, authProfile) => (authProfile ? (authProfiles.get(authProfile)?.key ?? '') : ''), }, }) + cleanup.service = service // 默认配置不再预选模型;测试显式选择 Qwen3 本地模型,使建索引可执行 service.setConfig({ version: 1, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) - return { service, chatDbPath, dir, db, authProfiles, getEmbedCount: () => embedTextCount } + return { service, chatDbPath, dir, db, authProfiles, getEmbedCount: () => embedTextCount, cleanup } } async function enableAndBuild(service: SemanticIndexService): Promise { @@ -127,8 +189,71 @@ async function waitForModelStatus( assert.equal(service.getModelStatus(), expected) } -test('explicit build indexes an enabled session to completion and reports status', async () => { - const { service, db } = setup() +test('service fixture removes its owned temporary directory after successful teardown', async (t) => { + const fixture = setup(t) + + assert.equal(fs.existsSync(fixture.dir), true) + await disposeFixture(fixture.cleanup) + + assert.equal(fs.existsSync(fixture.dir), false) +}) + +test('service fixture removes its owned temporary directory when the test body throws', async (t) => { + const fixture = setup(t) + + await assert.rejects(async () => { + try { + throw new Error('simulated test failure') + } finally { + await disposeFixture(fixture.cleanup) + } + }, /simulated test failure/) + + assert.equal(fs.existsSync(fixture.dir), false) +}) + +test('service fixture stops active work before cleanup after a timeout', async (t) => { + const fixture = setup(t, { embedDelayMs: 50 }) + + await assert.rejects(async () => { + try { + fixture.service.enable(SESSION_ID) + fixture.service.build(SESSION_ID) + await Promise.race([ + fixture.service.whenIdle(), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('simulated timeout')), 1) + }), + ]) + } finally { + await disposeFixture(fixture.cleanup) + } + }, /simulated timeout/) + + assert.equal(fs.existsSync(fixture.dir), false) +}) + +test('parallel service fixtures only remove their own temporary directories', async (t) => { + const first = setup(t, { embedDelayMs: 10 }) + const second = setup(t, { embedDelayMs: 10 }) + assert.notEqual(first.dir, second.dir) + + for (const fixture of [first, second]) { + fixture.service.enable(SESSION_ID) + fixture.service.build(SESSION_ID) + } + await Promise.all([first.service.whenIdle(), second.service.whenIdle()]) + + await disposeFixture(first.cleanup) + assert.equal(fs.existsSync(first.dir), false) + assert.equal(fs.existsSync(second.dir), true) + + await disposeFixture(second.cleanup) + assert.equal(fs.existsSync(second.dir), false) +}) + +test('explicit build indexes an enabled session to completion and reports status', async (t) => { + const { service } = setup(t) await enableAndBuild(service) const status = service.status(SESSION_ID)! @@ -138,12 +263,10 @@ test('explicit build indexes an enabled session to completion and reports status assert.equal(status.totalMessages, 40) assert.equal(status.needsRebuild, false) assert.ok(status.coverage > 0) - service.close() - db.close() }) -test('enable only marks a session enabled without starting a build', async () => { - const { service, db } = setup({ embedDelayMs: 25 }) +test('enable only marks a session enabled without starting a build', async (t) => { + const { service } = setup(t, { embedDelayMs: 25 }) service.enable(SESSION_ID) @@ -153,41 +276,34 @@ test('enable only marks a session enabled without starting a build', async () => assert.equal(status.queued, false) assert.equal(status.running, false) assert.equal(status.chunkCount, 0) - - service.close() - db.close() }) -test('search returns evidence blocks for an enabled completed index', async () => { - const { service, db } = setup() +test('search returns evidence blocks for an enabled completed index', async (t) => { + const { service } = setup(t) await enableAndBuild(service) const result = await service.search(SESSION_ID, '项目排期') assert.equal(result.available, true) assert.ok(result.blocks.length > 0) assert.equal(result.partial, false) - service.close() - db.close() }) -test('successful warmup retry clears a previous local model preload error', async () => { - const { service, db } = setup({ failFirstPipelineCreation: true }) +test('successful warmup retry clears a previous local model preload error', async (t) => { + const { service } = setup(t, { failFirstPipelineCreation: true }) await waitForModelStatus(service, 'error') await enableAndBuild(service) assert.equal(service.status(SESSION_ID)!.indexStatus, 'completed') assert.equal(service.getModelStatus(), 'ready') - service.close() - db.close() }) -test('stale local warmup completion does not mark current local model ready', async () => { +test('stale local warmup completion does not mark current local model ready', async (t) => { let resolveFirstBatchStarted: () => void = () => {} const firstBatchStarted = new Promise((resolve) => { resolveFirstBatchStarted = resolve }) - const { service, db } = setup({ + const { service } = setup(t, { embedDelayMs: 50, failPipelineCreationForModelIds: new Set([BGE_BASE_PROFILE.modelId]), onEmbedBatchStarted: resolveFirstBatchStarted, @@ -202,11 +318,9 @@ test('stale local warmup completion does not mark current local model ready', as await service.whenIdle() assert.equal(service.getModelStatus(), 'error') - service.close() - db.close() }) -test('stale model preload failure does not overwrite the current download source status', async () => { +test('stale model preload failure does not overwrite the current download source status', async (t) => { let resolveMirrorPreload: (extractor: FeatureExtractFn) => void = () => {} let rejectOfficialPreload: (reason?: unknown) => void = () => {} const officialPreload = new Promise((_resolve, reject) => { @@ -217,7 +331,7 @@ test('stale model preload failure does not overwrite the current download source }) const localPipelineFactory: LocalPipelineFactory = ({ modelDownloadSource }) => modelDownloadSource === 'hf-mirror' ? mirrorPreload : officialPreload - const { service, db } = setup({ localPipelineFactory }) + const { service } = setup(t, { localPipelineFactory }) service.setConfig({ version: 1, @@ -232,11 +346,9 @@ test('stale model preload failure does not overwrite the current download source await new Promise((resolve) => setImmediate(resolve)) assert.equal(service.getModelStatus(), 'ready') - service.close() - db.close() }) -test('stale model preload success does not overwrite the current download source status', async () => { +test('stale model preload success does not overwrite the current download source status', async (t) => { let resolveOfficialPreload: (extractor: FeatureExtractFn) => void = () => {} let rejectMirrorPreload: (reason?: unknown) => void = () => {} const officialPreload = new Promise((resolve) => { @@ -247,7 +359,7 @@ test('stale model preload success does not overwrite the current download source }) const localPipelineFactory: LocalPipelineFactory = ({ modelDownloadSource }) => modelDownloadSource === 'hf-mirror' ? mirrorPreload : officialPreload - const { service, db } = setup({ localPipelineFactory }) + const { service } = setup(t, { localPipelineFactory }) service.setConfig({ version: 1, @@ -262,12 +374,10 @@ test('stale model preload success does not overwrite the current download source await new Promise((resolve) => setImmediate(resolve)) assert.equal(service.getModelStatus(), 'error') - service.close() - db.close() }) -test('changing model identity marks needsRebuild and blocks search until rebuilt', async () => { - const { service, db } = setup() +test('changing model identity marks needsRebuild and blocks search until rebuilt', async (t) => { + const { service } = setup(t) await enableAndBuild(service) // 切换到不同模型身份(改用 API)-> 身份变化 -> 需重建 @@ -290,12 +400,10 @@ test('changing model identity marks needsRebuild and blocks search until rebuilt assert.equal(service.status(SESSION_ID)!.needsRebuild, false) const restored = await service.search(SESSION_ID, '项目排期') assert.equal(restored.available, true) - service.close() - db.close() }) -test('changing chunker identity marks needsRebuild and blocks search until rebuilt', async () => { - const { service, dir, db } = setup() +test('changing chunker identity marks needsRebuild and blocks search until rebuilt', async (t) => { + const { service, dir } = setup(t) await enableAndBuild(service) assert.equal(service.status(SESSION_ID)!.needsRebuild, false) assert.equal(service.canSearch(SESSION_ID), true) @@ -319,12 +427,10 @@ test('changing chunker identity marks needsRebuild and blocks search until rebui assert.equal(service.status(SESSION_ID)!.needsRebuild, false) const restored = await service.search(SESSION_ID, '项目排期') assert.equal(restored.available, true) - service.close() - db.close() }) -test('buildAllPending rebuilds a stale index back to searchable', async () => { - const { service, dir, db, getEmbedCount } = setup() +test('buildAllPending rebuilds a stale index back to searchable', async (t) => { + const { service, dir, getEmbedCount } = setup(t) await enableAndBuild(service) const afterFirstBuild = getEmbedCount() assert.ok(afterFirstBuild > 0) @@ -346,12 +452,10 @@ test('buildAllPending rebuilds a stale index back to searchable', async () => { assert.equal(service.canSearch(SESSION_ID), true) const result = await service.search(SESSION_ID, '项目排期') assert.equal(result.available, true) - service.close() - db.close() }) -test('re-enabling a stale index keeps it pending until explicit rebuild', async () => { - const { service, dir, db, getEmbedCount } = setup() +test('re-enabling a stale index keeps it pending until explicit rebuild', async (t) => { + const { service, dir, getEmbedCount } = setup(t) await enableAndBuild(service) const afterFirstBuild = getEmbedCount() @@ -374,12 +478,10 @@ test('re-enabling a stale index keeps it pending until explicit rebuild', async assert.ok(getEmbedCount() > afterFirstBuild, 'explicit pending rebuild should re-embed chunks') assert.equal(service.status(SESSION_ID)!.needsRebuild, false) assert.equal(service.canSearch(SESSION_ID), true) - service.close() - db.close() }) -test('remove clears index immediately and blocks search', async () => { - const { service, db } = setup() +test('remove clears index immediately and blocks search', async (t) => { + const { service } = setup(t) await enableAndBuild(service) service.remove(SESSION_ID) @@ -388,12 +490,10 @@ test('remove clears index immediately and blocks search', async () => { const blocked = await service.search(SESSION_ID, '排期') assert.equal(blocked.available, false) // cleanupUnused not needed; remove() is already immediate - service.close() - db.close() }) -test('re-enabling after remove rebuilds from scratch', async () => { - const { service, db, getEmbedCount } = setup() +test('re-enabling after remove rebuilds from scratch', async (t) => { + const { service, getEmbedCount } = setup(t) await enableAndBuild(service) const afterFirstBuild = getEmbedCount() @@ -406,12 +506,10 @@ test('re-enabling after remove rebuilds from scratch', async () => { assert.equal(service.canSearch(SESSION_ID), true) const result = await service.search(SESSION_ID, '项目排期') assert.equal(result.available, true) - service.close() - db.close() }) -test('rebuild wipes and rebuilds the index', async () => { - const { service, db } = setup() +test('rebuild wipes and rebuilds the index', async (t) => { + const { service } = setup(t) await enableAndBuild(service) const before = service.status(SESSION_ID)!.chunkCount @@ -420,12 +518,10 @@ test('rebuild wipes and rebuilds the index', async () => { const after = service.status(SESSION_ID)! assert.equal(after.indexStatus, 'completed') assert.equal(after.chunkCount, before) - service.close() - db.close() }) -test('setConfig stores API key by reference only and hasApiKey reflects it', async () => { - const { service, authProfiles, db } = setup() +test('setConfig stores API key by reference only and hasApiKey reflects it', async (t) => { + const { service, authProfiles } = setup(t) assert.equal(service.hasApiKey(), false) @@ -440,12 +536,10 @@ test('setConfig stores API key by reference only and hasApiKey reflects it', asy // 明文写入 auth profile 存储 assert.equal(authProfiles.get('semantic-index-embedding')?.key, 'sk-secret-123') assert.equal(service.hasApiKey(), true) - service.close() - db.close() }) -test('keyless local Ollama API config can run without an auth profile', async () => { - const { service, db } = setup() +test('keyless local Ollama API config can run without an auth profile', async (t) => { + const { service } = setup(t) service.setConfig({ version: 1, @@ -457,12 +551,10 @@ test('keyless local Ollama API config can run without an auth profile', async () assert.equal(service.hasApiKey(), false) assert.equal(service.isConfigured(), true) assert.equal(service.canRun(), true) - service.close() - db.close() }) -test('canSearch reflects availability: disabled/needs-rebuild/empty are false', async () => { - const { service, db } = setup() +test('canSearch reflects availability: disabled/needs-rebuild/empty are false', async (t) => { + const { service } = setup(t) // 未启用 assert.equal(service.canSearch(SESSION_ID), false) @@ -494,12 +586,10 @@ test('canSearch reflects availability: disabled/needs-rebuild/empty are false', searchMaxResults: 5, }) assert.equal(service.canSearch(SESSION_ID), true) - service.close() - db.close() }) -test('searchForTool desensitizes + anonymizes via pipeline, never leaks raw', async () => { - const { service, db } = setup() +test('searchForTool desensitizes + anonymizes via pipeline, never leaks raw', async (t) => { + const { service } = setup(t) await enableAndBuild(service) const result = await service.searchForTool(SESSION_ID, '项目排期', { @@ -552,13 +642,10 @@ test('searchForTool desensitizes + anonymizes via pipeline, never leaks raw', as const fiveDigitYear = /\b\d{5}\/\d{1,2}\/\d{1,2}/ assert.equal(fiveDigitYear.test(result.text), false) assert.ok(result.text.includes('2023/')) - - service.close() - db.close() }) -test('disabling the global switch blocks search and re-enabling keeps data usable', async () => { - const { service, db } = setup() +test('disabling the global switch blocks search and re-enabling keeps data usable', async (t) => { + const { service } = setup(t) await enableAndBuild(service) assert.equal(service.canSearch(SESSION_ID), true) @@ -573,12 +660,10 @@ test('disabling the global switch blocks search and re-enabling keeps data usabl // 重新开启后可直接使用保留的索引 service.setConfig({ version: 1, enabled: true, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) assert.equal(service.canSearch(SESSION_ID), true) - service.close() - db.close() }) -test('searchForTool is unavailable when disabled or empty query', async () => { - const { service, db } = setup() +test('searchForTool is unavailable when disabled or empty query', async (t) => { + const { service } = setup(t) const disabled = await service.searchForTool(SESSION_ID, '项目排期') assert.equal(disabled.available, false) @@ -588,12 +673,10 @@ test('searchForTool is unavailable when disabled or empty query', async () => { const empty = await service.searchForTool(SESSION_ID, ' ') assert.equal(empty.available, false) assert.equal(empty.reason, 'empty-query') - service.close() - db.close() }) -test('searchForTool uses configured default max results when caller omits it', async () => { - const { service, db } = setup() +test('searchForTool uses configured default max results when caller omits it', async (t) => { + const { service } = setup(t) await enableAndBuild(service) service.setConfig({ version: 1, @@ -606,12 +689,10 @@ test('searchForTool uses configured default max results when caller omits it', a const result = await service.searchForTool(SESSION_ID, '项目排期') assert.equal(result.available, true) assert.ok(result.returned <= 5) - service.close() - db.close() }) -test('searchForTool honors maxResultTokens as evidence budget ceiling', async () => { - const { service, db } = setup() +test('searchForTool honors maxResultTokens as evidence budget ceiling', async (t) => { + const { service } = setup(t) await enableAndBuild(service) const tiny = await service.searchForTool(SESSION_ID, '项目排期', { maxResults: 10, maxResultTokens: 10 }) @@ -622,12 +703,10 @@ test('searchForTool honors maxResultTokens as evidence budget ceiling', async () // 小预算必须显著截断证据文本,证明 maxResultTokens 被服务层消费 assert.ok(tiny.text.length < big.text.length) assert.ok(tiny.returned <= big.returned) - service.close() - db.close() }) -test('searchForTool applies timeFilter to exclude out-of-range chunks', async () => { - const { service, db } = setup() +test('searchForTool applies timeFilter to exclude out-of-range chunks', async (t) => { + const { service } = setup(t) await enableAndBuild(service) // 不带时间过滤:应有命中 @@ -643,12 +722,10 @@ test('searchForTool applies timeFilter to exclude out-of-range chunks', async () }) assert.equal(future.sources.length, 0) assert.equal(future.returned, 0) - service.close() - db.close() }) function tempPersistStore(prefix: string): SemanticIndexConfigStore { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = serviceTempBaseDir() const dir = fs.mkdtempSync(path.join(baseDir, prefix)) return new SemanticIndexConfigStore(path.join(dir, 'ai', 'semantic-index-config.json')) } @@ -690,8 +767,8 @@ test('persistSemanticIndexConfig ignores apiKey in local mode (no auth profile w assert.equal(resolveSemanticIndexApiKeySet(saved), false) }) -test('recover marks stale running as paused without auto-resuming', async () => { - const { service, dir, db } = setup() +test('recover marks stale running as paused without auto-resuming', async (t) => { + const { service, dir } = setup(t) await enableAndBuild(service) // 模拟崩溃:另一连接把状态置为 running @@ -701,6 +778,4 @@ test('recover marks stale running as paused without auto-resuming', async () => service.recover() assert.equal(service.status(SESSION_ID)!.indexStatus, 'paused') - service.close() - db.close() }) diff --git a/packages/node-runtime/src/semantic-index/session-state-store.test.ts b/packages/node-runtime/src/semantic-index/session-state-store.test.ts index 3e23a104..4a58b38f 100644 --- a/packages/node-runtime/src/semantic-index/session-state-store.test.ts +++ b/packages/node-runtime/src/semantic-index/session-state-store.test.ts @@ -6,7 +6,7 @@ import test from 'node:test' import { SemanticIndexStateStore } from './session-state-store' function makeTempDbPath(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-sis-')) return path.join(dir, 'embedding_index.db') } diff --git a/packages/node-runtime/src/semantic-index/store-mutations.test.ts b/packages/node-runtime/src/semantic-index/store-mutations.test.ts index d021ebe7..66cbdb87 100644 --- a/packages/node-runtime/src/semantic-index/store-mutations.test.ts +++ b/packages/node-runtime/src/semantic-index/store-mutations.test.ts @@ -8,7 +8,7 @@ import { STRATEGY_ID } from './chunker-config' import type { ChunkRecord } from './types' function makeStore() { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-store-mut-')) return new EmbeddingIndexStore(path.join(dir, 'embedding_index.db')) } diff --git a/packages/node-runtime/src/semantic-index/store.test.ts b/packages/node-runtime/src/semantic-index/store.test.ts index 081c641b..0a8eb5f0 100644 --- a/packages/node-runtime/src/semantic-index/store.test.ts +++ b/packages/node-runtime/src/semantic-index/store.test.ts @@ -8,7 +8,7 @@ import { EmbeddingIndexStore } from './store' import type { ChunkRecord } from './types' function makeTempDbPath(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-embidx-')) return path.join(dir, 'embedding_index.db') } diff --git a/packages/node-runtime/src/semantic-index/warmup/runner.test.ts b/packages/node-runtime/src/semantic-index/warmup/runner.test.ts index f2951077..a7052404 100644 --- a/packages/node-runtime/src/semantic-index/warmup/runner.test.ts +++ b/packages/node-runtime/src/semantic-index/warmup/runner.test.ts @@ -15,7 +15,7 @@ const DB_HASH = 'dbA' const MODEL = 'fake' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-warmup-')) } diff --git a/packages/node-runtime/src/semantic-index/worker-client.test.ts b/packages/node-runtime/src/semantic-index/worker-client.test.ts index 6ff80a42..38f42d48 100644 --- a/packages/node-runtime/src/semantic-index/worker-client.test.ts +++ b/packages/node-runtime/src/semantic-index/worker-client.test.ts @@ -13,7 +13,10 @@ import { function makeConfigStore(): SemanticIndexConfigStore { const dir = fs.mkdtempSync( - path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-si-client-') + path.join( + process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()), + 'chatlab-si-client-' + ) ) return new SemanticIndexConfigStore(path.join(dir, 'semantic-index-config.json')) } diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts index 1580a288..690fe83c 100644 --- a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts @@ -10,7 +10,7 @@ import { getContactsFactsCacheDir } from '../contacts/paths' import { getGlobalInsightDir, getGlobalInsightFactsCacheDir } from '../global-insight/paths' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-adapter-')) } diff --git a/packages/node-runtime/src/services/contacts/facts-cache.test.ts b/packages/node-runtime/src/services/contacts/facts-cache.test.ts index 959bf8f0..e8234cde 100644 --- a/packages/node-runtime/src/services/contacts/facts-cache.test.ts +++ b/packages/node-runtime/src/services/contacts/facts-cache.test.ts @@ -17,7 +17,7 @@ import { } from './facts-cache' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-facts-cache-')) } diff --git a/packages/node-runtime/src/services/contacts/service.test.ts b/packages/node-runtime/src/services/contacts/service.test.ts index 718c19bb..a4d176e9 100644 --- a/packages/node-runtime/src/services/contacts/service.test.ts +++ b/packages/node-runtime/src/services/contacts/service.test.ts @@ -21,7 +21,7 @@ import { createContactsService } from './service' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-service-')) } diff --git a/packages/node-runtime/src/services/contacts/signature.test.ts b/packages/node-runtime/src/services/contacts/signature.test.ts index d6304a77..d69d5e97 100644 --- a/packages/node-runtime/src/services/contacts/signature.test.ts +++ b/packages/node-runtime/src/services/contacts/signature.test.ts @@ -12,7 +12,7 @@ import { CONTACTS_ALGORITHM_VERSION } from './compute' import { buildContactsSignature } from './signature' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-signature-')) } diff --git a/packages/node-runtime/src/services/contacts/snapshot.test.ts b/packages/node-runtime/src/services/contacts/snapshot.test.ts index 5ea3310c..125dd13c 100644 --- a/packages/node-runtime/src/services/contacts/snapshot.test.ts +++ b/packages/node-runtime/src/services/contacts/snapshot.test.ts @@ -17,7 +17,7 @@ import { } from './snapshot' function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-snapshot-')) } diff --git a/packages/node-runtime/src/services/owner-profile-service.test.ts b/packages/node-runtime/src/services/owner-profile-service.test.ts index 8f557e65..dad3e777 100644 --- a/packages/node-runtime/src/services/owner-profile-service.test.ts +++ b/packages/node-runtime/src/services/owner-profile-service.test.ts @@ -25,7 +25,7 @@ import { const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-owner-profile-')) } diff --git a/packages/node-runtime/src/services/people/relationships/compute.test.ts b/packages/node-runtime/src/services/people/relationships/compute.test.ts index fe4db785..a9207b9e 100644 --- a/packages/node-runtime/src/services/people/relationships/compute.test.ts +++ b/packages/node-runtime/src/services/people/relationships/compute.test.ts @@ -51,7 +51,12 @@ interface SeedSession { } class TestEnv { - readonly dir = fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-')) + readonly dir = fs.mkdtempSync( + path.join( + process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()), + 'chatlab-rel-' + ) + ) readonly adapter: SessionRuntimeAdapter private dbPaths = new Map() private openDbs: DatabaseAdapter[] = [] diff --git a/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts b/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts index 656e2e9b..63f5ecea 100644 --- a/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts +++ b/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts @@ -17,7 +17,12 @@ import { } from './facts-cache' function makeTempDir(): string { - return fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-cache-')) + return fs.mkdtempSync( + path.join( + process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()), + 'chatlab-rel-cache-' + ) + ) } const range1y: ContactsTimeRangeState = { diff --git a/packages/node-runtime/src/services/people/relationships/service.test.ts b/packages/node-runtime/src/services/people/relationships/service.test.ts index a7b6b936..3e65cffd 100644 --- a/packages/node-runtime/src/services/people/relationships/service.test.ts +++ b/packages/node-runtime/src/services/people/relationships/service.test.ts @@ -18,7 +18,12 @@ import { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, type PeopleRelationshipsSnapsho import { createPeopleRelationshipsService } from './service' function makeTempDir(): string { - return fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-service-')) + return fs.mkdtempSync( + path.join( + process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()), + 'chatlab-rel-service-' + ) + ) } function makePathProvider(rootDir: string): PathProvider { diff --git a/packages/node-runtime/src/services/push-importer.test.ts b/packages/node-runtime/src/services/push-importer.test.ts index dcb9b5f3..78ab0a37 100644 --- a/packages/node-runtime/src/services/push-importer.test.ts +++ b/packages/node-runtime/src/services/push-importer.test.ts @@ -12,7 +12,7 @@ import { executeAnalyzePushImport, pushImport } from './push-importer' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') function makeTempDir(): string { - const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()) return fs.mkdtempSync(path.join(baseDir, 'chatlab-push-import-')) } diff --git a/packages/node-runtime/src/temp-workspace.test.ts b/packages/node-runtime/src/temp-workspace.test.ts new file mode 100644 index 00000000..cfd29c5d --- /dev/null +++ b/packages/node-runtime/src/temp-workspace.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + CHATLAB_TEMP_ROOT_ENV, + createChatLabTempDir, + ensureChatLabTempRoot, + getChatLabTempScopeDir, + removeChatLabTempDir, + resolveChatLabTempRoot, +} from './temp-workspace' + +function makeTestRoot(): string { + return fs.mkdtempSync(path.join(process.env.CHATLAB_TEST_TMPDIR ?? os.tmpdir(), 'chatlab-temp-workspace-')) +} + +test('resolveChatLabTempRoot uses /private/tmp/chatlab on macOS and the system temp directory elsewhere', () => { + assert.equal( + resolveChatLabTempRoot({ platform: 'darwin', systemTempDir: '/ignored', env: {} }), + '/private/tmp/chatlab' + ) + assert.equal( + resolveChatLabTempRoot({ platform: 'linux', systemTempDir: '/tmp', env: {} }), + path.resolve('/tmp/chatlab') + ) +}) + +test('explicit CHATLAB_TEMP_ROOT overrides the platform default', () => { + assert.equal( + resolveChatLabTempRoot({ + platform: 'darwin', + systemTempDir: '/ignored', + env: { [CHATLAB_TEMP_ROOT_ENV]: '/custom/chatlab-temp' }, + }), + path.resolve('/custom/chatlab-temp') + ) +}) + +test('ensureChatLabTempRoot creates a private root and publishes it to child processes', (t) => { + const parent = makeTestRoot() + const root = path.join(parent, 'chatlab') + const env: NodeJS.ProcessEnv = { [CHATLAB_TEMP_ROOT_ENV]: root } + t.after(() => fs.rmSync(parent, { recursive: true, force: true })) + + assert.equal(ensureChatLabTempRoot({ env }), root) + assert.equal(env[CHATLAB_TEMP_ROOT_ENV], root) + assert.equal(fs.statSync(root).isDirectory(), true) + if (process.platform !== 'win32') { + assert.equal(fs.statSync(root).mode & 0o777, 0o700) + } +}) + +test('scoped temp directories are isolated and cleanup never crosses scope boundaries', (t) => { + const parent = makeTestRoot() + const root = path.join(parent, 'chatlab') + const options = { env: { [CHATLAB_TEMP_ROOT_ENV]: root } } + t.after(() => fs.rmSync(parent, { recursive: true, force: true })) + + const first = createChatLabTempDir('imports', 'job-', options) + const second = createChatLabTempDir('imports', 'job-', options) + const merge = createChatLabTempDir('merge', 'job-', options) + + assert.notEqual(first, second) + assert.equal(path.dirname(first), getChatLabTempScopeDir('imports', options)) + assert.equal(path.dirname(merge), getChatLabTempScopeDir('merge', options)) + + removeChatLabTempDir(first, 'imports', options) + assert.equal(fs.existsSync(first), false) + assert.equal(fs.existsSync(second), true) + assert.equal(fs.existsSync(merge), true) + assert.throws(() => removeChatLabTempDir(merge, 'imports', options), /Refusing to remove non-owned/) +}) + +test('temp workspace rejects invalid scopes, prefixes, and symlink roots', (t) => { + const parent = makeTestRoot() + const root = path.join(parent, 'chatlab') + const options = { env: { [CHATLAB_TEMP_ROOT_ENV]: root } } + t.after(() => fs.rmSync(parent, { recursive: true, force: true })) + + assert.throws(() => getChatLabTempScopeDir('../outside' as never, options), /Invalid ChatLab temp scope/) + assert.throws(() => createChatLabTempDir('runtime', '../job-', options), /Invalid ChatLab temp prefix/) + + if (process.platform !== 'win32') { + const target = path.join(parent, 'target') + fs.mkdirSync(target) + fs.symlinkSync(target, root) + assert.throws(() => ensureChatLabTempRoot(options), /symbolic link/) + } +}) diff --git a/packages/node-runtime/src/temp-workspace.ts b/packages/node-runtime/src/temp-workspace.ts new file mode 100644 index 00000000..37600073 --- /dev/null +++ b/packages/node-runtime/src/temp-workspace.ts @@ -0,0 +1,93 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +export const CHATLAB_TEMP_ROOT_ENV = 'CHATLAB_TEMP_ROOT' + +const CHATLAB_TEMP_SCOPES = new Set(['tests', 'runtime', 'imports', 'merge', 'sync', 'parser', 'build', 'bench']) + +export type ChatLabTempScope = 'tests' | 'runtime' | 'imports' | 'merge' | 'sync' | 'parser' | 'build' | 'bench' + +export interface ChatLabTempRootOptions { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + systemTempDir?: string +} + +function optionsEnv(options: ChatLabTempRootOptions): NodeJS.ProcessEnv { + return options.env ?? process.env +} + +export function resolveChatLabTempRoot(options: ChatLabTempRootOptions = {}): string { + const envRoot = optionsEnv(options)[CHATLAB_TEMP_ROOT_ENV]?.trim() + if (envRoot) return path.resolve(envRoot) + + const platform = options.platform ?? process.platform + const systemTempDir = options.systemTempDir ?? os.tmpdir() + return path.resolve(platform === 'darwin' ? '/private/tmp/chatlab' : path.join(systemTempDir, 'chatlab')) +} + +function ensurePrivateOwnedDirectory(dirPath: string, label: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }) + const stats = fs.lstatSync(dirPath) + if (stats.isSymbolicLink()) { + throw new Error(`${label} must not be a symbolic link: ${dirPath}`) + } + if (!stats.isDirectory()) { + throw new Error(`${label} must be a directory: ${dirPath}`) + } + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) { + throw new Error(`${label} is owned by another user: ${dirPath}`) + } + if (process.platform !== 'win32') { + fs.chmodSync(dirPath, 0o700) + } +} + +export function ensureChatLabTempRoot(options: ChatLabTempRootOptions = {}): string { + const root = resolveChatLabTempRoot(options) + ensurePrivateOwnedDirectory(root, 'ChatLab temp root') + optionsEnv(options)[CHATLAB_TEMP_ROOT_ENV] = root + return root +} + +function assertValidScope(scope: string): asserts scope is ChatLabTempScope { + if (!CHATLAB_TEMP_SCOPES.has(scope)) { + throw new Error(`Invalid ChatLab temp scope: ${scope}`) + } +} + +function assertValidPrefix(prefix: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(prefix)) { + throw new Error(`Invalid ChatLab temp prefix: ${prefix}`) + } +} + +export function getChatLabTempScopeDir(scope: ChatLabTempScope, options: ChatLabTempRootOptions = {}): string { + assertValidScope(scope) + const scopeDir = path.join(ensureChatLabTempRoot(options), scope) + ensurePrivateOwnedDirectory(scopeDir, 'ChatLab temp scope') + return scopeDir +} + +export function createChatLabTempDir( + scope: ChatLabTempScope, + prefix: string, + options: ChatLabTempRootOptions = {} +): string { + assertValidPrefix(prefix) + return fs.mkdtempSync(path.join(getChatLabTempScopeDir(scope, options), prefix)) +} + +export function removeChatLabTempDir( + dirPath: string, + scope: ChatLabTempScope, + options: ChatLabTempRootOptions = {} +): void { + const scopeDir = path.resolve(getChatLabTempScopeDir(scope, options)) + const resolvedDir = path.resolve(dirPath) + if (path.dirname(resolvedDir) !== scopeDir) { + throw new Error(`Refusing to remove non-owned ChatLab temp directory: ${resolvedDir}`) + } + fs.rmSync(resolvedDir, { recursive: true, force: true }) +} diff --git a/packages/parser/src/formats/shuakami-qq-preprocessor.ts b/packages/parser/src/formats/shuakami-qq-preprocessor.ts index 394ec229..e17749c5 100644 --- a/packages/parser/src/formats/shuakami-qq-preprocessor.ts +++ b/packages/parser/src/formats/shuakami-qq-preprocessor.ts @@ -28,7 +28,11 @@ const PREPROCESS_THRESHOLD = 50 * 1024 * 1024 * 获取临时目录 */ function getTempDir(): string { - return path.join(os.tmpdir(), 'chatlab') + const configuredRoot = process.env.CHATLAB_TEMP_ROOT?.trim() + const root = configuredRoot + ? path.resolve(configuredRoot) + : path.resolve(process.platform === 'darwin' ? '/private/tmp/chatlab' : path.join(os.tmpdir(), 'chatlab')) + return path.join(root, 'parser') } /** @@ -36,7 +40,7 @@ function getTempDir(): string { */ function ensureDir(dir: string): void { if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } } @@ -355,7 +359,7 @@ async function preprocessQQJson(inputPath: string, onProgress?: (progress: Parse */ function cleanupTempFile(filePath: string): void { try { - if (fs.existsSync(filePath) && filePath.includes(getTempDir())) { + if (fs.existsSync(filePath) && path.dirname(path.resolve(filePath)) === path.resolve(getTempDir())) { fs.unlinkSync(filePath) } } catch { diff --git a/scripts/bench-weflow-parser.mts b/scripts/bench-weflow-parser.mts index ecc26d8a..9fbdc230 100644 --- a/scripts/bench-weflow-parser.mts +++ b/scripts/bench-weflow-parser.mts @@ -9,10 +9,10 @@ * Requires the native module to be built first: pnpm build:native */ -import { createWriteStream, mkdtempSync, rmSync, statSync } from 'node:fs' +import { createWriteStream, rmSync, statSync } from 'node:fs' import { join } from 'node:path' -import { tmpdir } from 'node:os' import { once } from 'node:events' +import { createChatLabTempDir } from './chatlab-temp.mjs' import { parseFileSync } from '../packages/parser/src/index' import { loadNativeParser } from '../packages/parser/src/native/loader' @@ -115,7 +115,7 @@ async function main() { process.exit(1) } - const dir = mkdtempSync(join(tmpdir(), 'chatlab-weflow-bench-')) + const dir = createChatLabTempDir('bench', 'weflow-') const filePath = join(dir, 'bench.json') try { console.log(`Generating fixture with ${messageCount.toLocaleString()} messages...`) diff --git a/scripts/chatlab-temp.mjs b/scripts/chatlab-temp.mjs new file mode 100644 index 00000000..ca1ff7aa --- /dev/null +++ b/scripts/chatlab-temp.mjs @@ -0,0 +1,39 @@ +import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export const CHATLAB_TEMP_ROOT_ENV = 'CHATLAB_TEMP_ROOT' + +const CHATLAB_TEMP_SCOPES = new Set(['tests', 'runtime', 'imports', 'merge', 'sync', 'parser', 'build', 'bench']) + +export function resolveChatLabTempRoot(env = process.env) { + const configured = env[CHATLAB_TEMP_ROOT_ENV]?.trim() + if (configured) return resolve(configured) + return resolve(process.platform === 'darwin' ? '/private/tmp/chatlab' : join(tmpdir(), 'chatlab')) +} + +function ensurePrivateDirectory(dirPath) { + mkdirSync(dirPath, { recursive: true, mode: 0o700 }) + const stats = lstatSync(dirPath) + if (stats.isSymbolicLink()) throw new Error(`ChatLab temp directory must not be a symbolic link: ${dirPath}`) + if (!stats.isDirectory()) throw new Error(`ChatLab temp path must be a directory: ${dirPath}`) + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) { + throw new Error(`ChatLab temp directory is owned by another user: ${dirPath}`) + } + if (process.platform !== 'win32') chmodSync(dirPath, 0o700) +} + +export function getChatLabTempScopeDir(scope, env = process.env) { + if (!CHATLAB_TEMP_SCOPES.has(scope)) throw new Error(`Invalid ChatLab temp scope: ${scope}`) + const root = resolveChatLabTempRoot(env) + ensurePrivateDirectory(root) + env[CHATLAB_TEMP_ROOT_ENV] = root + const scopeDir = join(root, scope) + ensurePrivateDirectory(scopeDir) + return scopeDir +} + +export function createChatLabTempDir(scope, prefix, env = process.env) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(prefix)) throw new Error(`Invalid ChatLab temp prefix: ${prefix}`) + return mkdtempSync(join(getChatLabTempScopeDir(scope, env), prefix)) +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index a23ea91c..401c3a8d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node -import { readdirSync, statSync } from 'node:fs' -import { join, relative, sep } from 'node:path' +import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs' +import { basename, dirname, join, relative, resolve, sep } from 'node:path' import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import { CHATLAB_TEMP_ROOT_ENV, getChatLabTempScopeDir } from './chatlab-temp.mjs' const SKIP_DIRS = new Set([ '.git', @@ -19,6 +20,8 @@ const SKIP_DIRS = new Set([ const TEST_FILE_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|mts|cjs|cts)$/ const SUPPORTED_NODE_MAJOR = 24 +const TEST_RUN_TEMP_PREFIX = 'run-' +export const TEST_RUN_TEMP_ENV = 'CHATLAB_TEST_TMPDIR' function normalizePath(filePath) { return filePath.split(sep).join('/') @@ -74,6 +77,24 @@ export function checkSupportedNodeVersion(version = process.versions.node) { } } +export function buildTestEnvironment(tempRoot, env = process.env) { + return { ...env, TMPDIR: tempRoot, [TEST_RUN_TEMP_ENV]: tempRoot, [CHATLAB_TEMP_ROOT_ENV]: tempRoot } +} + +export function withTestRunTempRoot(callback, parentDir = getChatLabTempScopeDir('tests')) { + const resolvedParent = resolve(parentDir) + const tempRoot = mkdtempSync(join(resolvedParent, TEST_RUN_TEMP_PREFIX)) + const resolvedRoot = resolve(tempRoot) + if (dirname(resolvedRoot) !== resolvedParent || !basename(resolvedRoot).startsWith(TEST_RUN_TEMP_PREFIX)) { + throw new Error(`Refusing to use non-owned test run temp root: ${resolvedRoot}`) + } + try { + return callback(tempRoot) + } finally { + rmSync(resolvedRoot, { recursive: true, force: true }) + } +} + function run() { const nodeVersion = checkSupportedNodeVersion() if (!nodeVersion.ok) { @@ -93,9 +114,12 @@ function run() { console.log(`Running ${testArgs.length} default test files.`) } - const result = spawnSync(process.execPath, buildNodeTestArgs(testArgs), { - stdio: 'inherit', - }) + const result = withTestRunTempRoot((tempRoot) => + spawnSync(process.execPath, buildNodeTestArgs(testArgs), { + stdio: 'inherit', + env: buildTestEnvironment(tempRoot), + }) + ) if (result.error) { throw result.error diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs index fabbabb5..0dc8f066 100644 --- a/scripts/run-tests.test.mjs +++ b/scripts/run-tests.test.mjs @@ -1,7 +1,16 @@ import assert from 'node:assert/strict' +import { existsSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import test from 'node:test' -import { buildNodeTestArgs, checkSupportedNodeVersion, filterDefaultTestFiles } from './run-tests.mjs' +import { + buildNodeTestArgs, + buildTestEnvironment, + checkSupportedNodeVersion, + filterDefaultTestFiles, + withTestRunTempRoot, +} from './run-tests.mjs' +import { CHATLAB_TEMP_ROOT_ENV } from './chatlab-temp.mjs' test('default test collection excludes e2e, smoke, and real external tests', () => { const files = [ @@ -39,3 +48,65 @@ test('node version check rejects unsupported test runtimes before native modules 'ChatLab tests require Node.js >=24 <25. Current Node.js is 22.20.0. Switch to Node 24 before running tests.', }) }) + +test('test run temp root is removed after success and exceptions', () => { + let successfulRoot = '' + withTestRunTempRoot((tempRoot) => { + successfulRoot = tempRoot + writeFileSync(`${tempRoot}/success.txt`, 'ok') + assert.equal(existsSync(tempRoot), true) + }) + assert.equal(existsSync(successfulRoot), false) + + let failedRoot = '' + assert.throws( + () => + withTestRunTempRoot((tempRoot) => { + failedRoot = tempRoot + throw new Error('simulated test runner failure') + }), + /simulated test runner failure/ + ) + assert.equal(existsSync(failedRoot), false) +}) + +test('test subprocesses receive the owned run root as every temp environment entry', () => { + const env = buildTestEnvironment('/tmp/chatlab/tests/run-example', { KEEP_ME: 'yes' }) + assert.equal(env.TMPDIR, '/tmp/chatlab/tests/run-example') + assert.equal(env.CHATLAB_TEST_TMPDIR, '/tmp/chatlab/tests/run-example') + assert.equal(env[CHATLAB_TEMP_ROOT_ENV], '/tmp/chatlab/tests/run-example') + assert.equal(env.KEEP_ME, 'yes') +}) + +test('interrupted child exits before its owned test run temp root is removed', () => { + let tempRoot = '' + const result = withTestRunTempRoot((root) => { + tempRoot = root + return spawnSync(process.execPath, ['-e', "process.kill(process.pid, 'SIGTERM')"], { + env: buildTestEnvironment(root), + }) + }) + + assert.equal(result.signal, 'SIGTERM') + assert.equal(existsSync(tempRoot), false) +}) + +test('parallel test runs receive isolated temp roots and never remove each other', () => { + let firstRoot = '' + let secondRoot = '' + + withTestRunTempRoot((rootA) => { + firstRoot = rootA + withTestRunTempRoot((rootB) => { + secondRoot = rootB + assert.notEqual(rootA, rootB) + assert.equal(existsSync(rootA), true) + assert.equal(existsSync(rootB), true) + }) + assert.equal(existsSync(rootA), true) + assert.equal(existsSync(secondRoot), false) + }) + + assert.equal(existsSync(firstRoot), false) + assert.equal(existsSync(secondRoot), false) +}) diff --git a/tests/e2e/run-smoke.js b/tests/e2e/run-smoke.js index 52a16f93..eb251b73 100644 --- a/tests/e2e/run-smoke.js +++ b/tests/e2e/run-smoke.js @@ -1,15 +1,16 @@ 'use strict' const { spawnSync } = require('node:child_process') +const path = require('node:path') -process.env.CHATLAB_RUN_E2E_SMOKE = '1' +const testRunner = path.resolve(__dirname, '../../scripts/run-tests.mjs') const result = spawnSync( process.execPath, - ['--test', 'tests/e2e/smoke/app-launcher.smoke.test.js', 'tests/e2e/smoke/chart-runtime.smoke.test.js'], + [testRunner, 'tests/e2e/smoke/app-launcher.smoke.test.js', 'tests/e2e/smoke/chart-runtime.smoke.test.js'], { stdio: 'inherit', - env: process.env, + env: { ...process.env, CHATLAB_RUN_E2E_SMOKE: '1' }, } )