From 2bb5eb3715697864c48f903bac1c9f4c06449f80 Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 1 Aug 2026 16:29:30 +0800 Subject: [PATCH] test(apps): strengthen application and build verification --- apps/cli/package.json | 2 +- apps/cli/src/http/auth.test.ts | 90 ++---- apps/cli/src/query/commands-stats.test.ts | 42 ++- apps/cli/src/query/envelope.test.ts | 61 ++-- apps/cli/src/release-workflow.test.ts | 28 -- apps/desktop/electron-builder.yml | 2 + .../main/database/startup-migration.test.ts | 25 -- apps/desktop/main/paths/paths.test.ts | 271 ++++++++++-------- .../main/security/lock-manager.test.ts | 239 +++++++-------- .../main/semantic-index-runtime-deps.test.ts | 23 -- apps/desktop/main/window/titlebar.test.ts | 57 ---- .../main/worker/core/perfLogger.test.ts | 25 -- apps/desktop/package.json | 2 +- .../scripts/verify-packaged-local-runtime.mjs | 55 ++++ .../session/insights/SessionInsights.test.ts | 10 - apps/web-wasm/src/routes.test.ts | 14 - scripts/check-build-artifacts.mjs | 51 ++++ tests/build-worker-entries.test.ts | 27 -- 18 files changed, 465 insertions(+), 559 deletions(-) delete mode 100644 apps/cli/src/release-workflow.test.ts delete mode 100644 apps/desktop/main/semantic-index-runtime-deps.test.ts delete mode 100644 apps/desktop/main/window/titlebar.test.ts delete mode 100644 apps/desktop/main/worker/core/perfLogger.test.ts create mode 100644 apps/desktop/scripts/verify-packaged-local-runtime.mjs delete mode 100644 apps/web-wasm/src/components/session/insights/SessionInsights.test.ts delete mode 100644 apps/web-wasm/src/routes.test.ts create mode 100644 scripts/check-build-artifacts.mjs delete mode 100644 tests/build-worker-entries.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 1b246793..e7ad880e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -35,7 +35,7 @@ ], "scripts": { "cli": "node scripts/ensure-native.mjs && tsx src/cli.ts", - "build": "tsup", + "build": "tsup && node ../../scripts/check-build-artifacts.mjs cli", "build:full": "pnpm -w run build:cli-web && pnpm run build && pnpm run prepare:cli-web", "prepare:cli-web": "rm -rf dist-cli-web && cp -r ../../dist-cli-web dist-cli-web", "ensure-native": "node scripts/ensure-native.mjs", diff --git a/apps/cli/src/http/auth.test.ts b/apps/cli/src/http/auth.test.ts index 766c7250..02debf44 100644 --- a/apps/cli/src/http/auth.test.ts +++ b/apps/cli/src/http/auth.test.ts @@ -45,26 +45,6 @@ describe('authHook — authentication matrix', () => { assert.equal(reply.statusCode, 0, '/api/* should pass without token configured') }) - // ── /api/* always requires auth ── - - it('rejects /api/* without Bearer header', async () => { - const reply = fakeReply() - await authHook(fakeRequest('/api/v1/status'), reply as never) - assert.equal(reply.statusCode, 401) - }) - - it('rejects /api/* with wrong token', async () => { - const reply = fakeReply() - await authHook(fakeRequest('/api/v1/status', 'Bearer wrong_token'), reply as never) - assert.equal(reply.statusCode, 401) - }) - - it('allows /api/* with correct token', async () => { - const reply = fakeReply() - await authHook(fakeRequest('/api/v1/status', `Bearer ${VALID_TOKEN}`), reply as never) - assert.equal(reply.statusCode, 0, 'should not send any error') - }) - // ── /_web/* default (requireAuth=false): bypass ── it('allows /_web/* without auth when requireAuth=false', async () => { @@ -73,59 +53,25 @@ describe('authHook — authentication matrix', () => { assert.equal(reply.statusCode, 0) }) - // ── /_web/* with requireAuth=true: requires token ── + const cases = [ + ['rejects /api/* without Bearer header', '/api/v1/status', undefined, false, 401], + ['rejects /api/* with wrong token', '/api/v1/status', 'Bearer wrong_token', false, 401], + ['allows /api/* with correct token', '/api/v1/status', `Bearer ${VALID_TOKEN}`, false, 0], + ['rejects /_web/* without Bearer when requireAuth=true', '/_web/sessions', undefined, true, 401], + ['rejects /_web/* with wrong token when requireAuth=true', '/_web/sessions', 'Bearer bad', true, 401], + ['allows /_web/* with correct token when requireAuth=true', '/_web/sessions', `Bearer ${VALID_TOKEN}`, true, 0], + ['allows static file paths without auth', '/index.html', undefined, false, 0], + ['allows static file paths without auth even with requireAuth=true', '/assets/main.js', undefined, true, 0], + ] as const - it('rejects /_web/* without Bearer when requireAuth=true', async () => { - setRequireAuth(true) - const reply = fakeReply() - await authHook(fakeRequest('/_web/sessions'), reply as never) - assert.equal(reply.statusCode, 401) - }) - - it('rejects /_web/* with wrong token when requireAuth=true', async () => { - setRequireAuth(true) - const reply = fakeReply() - await authHook(fakeRequest('/_web/sessions', 'Bearer bad'), reply as never) - assert.equal(reply.statusCode, 401) - }) - - it('allows /_web/* with correct token when requireAuth=true', async () => { - setRequireAuth(true) - const reply = fakeReply() - await authHook(fakeRequest('/_web/sessions', `Bearer ${VALID_TOKEN}`), reply as never) - assert.equal(reply.statusCode, 0) - }) - - // ── Static files / SPA: always public ── - - it('allows static file paths without auth', async () => { - const reply = fakeReply() - await authHook(fakeRequest('/index.html'), reply as never) - assert.equal(reply.statusCode, 0) - }) - - it('allows static file paths without auth even with requireAuth=true', async () => { - setRequireAuth(true) - const reply = fakeReply() - await authHook(fakeRequest('/assets/main.js'), reply as never) - assert.equal(reply.statusCode, 0) - }) - - // ── Combined: webRoot + requireAuth (the P0 scenario) ── - - it('P0 regression: /_web/* is protected when both webRoot and requireAuth are active', async () => { - setRequireAuth(true) - const reply = fakeReply() - await authHook(fakeRequest('/_web/ai/llm/providers'), reply as never) - assert.equal(reply.statusCode, 401, '/_web/* must NOT bypass auth when requireAuth=true') - }) - - it('P0 regression: /api/* remains protected regardless of requireAuth flag', async () => { - setRequireAuth(false) - const reply = fakeReply() - await authHook(fakeRequest('/api/v1/status'), reply as never) - assert.equal(reply.statusCode, 401, '/api/* must always require auth') - }) + for (const [name, url, authorization, requireAuth, expectedStatus] of cases) { + it(name, async () => { + setRequireAuth(requireAuth) + const reply = fakeReply() + await authHook(fakeRequest(url, authorization), reply as never) + assert.equal(reply.statusCode, expectedStatus) + }) + } // ── setRequireAuth reset ── diff --git a/apps/cli/src/query/commands-stats.test.ts b/apps/cli/src/query/commands-stats.test.ts index 470811dc..c955c4ad 100644 --- a/apps/cli/src/query/commands-stats.test.ts +++ b/apps/cli/src/query/commands-stats.test.ts @@ -1,13 +1,49 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import { executeParameterizedSql } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '@openchatlab/node-runtime' import { responseStatsMessagesSql } from './commands-stats' describe('responseStatsMessagesSql', () => { it('orders same-second messages by id for deterministic response gaps', () => { - const sql = responseStatsMessagesSql() + const db = openBetterSqliteDatabase(':memory:') + try { + db.exec(` + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + account_name TEXT, + group_nickname TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL + ); + INSERT INTO member (id, account_name) VALUES (1, 'Alice'), (2, 'Bob'); + INSERT INTO message (id, sender_id, ts, type) VALUES + (30, 1, 100, 0), + (10, 2, 100, 0), + (20, 1, 100, 0), + (40, 2, 110, 0); + `) - assert.match(sql, /SELECT\s+msg\.id,/i) - assert.match(sql, /ORDER BY msg\.ts ASC, msg\.id ASC/i) + const rows = executeParameterizedSql<{ id: number; sender_id: number }>(db, responseStatsMessagesSql(), { + startTs: 0, + }) + + assert.deepEqual( + rows.map(({ id, sender_id }) => [id, sender_id]), + [ + [10, 2], + [20, 1], + [30, 1], + [40, 2], + ] + ) + } finally { + db.close() + } }) }) diff --git a/apps/cli/src/query/envelope.test.ts b/apps/cli/src/query/envelope.test.ts index 2c56a133..7b86a6f6 100644 --- a/apps/cli/src/query/envelope.test.ts +++ b/apps/cli/src/query/envelope.test.ts @@ -3,21 +3,21 @@ import assert from 'node:assert/strict' import { API_VERSION, QueryError, successEnvelope, errorEnvelope, exitCodeForError } from './envelope' describe('successEnvelope', () => { - it('builds ok envelope with data, meta and apiVersion', () => { - const envelope = successEnvelope('messages.search', { text: 'hello' }, { totalHits: 3 }) + const cases = [ + ['builds ok envelope with data, meta and apiVersion', 'messages.search', { text: 'hello' }, { totalHits: 3 }], + ['does not emit an error key on success', 'sessions.list', { items: [] }, undefined], + ] as const - assert.equal(envelope.ok, true) - assert.equal(envelope.command, 'messages.search') - assert.deepEqual(envelope.data, { text: 'hello' }) - assert.equal(envelope.meta.totalHits, 3) - assert.equal(envelope.meta.apiVersion, API_VERSION) - }) - - it('does not emit an error key on success', () => { - const envelope = successEnvelope('sessions.list', { items: [] }) - assert.equal('error' in envelope, false) - assert.deepEqual(envelope.meta, { apiVersion: API_VERSION }) - }) + for (const [name, command, data, meta] of cases) { + it(name, () => { + const envelope = successEnvelope(command, data, meta) + assert.equal(envelope.ok, true) + assert.equal(envelope.command, command) + assert.deepEqual(envelope.data, data) + assert.equal('error' in envelope, false) + assert.deepEqual(envelope.meta, { ...meta, apiVersion: API_VERSION }) + }) + } }) describe('errorEnvelope', () => { @@ -54,28 +54,19 @@ describe('errorEnvelope', () => { }) describe('exitCodeForError', () => { - it('maps argument-class errors to 2', () => { - assert.equal(exitCodeForError('INVALID_ARGUMENT'), 2) - assert.equal(exitCodeForError('CURSOR_INVALID'), 2) - assert.equal(exitCodeForError('RAW_DISABLED'), 2) - assert.equal(exitCodeForError('SQL_DISABLED'), 2) - }) + const cases = [ + ['maps argument-class errors to 2', ['INVALID_ARGUMENT', 'CURSOR_INVALID', 'RAW_DISABLED', 'SQL_DISABLED'], 2], + ['maps not-found errors to 3', ['SESSION_NOT_FOUND', 'MEMBER_NOT_FOUND', 'SEGMENT_NOT_FOUND'], 3], + ['maps ambiguity errors to 4', ['SESSION_AMBIGUOUS', 'MEMBER_AMBIGUOUS'], 4], + ['maps SQL errors to 5', ['SQL_ERROR'], 5], + ['maps unknown errors to 1', ['SOMETHING_ELSE'], 1], + ] as const - it('maps not-found errors to 3', () => { - assert.equal(exitCodeForError('SESSION_NOT_FOUND'), 3) - assert.equal(exitCodeForError('MEMBER_NOT_FOUND'), 3) - assert.equal(exitCodeForError('SEGMENT_NOT_FOUND'), 3) - }) - - it('maps ambiguity errors to 4', () => { - assert.equal(exitCodeForError('SESSION_AMBIGUOUS'), 4) - assert.equal(exitCodeForError('MEMBER_AMBIGUOUS'), 4) - }) - - it('maps SQL errors to 5 and unknown codes to 1', () => { - assert.equal(exitCodeForError('SQL_ERROR'), 5) - assert.equal(exitCodeForError('SOMETHING_ELSE'), 1) - }) + for (const [name, codes, expected] of cases) { + it(name, () => { + for (const code of codes) assert.equal(exitCodeForError(code), expected, code) + }) + } }) describe('QueryError', () => { diff --git a/apps/cli/src/release-workflow.test.ts b/apps/cli/src/release-workflow.test.ts deleted file mode 100644 index ccf3b2c7..00000000 --- a/apps/cli/src/release-workflow.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' - -const releaseWorkflow = readFileSync(new URL('../../../.github/workflows/release.yml', import.meta.url), 'utf-8') - -function extractSection(source: string, startMarker: string, endMarker: string): string { - const start = source.indexOf(startMarker) - assert.notEqual(start, -1, `Missing workflow section: ${startMarker}`) - - const end = source.indexOf(endMarker, start + startMarker.length) - assert.notEqual(end, -1, `Missing workflow section terminator: ${endMarker}`) - - return source.slice(start, end) -} - -test('release CLI build injects Umami configuration before tsup inlines env values', () => { - const buildCliJob = extractSection(releaseWorkflow, ' build-cli:', ' release:') - const buildCliStep = extractSection( - buildCliJob, - ' - name: Build CLI (with Web UI)', - ' - name: Upload CLI build output' - ) - - assert.match(buildCliStep, /run: pnpm --filter chatlab-cli run build:full/) - assert.match(buildCliStep, /UMAMI_ENDPOINT: \$\{\{ secrets\.UMAMI_ENDPOINT \}\}/) - assert.match(buildCliStep, /UMAMI_WEBSITE_ID: \$\{\{ secrets\.UMAMI_WEBSITE_ID \}\}/) -}) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 1ccf5970..5c4ec161 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -2,6 +2,8 @@ appId: com.chatlab.fun # 应用程序的产品名称 productName: ChatLab +# 打包完成后使用目标 Electron 真实加载本地 embedding 运行时,避免只检查 package.json。 +afterPack: scripts/verify-packaged-local-runtime.mjs # 发布配置 publish: provider: github diff --git a/apps/desktop/main/database/startup-migration.test.ts b/apps/desktop/main/database/startup-migration.test.ts index dfb743aa..2af51219 100644 --- a/apps/desktop/main/database/startup-migration.test.ts +++ b/apps/desktop/main/database/startup-migration.test.ts @@ -47,31 +47,6 @@ test('assertDesktopStartupMigrationSucceeded accepts successful startup migratio assert.doesNotThrow(() => assertDesktopStartupMigrationSucceeded({ success: true, migratedCount: 1, failures: [] })) }) -test('repairDesktopStartupCompatibilityGate raises metadata for already migrated segment schema data', () => { - const calls: Array<{ minRuntimeVersion: string; reason: string }> = [] - - repairDesktopStartupCompatibilityGate( - { version: '0.25.1', kind: 'desktop' }, - { - hasCurrentSegmentSchemaData: () => true, - raiseDataDirMinRuntimeVersion: (_pathProvider, input) => { - calls.push({ minRuntimeVersion: input.minRuntimeVersion, reason: input.reason }) - return { - formatVersion: 1, - minRuntimeVersion: input.minRuntimeVersion, - dataCompatibilityVersion: input.dataCompatibilityVersion, - reasons: [input.reason], - updatedBy: { runtime: input.runtime.kind, module: input.module, version: input.runtime.version }, - updatedAt: 1780830000, - } - }, - pathProvider: {} as never, - } - ) - - assert.deepEqual(calls, [{ minRuntimeVersion: '0.25.1', reason: 'segment-schema' }]) -}) - test('repairDesktopStartupCompatibilityGate writes missing metadata for existing v6 databases', () => { const root = makeTempDir() const dbPath = path.join(root, 'data', 'databases', 'already-v6.db') diff --git a/apps/desktop/main/paths/paths.test.ts b/apps/desktop/main/paths/paths.test.ts index 7af9f03d..a8068d82 100644 --- a/apps/desktop/main/paths/paths.test.ts +++ b/apps/desktop/main/paths/paths.test.ts @@ -2,23 +2,23 @@ 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' +import { after, before, beforeEach, 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 = 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 - const electronUserDataDir = path.join(root, 'electron-user-data') - const documentsDir = path.join(root, 'Documents') - const currentDir = path.join(root, 'current-data') - const targetDir = path.join(root, 'target-data') - let configuredUserDataDir = currentDir +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 electronUserDataDir = path.join(root, 'electron-user-data') +const documentsDir = path.join(root, 'Documents') +const currentDir = path.join(root, 'current-data') +const targetDir = path.join(root, 'target-data') +const originalHome = process.env.HOME +const originalDataDir = process.env.CHATLAB_DATA_DIR +let configuredUserDataDir = currentDir +let paths: typeof import('./locations.js') & + typeof import('./data-dir-switch.js') & + typeof import('./legacy-migration.js') - fs.mkdirSync(electronUserDataDir, { recursive: true }) - fs.mkdirSync(path.join(currentDir, 'databases'), { recursive: true }) - fs.writeFileSync(path.join(currentDir, 'databases', 'current.db'), 'sqlite', 'utf-8') +before(async () => { process.env.HOME = root delete process.env.CHATLAB_DATA_DIR @@ -48,115 +48,138 @@ test('desktop paths preserve data across configured and legacy directory migrati namedExports: { getChatLabTempScopeDir: () => path.join(root, 'chatlab-temp', 'runtime') }, }) - try { - const paths = { - ...(await import('./locations.js')), - ...(await import('./data-dir-switch.js')), - ...(await import('./legacy-migration.js')), - } - - paths.ensureAppDirs() - 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, { - success: true, - from: currentDir, - to: targetDir, - requiresRelaunch: true, - }) - - const applyResult = paths.applyPendingDataDirMigration() - assert.equal(applyResult.success, true) - assert.equal(configuredUserDataDir, targetDir) - assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'current.db'), 'utf-8'), 'sqlite') - - paths.preserveLegacyPendingDeleteDir() - assert.equal(fs.existsSync(currentDir), true) - assert.equal(dataDirSwitch.getPendingDataDirCleanups(path.join(root, '.chatlab'))[0]?.sourceDir, currentDir) - - const legacyCleanupDir = path.join(root, 'legacy-cleanup-data') - fs.mkdirSync(path.join(legacyCleanupDir, 'databases'), { recursive: true }) - fs.writeFileSync(path.join(legacyCleanupDir, '.chatlab'), 'ChatLab Data Directory', 'utf-8') - fs.writeFileSync(path.join(legacyCleanupDir, 'databases', 'legacy-cleanup.db'), 'sqlite', 'utf-8') - fs.writeFileSync( - path.join(electronUserDataDir, 'storage.json'), - JSON.stringify({ pendingDeleteDir: legacyCleanupDir }), - 'utf-8' - ) - paths.preserveLegacyPendingDeleteDir() - assert.equal(fs.existsSync(legacyCleanupDir), true) - assert.equal( - dataDirSwitch - .getPendingDataDirCleanups(path.join(root, '.chatlab')) - .some((cleanup) => cleanup.sourceDir === legacyCleanupDir), - true - ) - - 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') - - const assertLegacyMigrationBlocked = (activeDir: string) => { - configuredUserDataDir = activeDir - paths.setCachedUserDataDir(activeDir) - assert.equal(paths.needsLegacyMigration(), false) - assert.equal(paths.migrateFromLegacyDir().success, true) - assert.equal(paths.removeLegacyDir(), false) - assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') - } - - assertLegacyMigrationBlocked(legacyDir) - - configuredUserDataDir = targetDir - paths.setCachedUserDataDir(targetDir) - const nestedActiveDir = path.join(legacyDir, 'active-data') - assert.equal(paths.setCustomDataDir(nestedActiveDir, true).success, true) - assert.equal(paths.applyPendingDataDirMigration().success, true) - assert.equal(configuredUserDataDir, nestedActiveDir) - assertLegacyMigrationBlocked(nestedActiveDir) - assert.equal(fs.readFileSync(path.join(nestedActiveDir, 'databases', 'current.db'), 'utf-8'), 'sqlite') - - assertLegacyMigrationBlocked(documentsDir) - - const legacyAliasDir = path.join(root, 'legacy-alias') - fs.symlinkSync(legacyDir, legacyAliasDir, process.platform === 'win32' ? 'junction' : 'dir') - assertLegacyMigrationBlocked(legacyAliasDir) - - configuredUserDataDir = targetDir - paths.setCachedUserDataDir(targetDir) - fs.unlinkSync(legacyAliasDir) - fs.rmSync(nestedActiveDir, { recursive: true, force: true }) - 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 - if (originalDataDir === undefined) delete process.env.CHATLAB_DATA_DIR - else process.env.CHATLAB_DATA_DIR = originalDataDir - fs.rmSync(root, { recursive: true, force: true }) + paths = { + ...(await import('./locations.js')), + ...(await import('./data-dir-switch.js')), + ...(await import('./legacy-migration.js')), } }) + +beforeEach(() => { + for (const entry of fs.readdirSync(root)) fs.rmSync(path.join(root, entry), { recursive: true, force: true }) + fs.mkdirSync(electronUserDataDir, { recursive: true }) + fs.mkdirSync(path.join(currentDir, 'databases'), { recursive: true }) + fs.writeFileSync(path.join(currentDir, 'databases', 'current.db'), 'sqlite', 'utf-8') + configuredUserDataDir = currentDir + paths.setCachedUserDataDir(currentDir) +}) + +after(() => { + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + if (originalDataDir === undefined) delete process.env.CHATLAB_DATA_DIR + else process.env.CHATLAB_DATA_DIR = originalDataDir + fs.rmSync(root, { recursive: true, force: true }) +}) + +test('desktop paths switch configured data directories without deleting the source', () => { + paths.ensureAppDirs() + 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) + + assert.deepEqual(paths.setCustomDataDir(targetDir, true), { + success: true, + from: currentDir, + to: targetDir, + requiresRelaunch: true, + }) + assert.equal(paths.applyPendingDataDirMigration().success, true) + assert.equal(configuredUserDataDir, targetDir) + assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'current.db'), 'utf-8'), 'sqlite') + + paths.preserveLegacyPendingDeleteDir() + assert.equal(fs.existsSync(currentDir), true) + assert.equal(dataDirSwitch.getPendingDataDirCleanups(path.join(root, '.chatlab'))[0]?.sourceDir, currentDir) +}) + +test('desktop paths convert legacy automatic deletion into manual cleanup', () => { + const legacyCleanupDir = path.join(root, 'legacy-cleanup-data') + fs.mkdirSync(path.join(legacyCleanupDir, 'databases'), { recursive: true }) + fs.writeFileSync(path.join(legacyCleanupDir, '.chatlab'), 'ChatLab Data Directory', 'utf-8') + fs.writeFileSync(path.join(legacyCleanupDir, 'databases', 'legacy-cleanup.db'), 'sqlite', 'utf-8') + fs.writeFileSync( + path.join(electronUserDataDir, 'storage.json'), + JSON.stringify({ pendingDeleteDir: legacyCleanupDir }) + ) + + paths.preserveLegacyPendingDeleteDir() + + assert.equal(fs.existsSync(legacyCleanupDir), true) + assert.equal( + dataDirSwitch + .getPendingDataDirCleanups(path.join(root, '.chatlab')) + .some((cleanup) => cleanup.sourceDir === legacyCleanupDir), + true + ) +}) + +test('legacy migration refuses active directories that overlap the source', () => { + const legacyDir = createLegacyDocumentsData() + + const assertBlocked = (activeDir: string) => { + configuredUserDataDir = activeDir + paths.setCachedUserDataDir(activeDir) + assert.equal(paths.needsLegacyMigration(), false, activeDir) + assert.equal(paths.migrateFromLegacyDir().success, true, activeDir) + assert.equal(paths.removeLegacyDir(), false, activeDir) + assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy', activeDir) + } + + assertBlocked(legacyDir) + assertBlocked(documentsDir) + + const aliasDir = path.join(root, 'legacy-alias') + fs.symlinkSync(legacyDir, aliasDir, process.platform === 'win32' ? 'junction' : 'dir') + assertBlocked(aliasDir) +}) + +test('legacy migration protects a newly selected directory nested under the legacy source', () => { + const legacyDir = createLegacyDocumentsData() + const nestedActiveDir = path.join(legacyDir, 'active-data') + + assert.equal(paths.setCustomDataDir(nestedActiveDir, true).success, true) + assert.equal(paths.applyPendingDataDirMigration().success, true) + assert.equal(configuredUserDataDir, nestedActiveDir) + assert.equal(paths.needsLegacyMigration(), false) + assert.equal(paths.migrateFromLegacyDir().success, true) + assert.equal(paths.removeLegacyDir(), false) + assert.equal(fs.readFileSync(path.join(nestedActiveDir, 'databases', 'current.db'), 'utf-8'), 'sqlite') + assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') +}) + +test('legacy migration copies persistent data, skips temp files, and removes the old directory', () => { + const legacyDir = createLegacyDocumentsData() + + assert.equal(paths.needsLegacyMigration(), true) + assert.equal(paths.migrateFromLegacyDir().success, true) + assert.equal(fs.readFileSync(path.join(currentDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') + assert.equal(fs.existsSync(path.join(currentDir, 'temp', 'stale.tmp')), false) + assert.equal(fs.existsSync(legacyDir), false) +}) + +test('unified directory migration moves system data but preserves old temporary files', () => { + 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') +}) + +function createLegacyDocumentsData(): string { + 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') + return legacyDir +} diff --git a/apps/desktop/main/security/lock-manager.test.ts b/apps/desktop/main/security/lock-manager.test.ts index db2b4f98..5b2aa927 100644 --- a/apps/desktop/main/security/lock-manager.test.ts +++ b/apps/desktop/main/security/lock-manager.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 { mock, test } from 'node:test' +import { after, afterEach, before, beforeEach, mock, test } from 'node:test' class FakeMainWindow { readonly sentMessages: unknown[][] = [] @@ -20,13 +20,13 @@ function makeTempDir(): string { return fs.mkdtempSync(path.join(baseDir, 'chatlab-app-lock-')) } -test('desktop app lock persists its canonical config and handles manual, idle, and startup locking', async () => { - const root = makeTempDir() - const settingsDir = path.join(root, 'settings') - const mainWindow = new FakeMainWindow() - let systemIdleSeconds = 0 - let cleanupLockManager: (() => void) | undefined +const root = makeTempDir() +const settingsDir = path.join(root, 'settings') +let systemIdleSeconds = 0 +let mainWindow: FakeMainWindow +let lockManager: typeof import('./lock-manager.js') +before(async () => { await mock.module('electron', { namedExports: { powerMonitor: { @@ -49,114 +49,125 @@ test('desktop app lock persists its canonical config and handles manual, idle, a }, }, }) - - try { - fs.mkdirSync(settingsDir, { recursive: true }) - fs.writeFileSync( - path.join(settingsDir, 'app-lock.json'), - JSON.stringify({ enabled: true, idleTimeoutMinutes: 0, lockOnStartup: false }), - 'utf-8' - ) - fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), 'stale', 'utf-8') - - const lockManager = await import('./lock-manager.js') - cleanupLockManager = lockManager.cleanupLockManager - lockManager.initLockManager(mainWindow as never) - - assert.deepEqual(lockManager.getLockConfig(), { - enabled: false, - idleTimeoutMinutes: 0, - lockOnStartup: false, - }) - assert.equal(fs.existsSync(path.join(settingsDir, '.app-lock-flag')), false) - - assert.deepEqual(await lockManager.setPassword('123'), { success: false, error: 'invalid-pin' }) - assert.deepEqual(await lockManager.setPassword('1234'), { success: true }) - assert.deepEqual(lockManager.getLockConfig(), { - enabled: true, - idleTimeoutMinutes: 0, - lockOnStartup: false, - }) - - const persisted = JSON.parse(fs.readFileSync(path.join(settingsDir, 'app-lock.json'), 'utf-8')) as { - passwordHash: { hash: string; salt: string; version: number } - } - assert.deepEqual(Object.keys(persisted).sort(), ['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash']) - assert.equal(persisted.passwordHash.version, 1) - assert.equal(persisted.passwordHash.hash.length, 128) - assert.equal(persisted.passwordHash.salt.length, 64) - - assert.deepEqual(lockManager.updateLockConfig({ enabled: true }), { + lockManager = await import('./lock-manager.js') +}) + +beforeEach(() => { + lockManager.cleanupLockManager() + fs.rmSync(settingsDir, { recursive: true, force: true }) + fs.mkdirSync(settingsDir, { recursive: true }) + systemIdleSeconds = 0 + mainWindow = new FakeMainWindow() +}) + +afterEach(() => lockManager.cleanupLockManager()) +after(() => fs.rmSync(root, { recursive: true, force: true })) + +async function enableAppLock(pin = '1234'): Promise { + lockManager.initLockManager(mainWindow as never) + assert.deepEqual(await lockManager.setPassword(pin), { success: true }) +} + +test('desktop app lock normalizes stale config and persists the canonical password shape', async () => { + fs.writeFileSync( + path.join(settingsDir, 'app-lock.json'), + JSON.stringify({ enabled: true, idleTimeoutMinutes: 0, lockOnStartup: false }), + 'utf-8' + ) + fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), 'stale', 'utf-8') + + lockManager.initLockManager(mainWindow as never) + assert.deepEqual(lockManager.getLockConfig(), { + enabled: false, + idleTimeoutMinutes: 0, + lockOnStartup: false, + }) + assert.equal(fs.existsSync(path.join(settingsDir, '.app-lock-flag')), false) + assert.deepEqual(await lockManager.setPassword('123'), { success: false, error: 'invalid-pin' }) + assert.deepEqual(await lockManager.setPassword('1234'), { success: true }) + + const persisted = JSON.parse(fs.readFileSync(path.join(settingsDir, 'app-lock.json'), 'utf-8')) as { + passwordHash: { hash: string; salt: string; version: number } + } + assert.deepEqual(Object.keys(persisted).sort(), ['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash']) + assert.equal(persisted.passwordHash.version, 1) + assert.equal(persisted.passwordHash.hash.length, 128) + assert.equal(persisted.passwordHash.salt.length, 64) + assert.deepEqual(lockManager.updateLockConfig({ enabled: true }), { + success: false, + error: 'invalid-config', + }) +}) + +test('desktop app lock persists failed-attempt cooldown across restarts', async () => { + await enableAppLock() + assert.deepEqual(lockManager.lockApp(), { success: true }) + + for (let attempt = 1; attempt < 5; attempt++) { + assert.deepEqual(await lockManager.unlockApp('0000'), { success: false, - error: 'invalid-config', + error: 'wrong-password', + wrongPassword: true, }) - assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 5 }).success, true) - assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 0 }).success, true) - - assert.deepEqual(lockManager.lockApp(), { success: true }) - assert.equal(lockManager.getLockState(), 'locked') - for (let attempt = 1; attempt < 5; attempt++) { - assert.deepEqual(await lockManager.unlockApp('0000'), { - success: false, - error: 'wrong-password', - wrongPassword: true, - }) - } - - const blockedResult = await lockManager.unlockApp('0000') - assert.equal(blockedResult.success, false) - assert.equal(blockedResult.error, 'too-many-attempts') - assert.equal(blockedResult.wrongPassword, true) - assert.ok(blockedResult.retryAfterSeconds && blockedResult.retryAfterSeconds > 0) - - const blockedFlag = JSON.parse(fs.readFileSync(path.join(settingsDir, '.app-lock-flag'), 'utf-8')) as { - failureCount: number - cooldownUntil: number - } - assert.equal(blockedFlag.failureCount, 5) - assert.ok(blockedFlag.cooldownUntil > Date.now()) - - lockManager.cleanupLockManager() - lockManager.initLockManager(mainWindow as never) - assert.equal(lockManager.getLockState(), 'locked') - - const blockedAfterRestart = await lockManager.unlockApp('1234') - assert.equal(blockedAfterRestart.error, 'too-many-attempts') - assert.ok(blockedAfterRestart.retryAfterSeconds && blockedAfterRestart.retryAfterSeconds > 0) - - blockedFlag.cooldownUntil = Date.now() - 1 - fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), JSON.stringify(blockedFlag), 'utf-8') - lockManager.cleanupLockManager() - lockManager.initLockManager(mainWindow as never) - assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) - assert.deepEqual(mainWindow.sentMessages.slice(-2), [ - ['app-lock-state-changed', true], - ['app-lock-state-changed', false], - ]) - - assert.deepEqual(await lockManager.changePassword('1234', '1234'), { - success: false, - error: 'same-password', - }) - assert.deepEqual(await lockManager.changePassword('1234', '4321'), { success: true }) - - systemIdleSeconds = 60 - assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 1 }).success, true) - assert.equal(lockManager.getLockState(), 'locked') - systemIdleSeconds = 0 - assert.deepEqual(await lockManager.unlockApp('4321'), { success: true }) - - assert.equal(lockManager.updateLockConfig({ lockOnStartup: true }).success, true) - lockManager.cleanupLockManager() - lockManager.initLockManager(mainWindow as never) - assert.equal(lockManager.getLockState(), 'locked') - assert.deepEqual(await lockManager.unlockApp('4321'), { success: true }) - - assert.deepEqual(lockManager.resetAppLockPassword(), { success: true }) - assert.equal(lockManager.getLockConfig().enabled, false) - assert.deepEqual(lockManager.lockApp(), { success: false, error: 'disabled' }) - } finally { - cleanupLockManager?.() - fs.rmSync(root, { recursive: true, force: true }) + } + const blockedResult = await lockManager.unlockApp('0000') + assert.equal(blockedResult.error, 'too-many-attempts') + assert.equal(blockedResult.wrongPassword, true) + assert.ok(blockedResult.retryAfterSeconds && blockedResult.retryAfterSeconds > 0) + + const flagPath = path.join(settingsDir, '.app-lock-flag') + const blockedFlag = JSON.parse(fs.readFileSync(flagPath, 'utf-8')) as { + failureCount: number + cooldownUntil: number } + assert.equal(blockedFlag.failureCount, 5) + assert.ok(blockedFlag.cooldownUntil > Date.now()) + + lockManager.cleanupLockManager() + lockManager.initLockManager(mainWindow as never) + assert.equal((await lockManager.unlockApp('1234')).error, 'too-many-attempts') + + blockedFlag.cooldownUntil = Date.now() - 1 + fs.writeFileSync(flagPath, JSON.stringify(blockedFlag), 'utf-8') + lockManager.cleanupLockManager() + lockManager.initLockManager(mainWindow as never) + assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) + assert.deepEqual(mainWindow.sentMessages.slice(-2), [ + ['app-lock-state-changed', true], + ['app-lock-state-changed', false], + ]) +}) + +test('desktop app lock handles manual locking, password changes, and reset independently', async () => { + await enableAppLock() + assert.deepEqual(lockManager.lockApp(), { success: true }) + assert.equal(lockManager.getLockState(), 'locked') + assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) + + assert.deepEqual(await lockManager.changePassword('1234', '1234'), { + success: false, + error: 'same-password', + }) + assert.deepEqual(await lockManager.changePassword('1234', '4321'), { success: true }) + assert.deepEqual(lockManager.lockApp(), { success: true }) + assert.deepEqual(await lockManager.unlockApp('4321'), { success: true }) + + assert.deepEqual(lockManager.resetAppLockPassword(), { success: true }) + assert.equal(lockManager.getLockConfig().enabled, false) + assert.deepEqual(lockManager.lockApp(), { success: false, error: 'disabled' }) +}) + +test('desktop app lock applies idle and startup locking independently', async () => { + await enableAppLock() + systemIdleSeconds = 60 + assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 1 }).success, true) + assert.equal(lockManager.getLockState(), 'locked') + + systemIdleSeconds = 0 + assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) + assert.equal(lockManager.updateLockConfig({ lockOnStartup: true }).success, true) + lockManager.cleanupLockManager() + lockManager.initLockManager(mainWindow as never) + assert.equal(lockManager.getLockState(), 'locked') + assert.deepEqual(await lockManager.unlockApp('1234'), { success: true }) }) diff --git a/apps/desktop/main/semantic-index-runtime-deps.test.ts b/apps/desktop/main/semantic-index-runtime-deps.test.ts deleted file mode 100644 index c3e169a6..00000000 --- a/apps/desktop/main/semantic-index-runtime-deps.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict' -import fs from 'node:fs' -import path from 'node:path' -import { describe, it } from 'node:test' -import { fileURLToPath } from 'node:url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const desktopPackageJsonPath = path.resolve(__dirname, '../package.json') - -function readDesktopPackageJson(): { dependencies?: Record } { - return JSON.parse(fs.readFileSync(desktopPackageJsonPath, 'utf-8')) as { dependencies?: Record } -} - -describe('desktop semantic-index runtime dependencies', () => { - it('declares transformers node runtime packages as direct production dependencies', () => { - const pkg = readDesktopPackageJson() - const dependencies = pkg.dependencies ?? {} - - for (const name of ['onnxruntime-common', 'onnxruntime-node', 'sharp']) { - assert.ok(dependencies[name], `${name} must be a direct desktop dependency for packaged local embeddings`) - } - }) -}) diff --git a/apps/desktop/main/window/titlebar.test.ts b/apps/desktop/main/window/titlebar.test.ts deleted file mode 100644 index 5f395188..00000000 --- a/apps/desktop/main/window/titlebar.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import type { BrowserWindow, TitleBarOverlayOptions } from 'electron' -import { - applyCurrentTitleBarOverlay, - applyTitleBarOverlayColor, - getTitleBarOverlayOptionsForColor, - getTitleBarOverlayOptions, - resetCurrentTitleBarOverlayColor, -} from './titlebar' - -describe('Windows title bar overlay options', () => { - it('keeps the native overlay background transparent in normal mode', () => { - assert.deepEqual(getTitleBarOverlayOptions(false), { - color: 'rgba(0, 0, 0, 0)', - symbolColor: '#52525b', - height: 32, - }) - assert.deepEqual(getTitleBarOverlayOptions(true), { - color: 'rgba(0, 0, 0, 0)', - symbolColor: '#d4d4d8', - height: 32, - }) - }) - - it('uses readable symbols for sampled custom colors', () => { - assert.deepEqual(getTitleBarOverlayOptionsForColor('#ffffff'), { - color: 'rgba(0, 0, 0, 0)', - symbolColor: '#3f3f46', - height: 32, - }) - assert.deepEqual(getTitleBarOverlayOptionsForColor('#111827'), { - color: 'rgba(0, 0, 0, 0)', - symbolColor: '#e4e4e7', - height: 32, - }) - }) - - it('can drop a sampled color when the effective theme changes', () => { - const calls: TitleBarOverlayOptions[] = [] - const win = { - setTitleBarOverlay: (options: TitleBarOverlayOptions) => { - calls.push(options) - }, - } as Pick as BrowserWindow - - applyTitleBarOverlayColor(win, '#111827') - resetCurrentTitleBarOverlayColor() - applyCurrentTitleBarOverlay(win, false) - - assert.deepEqual(calls.at(-1), { - color: 'rgba(0, 0, 0, 0)', - symbolColor: '#52525b', - height: 32, - }) - }) -}) diff --git a/apps/desktop/main/worker/core/perfLogger.test.ts b/apps/desktop/main/worker/core/perfLogger.test.ts deleted file mode 100644 index 517edb64..00000000 --- a/apps/desktop/main/worker/core/perfLogger.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, afterEach } from 'node:test' -import assert from 'node:assert/strict' -import path from 'node:path' -import { getLogsDir, initDbDir } from './dbCore' -import { getImportLogDir } from './perfLogPath' - -describe('desktop worker import perf logger', () => { - afterEach(() => { - initDbDir('', '', '', undefined, '') - }) - - it('resolves import logs under the injected system logs directory', () => { - const root = path.join('/tmp', 'chatlab-worker-perf-log') - const dbDir = path.join(root, 'data', 'databases') - const cacheDir = path.join(root, 'cache') - const tempDir = path.join(root, 'temp') - const logsDir = path.join(root, 'logs') - - initDbDir(dbDir, cacheDir, tempDir, undefined, logsDir) - - assert.equal(getLogsDir(), logsDir) - assert.equal(getImportLogDir(logsDir), path.join(logsDir, 'import')) - assert.notEqual(getImportLogDir(logsDir), path.join(root, 'data', 'logs', 'import')) - }) -}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 06f4a00a..abc6709b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,7 +10,7 @@ "restore-node-abi": "node scripts/restore-node-abi.mjs", "dev": "node scripts/ensure-native.mjs && electron-vite dev", "preview": "node scripts/ensure-native.mjs && electron-vite preview", - "build": "electron-vite build", + "build": "electron-vite build && node ../../scripts/check-build-artifacts.mjs desktop", "build:mac": "node scripts/ensure-native.mjs && pnpm run build && node scripts/build-app.mjs --mac", "build:win": "node scripts/ensure-native.mjs && pnpm run build && node scripts/build-app.mjs --win" }, diff --git a/apps/desktop/scripts/verify-packaged-local-runtime.mjs b/apps/desktop/scripts/verify-packaged-local-runtime.mjs new file mode 100644 index 00000000..bbb44345 --- /dev/null +++ b/apps/desktop/scripts/verify-packaged-local-runtime.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +function getPackagedPaths(context) { + const productFilename = context.packager.appInfo.productFilename + if (context.electronPlatformName === 'darwin') { + const appDir = join(context.appOutDir, `${productFilename}.app`, 'Contents') + return { + executable: join(appDir, 'MacOS', productFilename), + resources: join(appDir, 'Resources'), + } + } + + const executableName = context.electronPlatformName === 'win32' ? `${productFilename}.exe` : productFilename + return { + executable: join(context.appOutDir, executableName), + resources: join(context.appOutDir, 'resources'), + } +} + +export default function verifyPackagedLocalRuntime(context) { + const { executable, resources } = getPackagedPaths(context) + const asarPath = join(resources, 'app.asar') + if (!existsSync(executable) || !existsSync(asarPath)) { + throw new Error(`Packaged Electron runtime is incomplete: ${context.appOutDir}`) + } + + const smokeScript = ` + const { createRequire } = require('node:module'); + const path = require('node:path'); + const requireFromApp = createRequire(path.join(process.env.CHATLAB_PACKAGED_RESOURCES, 'app.asar', 'package.json')); + requireFromApp.resolve('@huggingface/transformers'); + requireFromApp('onnxruntime-common'); + requireFromApp('onnxruntime-node'); + requireFromApp('sharp'); + ` + const result = spawnSync(executable, ['-e', smokeScript], { + encoding: 'utf8', + env: { + ...process.env, + CHATLAB_PACKAGED_RESOURCES: resources, + ELECTRON_RUN_AS_NODE: '1', + }, + }) + + if (result.status !== 0) { + const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim() + throw new Error(`Packaged local embedding runtime failed to load${details ? `:\n${details}` : ''}`) + } + + console.log('[desktop build] Packaged local embedding runtime loaded successfully') +} diff --git a/apps/web-wasm/src/components/session/insights/SessionInsights.test.ts b/apps/web-wasm/src/components/session/insights/SessionInsights.test.ts deleted file mode 100644 index 0370e772..00000000 --- a/apps/web-wasm/src/components/session/insights/SessionInsights.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' - -const source = readFileSync(new URL('./SessionInsights.vue', import.meta.url), 'utf8') - -test('keeps member filter invalidation scoped to the active insight subtree', () => { - assert.equal(source.includes('abortAnalyticsRequests'), false) - assert.equal(source.match(/:key="selectedMemberId \?\? 'all'"/g)?.length, 3) -}) diff --git a/apps/web-wasm/src/routes.test.ts b/apps/web-wasm/src/routes.test.ts deleted file mode 100644 index b4f5a653..00000000 --- a/apps/web-wasm/src/routes.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { webWasmRoutes } from './routes' - -test('keeps Web WASM application routes independent from the backend client', () => { - assert.deepEqual( - webWasmRoutes.map(({ path, name }) => ({ path, name })), - [ - { path: '/', name: 'home' }, - { path: '/group-chat/:id', name: 'group-chat' }, - { path: '/private-chat/:id', name: 'private-chat' }, - ] - ) -}) diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs new file mode 100644 index 00000000..54f4687a --- /dev/null +++ b/scripts/check-build-artifacts.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..') +const targets = { + cli: join(repoRoot, 'apps', 'cli', 'dist', 'people-relationships-worker.mjs'), + desktop: join(repoRoot, 'apps', 'desktop', 'out', 'main', 'people-relationships-worker.js'), +} + +function verifyFile(target, artifactPath) { + if (!existsSync(artifactPath)) { + throw new Error(`Missing ${target} worker bundle: ${artifactPath}`) + } + if (statSync(artifactPath).size === 0) { + throw new Error(`Empty ${target} worker bundle: ${artifactPath}`) + } +} + +function verifyCliAnalyticsConfig() { + const endpoint = process.env.UMAMI_ENDPOINT?.trim() + const websiteId = process.env.UMAMI_WEBSITE_ID?.trim() + if (!endpoint && !websiteId) return + if (!endpoint || !websiteId) { + throw new Error('UMAMI_ENDPOINT and UMAMI_WEBSITE_ID must be configured together') + } + + const distDir = join(repoRoot, 'apps', 'cli', 'dist') + const bundledSource = readdirSync(distDir) + .filter((name) => name.endsWith('.mjs')) + .map((name) => readFileSync(join(distDir, name), 'utf8')) + .join('\n') + + if (!bundledSource.includes(endpoint) || !bundledSource.includes(websiteId)) { + throw new Error('CLI build did not inline the configured analytics values') + } +} + +const target = process.argv[2] +const artifactPath = targets[target] + +try { + if (!artifactPath) throw new Error('Usage: node scripts/check-build-artifacts.mjs ') + verifyFile(target, artifactPath) + if (target === 'cli') verifyCliAnalyticsConfig() + console.log(`[check-build-artifacts] OK: ${target}`) +} catch (error) { + console.error(`[check-build-artifacts] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/tests/build-worker-entries.test.ts b/tests/build-worker-entries.test.ts deleted file mode 100644 index d9dd5ac0..00000000 --- a/tests/build-worker-entries.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Regression tests for worker bundle entry configuration. - * - * Run: pnpm test -- tests/build-worker-entries.test.ts - */ - -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import test from 'node:test' - -function readProjectFile(path: string): string { - return readFileSync(path, 'utf-8') -} - -test('CLI bundle emits the people relationships worker entry expected by the runtime resolver', () => { - const config = readProjectFile('apps/cli/tsup.config.ts') - - assert.match(config, /['"]people-relationships-worker['"]\s*:/) - assert.match(config, /packages\/node-runtime\/src\/services\/people\/relationships\/worker-entry\.ts/) -}) - -test('Desktop bundle emits the people relationships worker entry expected by the runtime resolver', () => { - const config = readProjectFile('apps/desktop/electron.vite.config.ts') - - assert.match(config, /['"]people-relationships-worker['"]\s*:/) - assert.match(config, /packages\/node-runtime\/src\/services\/people\/relationships\/worker-entry\.ts/) -})