diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..36d4a79c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,9 @@ on: jobs: claude-review: + # Fork PRs do not receive the OIDC token/secrets required by + # anthropics/claude-code-action on pull_request workflows. + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +44,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/project-checks.yml b/.github/workflows/project-checks.yml new file mode 100644 index 00000000..0c8627ff --- /dev/null +++ b/.github/workflows/project-checks.yml @@ -0,0 +1,62 @@ +name: Project Checks + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: project-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-i18n-build: + name: Tests, i18n, and build + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: true + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + SUPERCMD_SKIP_ELECTRON_TESTS: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies without native postinstall scripts + run: npm ci --ignore-scripts + + - name: Check i18n + run: npm run check:i18n + + - name: Build main process + run: npm run build:main + + - name: Typecheck renderer + run: npm run typecheck:renderer + + - name: Run unit tests + run: npm run test:ci + + - name: Run perf regression budgets + continue-on-error: true + run: npm run test:perf:ci + + - name: Build renderer + run: npm run build:renderer diff --git a/.gitignore b/.gitignore index 8732339c..b72a369e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +pnpm-lock.yaml dist/ out/ .DS_Store @@ -11,4 +12,4 @@ extensions/* **/.build/* .gstack/ -**/AeroSpace-main/** \ No newline at end of file +**/AeroSpace-main/** diff --git a/package-lock.json b/package-lock.json index cbe18d8a..6d7d0d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -14,7 +14,6 @@ "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -39,6 +38,9 @@ "typescript": "^5.3.3", "vite": "^5.0.11", "wait-on": "^7.2.0" + }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" } }, "node_modules/@alloc/quick-lru": { @@ -3420,6 +3422,7 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", + "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -4631,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/electron-liquid-glass/-/electron-liquid-glass-1.1.1.tgz", "integrity": "sha512-AfPxcu6RJYxlsW2sjgjDEON0rVOjhh5QYM6ur5hsfILQmfY5ewHCWJZJZoDsQxhjeW1DAhbRbvB79IvgW4ansA==", "license": "MIT", + "optional": true, "os": [ "darwin" ], @@ -4650,6 +4654,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", "license": "MIT", + "optional": true, "engines": { "node": "^18 || ^20 || >= 21" } @@ -5144,7 +5149,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/filelist": { "version": "1.0.4", diff --git a/package.json b/package.json index f262a8c1..ccb1f92d 100644 --- a/package.json +++ b/package.json @@ -6,19 +6,30 @@ "homepage": "https://supercmd.sh", "main": "dist/main/main.js", "repository": "https://github.com/SuperCmdLabs/SuperCmd", + "packageManager": "npm@11.12.1", "scripts": { "dev": "npm run build:main && concurrently \"npm run watch:main\" \"npm run dev:renderer\" \"npm run start:electron\"", "watch:main": "tsc -p tsconfig.main.json --watch", "dev:renderer": "vite", "start:electron": "wait-on dist/main/main.js && cross-env NODE_ENV=development SUPERCMD_OPEN_DEVTOOLS_ON_STARTUP=1 electron .", "build": "npm run build:main && npm run build:renderer && npm run build:native", + "prebuild:main": "node scripts/check-package-manager-parity.mjs", "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", + "pretypecheck:renderer": "node scripts/check-package-manager-parity.mjs", + "typecheck:renderer": "tsc -p tsconfig.renderer.json --noEmit --pretty false", + "prebuild:renderer": "node scripts/check-package-manager-parity.mjs", "build:renderer": "vite build", + "precheck:i18n": "node scripts/check-package-manager-parity.mjs", "check:i18n": "node scripts/check-i18n.mjs", - "test": "node --test 'scripts/test-*.mjs'", + "check:package-manager": "node scripts/check-package-manager-parity.mjs", + "perf:file-search": "node scripts/file-search-perf-harness.mjs", + "test:ci": "node scripts/run-node-tests.mjs --exclude-perf", + "test:perf:ci": "cross-env SUPERCMD_PERF_CI=1 SUPERCMD_PERF_REPORT=1 node --test scripts/test-browser-search-performance.mjs scripts/test-file-search-perf-harness.mjs && cross-env SUPERCMD_ROOT_SEARCH_PERF_COMMANDS=5000 SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS=3 SUPERCMD_ROOT_SEARCH_PERF_WARMUPS=1 SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN=1.05 node scripts/test-root-search-perf.mjs", + "pretest": "node scripts/check-package-manager-parity.mjs", + "test": "node scripts/run-node-tests.mjs --exclude-perf", "build:native": "node scripts/build-native.mjs", "ensure:cross-arch-esbuild": "node scripts/ensure-cross-arch-esbuild.mjs", - "postinstall": "node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", + "postinstall": "node scripts/ensure-macos-runtime-deps.mjs && node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", "start": "electron .", "package": "cross-env NODE_ENV=production npm run build && electron-builder", "package:unsigned": "cross-env NODE_ENV=production CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && electron-builder --mac dmg -c.mac.identity=null -c.mac.notarize=false" @@ -42,12 +53,9 @@ }, "dependencies": { "@aptabase/electron": "^0.3.1", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -57,6 +65,9 @@ "react-dom": "^18.2.0", "transliteration": "^2.6.1" }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" + }, "build": { "appId": "com.supercmd.app", "productName": "SuperCmd", diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 35f3172f..eb02015e 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -1,19 +1,78 @@ #!/usr/bin/env node import { execSync } from 'child_process'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); mkdirSync('dist/native', { recursive: true }); const electronVersion = require('../node_modules/electron/package.json').version; const arch = process.arch; +const stampVersion = 1; function run(cmd) { execSync(cmd, { stdio: 'inherit' }); } +function readStamp(stampPath) { + try { + return JSON.parse(readFileSync(stampPath, 'utf8')); + } catch { + return null; + } +} + +function inputState(filePath) { + if (!existsSync(filePath)) return null; + const stats = statSync(filePath); + return { + path: filePath, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; +} + +function isUpToDate(out, stampPath, stamp) { + if (!existsSync(out)) return false; + const currentInputs = stamp.inputs.map(inputState); + if (currentInputs.some((input) => input === null)) return false; + + const previous = readStamp(stampPath); + if (JSON.stringify(previous) !== JSON.stringify({ ...stamp, inputState: currentInputs })) { + return false; + } + + const outputMtime = statSync(out).mtimeMs; + return currentInputs.every((input) => input.mtimeMs <= outputMtime); +} + +function writeStamp(stampPath, stamp) { + const currentInputs = stamp.inputs.map(inputState); + writeFileSync(stampPath, JSON.stringify({ ...stamp, inputState: currentInputs }, null, 2)); +} + +function buildIfStale({ label, out, inputs, command }) { + const stampPath = `${out}.stamp.json`; + const stamp = { + version: stampVersion, + label, + command, + inputs, + }; + + if (isUpToDate(out, stampPath, stamp)) { + console.log(`[native] ${path.basename(out)} is up to date, skipping rebuild`); + return; + } + + run(command); + writeStamp(stampPath, stamp); +} + const swift = [ ['dist/native/get-selected-text', 'src/native/get-selected-text.swift', '-framework Foundation -framework ApplicationServices -framework AppKit'], @@ -49,17 +108,34 @@ const swift = [ ]; for (const [out, src, frameworks] of swift) { - run(`swiftc -O -o ${out} ${src} ${frameworks}`); + const inputs = [...src.split(' '), __filename]; + buildIfStale({ + label: path.basename(out), + out, + inputs, + command: `swiftc -O -o ${out} ${src} ${frameworks}`, + }); } // Build native Node addon (native_helpers.node) -run( - `cd src/native/native-helpers-addon && ` + - `HOME=~/.electron-gyp npx node-gyp rebuild ` + - `--target=${electronVersion} --arch=${arch} ` + - `--dist-url=https://electronjs.org/headers && ` + - `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node` -); +buildIfStale({ + label: 'native_helpers.node', + out: 'dist/native/native_helpers.node', + inputs: [ + 'src/native/native-helpers-addon/binding.gyp', + 'src/native/native-helpers-addon/native_helpers.mm', + 'package.json', + 'package-lock.json', + 'node_modules/electron/package.json', + __filename, + ], + command: + `cd src/native/native-helpers-addon && ` + + `HOME=~/.electron-gyp npx node-gyp rebuild ` + + `--target=${electronVersion} --arch=${arch} ` + + `--dist-url=https://electronjs.org/headers && ` + + `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node`, +}); run('node scripts/build-whispercpp.mjs'); run('node scripts/build-parakeet.mjs'); diff --git a/scripts/build-parakeet.mjs b/scripts/build-parakeet.mjs index 71fd8ff3..d837b2af 100644 --- a/scripts/build-parakeet.mjs +++ b/scripts/build-parakeet.mjs @@ -14,8 +14,10 @@ const distNativeDir = path.join(repoRoot, 'dist', 'native'); const binaryDest = path.join(distNativeDir, 'parakeet-transcriber'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-soulver-calculator.mjs b/scripts/build-soulver-calculator.mjs index e02d8a4d..75f7e75f 100644 --- a/scripts/build-soulver-calculator.mjs +++ b/scripts/build-soulver-calculator.mjs @@ -15,8 +15,10 @@ const binaryDest = path.join(outDir, 'soulver-calculator'); const frameworkDest = path.join(outDir, 'SoulverCore.framework'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-whispercpp.mjs b/scripts/build-whispercpp.mjs index cad84f2c..81a40e61 100644 --- a/scripts/build-whispercpp.mjs +++ b/scripts/build-whispercpp.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; import { createHash } from 'crypto'; import path from 'path'; import { tmpdir } from 'os'; @@ -23,6 +23,18 @@ const runtimeDir = path.join(distNativeDir, 'whisper-runtime'); const frameworkDir = path.join(runtimeDir, 'whisper.framework'); const transcriberSource = path.join(repoRoot, 'src', 'native', 'whisper-transcriber.swift'); const transcriberBinary = path.join(distNativeDir, 'whisper-transcriber'); +const transcriberStamp = path.join(distNativeDir, 'whisper-transcriber.stamp.json'); +const stampVersion = 1; +const transcriberBuildArgs = [ + '-O', + '-module-cache-path', '/supercmd-swift-module-cache', + '-F', runtimeDir, + '-framework', 'whisper', + '-Xlinker', '-rpath', + '-Xlinker', '@executable_path/whisper-runtime', + '-o', transcriberBinary, + transcriberSource, +]; function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -85,17 +97,66 @@ function buildTranscriber() { console.log('[whisper.cpp] Building whisper-transcriber'); run('swiftc', [ - '-O', - '-module-cache-path', moduleCacheDir, - '-F', runtimeDir, - '-framework', 'whisper', - '-Xlinker', '-rpath', - '-Xlinker', '@executable_path/whisper-runtime', - '-o', transcriberBinary, - transcriberSource, + ...transcriberBuildArgs.map((arg) => arg === '/supercmd-swift-module-cache' ? moduleCacheDir : arg), ]); } +function newestMtimeMs(filePath) { + const stats = lstatSync(filePath); + if (stats.isSymbolicLink()) return stats.mtimeMs; + if (!stats.isDirectory()) return stats.mtimeMs; + + return readdirSync(filePath).reduce((newest, entry) => { + return Math.max(newest, newestMtimeMs(path.join(filePath, entry))); + }, stats.mtimeMs); +} + +function transcriberStampPayload() { + return { + version: stampVersion, + whisperVersion, + frameworkUrl, + frameworkSha256, + command: ['swiftc', ...transcriberBuildArgs], + source: { + path: transcriberSource, + mtimeMs: statSync(transcriberSource).mtimeMs, + size: statSync(transcriberSource).size, + }, + script: { + path: __filename, + mtimeMs: statSync(__filename).mtimeMs, + size: statSync(__filename).size, + }, + framework: { + path: frameworkDir, + mtimeMs: newestMtimeMs(frameworkDir), + }, + }; +} + +function readStamp() { + try { + return JSON.parse(readFileSync(transcriberStamp, 'utf8')); + } catch { + return null; + } +} + +function transcriberIsUpToDate() { + if (!existsSync(transcriberBinary) || !existsSync(transcriberSource) || !existsSync(frameworkDir)) return false; + + const payload = transcriberStampPayload(); + if (JSON.stringify(readStamp()) !== JSON.stringify(payload)) return false; + + const binaryMtime = statSync(transcriberBinary).mtimeMs; + return payload.source.mtimeMs <= binaryMtime && payload.framework.mtimeMs <= binaryMtime; +} + +function writeTranscriberStamp() { + writeFileSync(transcriberStamp, JSON.stringify(transcriberStampPayload(), null, 2)); +} + try { // Verify the framework actually has the whisper module, not just an empty directory const moduleMapPath = path.join(frameworkDir, 'Modules', 'module.modulemap'); @@ -104,7 +165,12 @@ try { } else { console.log('[whisper.cpp] Using existing macOS framework'); } - buildTranscriber(); + if (transcriberIsUpToDate()) { + console.log('[whisper.cpp] whisper-transcriber is up to date, skipping rebuild'); + } else { + buildTranscriber(); + writeTranscriberStamp(); + } console.log('[whisper.cpp] Ready'); } catch (error) { console.error('[whisper.cpp] Build failed:', error instanceof Error ? error.message : error); diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index d6835fe5..780ac61c 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -3,7 +3,11 @@ import path from 'path'; const localesDir = path.resolve('src/renderer/src/i18n/locales'); const baseLocale = 'en'; -const strictLocales = new Set((process.env.SUPERCMD_STRICT_LOCALES || 'ko').split(',').map((item) => item.trim()).filter(Boolean)); +const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`).sort(); +const defaultStrictLocales = localeFiles.map((file) => file.replace(/\.json$/, '')); +const strictLocales = new Set( + (process.env.SUPERCMD_STRICT_LOCALES || defaultStrictLocales.join(',')).split(',').map((item) => item.trim()).filter(Boolean), +); function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -41,7 +45,6 @@ function walk(baseNode, localeNode, currentPath, result) { } const baseMessages = JSON.parse(fs.readFileSync(path.join(localesDir, `${baseLocale}.json`), 'utf8')); -const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`); let hasStrictFailure = false; diff --git a/scripts/check-package-manager-parity.mjs b/scripts/check-package-manager-parity.mjs new file mode 100644 index 00000000..61b16713 --- /dev/null +++ b/scripts/check-package-manager-parity.mjs @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const STRICT_INSTALLED_VERSION_ENV = "SUPERCMD_STRICT_PACKAGE_MANAGER_PARITY"; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function packagePathForLockPath(rootDir, lockPath) { + return path.join(rootDir, lockPath, "package.json"); +} + +function collectDependencySpecFailures(packageJson, packageLock) { + const failures = []; + const rootLockPackage = packageLock.packages?.[""]; + const sections = ["dependencies", "devDependencies", "optionalDependencies"]; + + if (!rootLockPackage) { + return ["package-lock.json is missing packages[\"\"], so root dependency specs cannot be validated."]; + } + + for (const section of sections) { + const packageSpecs = packageJson[section] ?? {}; + const lockSpecs = rootLockPackage[section] ?? {}; + const dependencyNames = new Set([...Object.keys(packageSpecs), ...Object.keys(lockSpecs)]); + + for (const dependencyName of dependencyNames) { + if (packageSpecs[dependencyName] !== lockSpecs[dependencyName]) { + failures.push( + `${section}.${dependencyName} is ${JSON.stringify(packageSpecs[dependencyName])} in package.json but ${JSON.stringify(lockSpecs[dependencyName])} in package-lock.json.`, + ); + } + } + } + + return failures; +} + +function collectInstalledVersionFailures(rootDir, packageLock) { + const failures = []; + let checkedInstalledPackages = 0; + + for (const [lockPath, packageEntry] of Object.entries(packageLock.packages ?? {})) { + if (!lockPath.startsWith("node_modules/") || !packageEntry.version) { + continue; + } + + const installedPackageJsonPath = packagePathForLockPath(rootDir, lockPath); + if (!fs.existsSync(installedPackageJsonPath)) { + continue; + } + + const installedPackageJson = readJson(installedPackageJsonPath); + checkedInstalledPackages += 1; + + if (installedPackageJson.version !== packageEntry.version) { + failures.push( + `${lockPath} is installed at ${installedPackageJson.version}, but package-lock.json expects ${packageEntry.version}.`, + ); + } + } + + return { failures, checkedInstalledPackages }; +} + +function getExpectedPackageManager(packageJson) { + return typeof packageJson.packageManager === "string" ? packageJson.packageManager : ""; +} + +function isStrictInstalledVersionCheck(env) { + return env.CI === "true" || env[STRICT_INSTALLED_VERSION_ENV] === "1"; +} + +function isPnpmRuntime(env) { + const markers = [ + env.npm_config_user_agent, + env.npm_execpath, + env.npm_node_execpath, + env.npm_config_userconfig, + ].filter(Boolean); + + return markers.some((marker) => /(^|[/\s])pnpm(?:[/@\s.]|$)/i.test(marker)); +} + +export function checkPackageManagerParity({ rootDir = process.cwd(), env = process.env } = {}) { + const failures = []; + const packageJsonPath = path.join(rootDir, "package.json"); + const packageLockPath = path.join(rootDir, "package-lock.json"); + const pnpmLockPath = path.join(rootDir, "pnpm-lock.yaml"); + + if (!fs.existsSync(packageJsonPath)) { + failures.push("package.json is missing."); + } + + if (!fs.existsSync(packageLockPath)) { + failures.push("package-lock.json is missing; validation must use the committed npm lockfile."); + } + + if (fs.existsSync(pnpmLockPath)) { + failures.push("pnpm-lock.yaml exists at the repository root; remove it before running validation."); + } + + if (isPnpmRuntime(env)) { + failures.push("validation is running under pnpm; use a real npm binary with package-lock.json instead."); + } + + if (failures.length > 0 || !fs.existsSync(packageJsonPath) || !fs.existsSync(packageLockPath)) { + return { ok: false, failures, checkedInstalledPackages: 0 }; + } + + const packageJson = readJson(packageJsonPath); + const packageLock = readJson(packageLockPath); + const expectedPackageManager = getExpectedPackageManager(packageJson); + + if (!expectedPackageManager) { + failures.push("package.json packageManager must be set to the expected npm runtime."); + } else if (!expectedPackageManager.startsWith("npm@")) { + failures.push( + `package.json packageManager must use npm, found ${JSON.stringify(packageJson.packageManager)}.`, + ); + } + + failures.push(...collectDependencySpecFailures(packageJson, packageLock)); + + const strictInstalledVersions = isStrictInstalledVersionCheck(env); + let checkedInstalledPackages = 0; + + if (strictInstalledVersions) { + const installedVersionResult = collectInstalledVersionFailures(rootDir, packageLock); + checkedInstalledPackages = installedVersionResult.checkedInstalledPackages; + failures.push(...installedVersionResult.failures); + } + + return { + ok: failures.length === 0, + failures, + checkedInstalledPackages, + strictInstalledVersions, + expectedPackageManager, + }; +} + +export function formatPackageManagerParityResult(result) { + if (result.ok) { + const installedStatus = result.strictInstalledVersions + ? `checked ${result.checkedInstalledPackages} installed package version(s)` + : "skipped installed package version checks outside CI"; + return `package-manager parity ok: package-lock.json is authoritative for ${result.expectedPackageManager}; ${installedStatus}.`; + } + + return [ + "Package manager parity check failed:", + ...result.failures.map((failure) => `- ${failure}`), + "", + "Use a real npm binary and `npm ci` to restore package-lock.json parity. Do not commit pnpm-lock.yaml.", + ].join("\n"); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + const result = checkPackageManagerParity(); + const message = formatPackageManagerParityResult(result); + + if (result.ok) { + console.log(message); + } else { + console.error(message); + process.exitCode = 1; + } +} diff --git a/scripts/ensure-macos-runtime-deps.mjs b/scripts/ensure-macos-runtime-deps.mjs new file mode 100644 index 00000000..10dd167d --- /dev/null +++ b/scripts/ensure-macos-runtime-deps.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; + +if (process.platform !== 'darwin') { + process.exit(0); +} + +const require = createRequire(import.meta.url); +const REQUIRED_PACKAGES = ['electron-liquid-glass']; + +let missing = 0; + +for (const packageName of REQUIRED_PACKAGES) { + try { + require(packageName); + } catch (error) { + missing++; + console.error(`ensure-macos-runtime-deps: failed to load ${packageName}.`); + console.error(error); + } +} + +if (missing > 0) { + process.exit(1); +} + +console.log('ensure-macos-runtime-deps: macOS runtime packages are available.'); diff --git a/scripts/file-search-perf-harness.mjs b/scripts/file-search-perf-harness.mjs new file mode 100644 index 00000000..fb6ae292 --- /dev/null +++ b/scripts/file-search-perf-harness.mjs @@ -0,0 +1,561 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import { importTs } from './lib/ts-import.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const FILE_SEARCH_INDEX_TS = path.join(REPO_ROOT, 'src/main/file-search-index.ts'); + +const DEFAULT_CONFIG = { + projects: 24, + modulesPerProject: 4, + filesPerModule: 8, + queryRuns: 5, + updateCount: 32, + deleteCount: 32, + limit: 12, + keep: false, + json: false, + output: '', + includeProtectedHomeRoots: true, + thresholds: {}, +}; + +const FILE_NAME_PATTERNS = [ + 'command-palette-{project}-{module}-{file}.ts', + 'launch-plan-alpha-{project}-{module}-{file}.md', + 'invoice-report-{project}-{module}-{file}.json', + 'notes-search-index-{project}-{module}-{file}.txt', + 'shortcut-resolver-{project}-{module}-{file}.tsx', + 'settings-migration-{project}-{module}-{file}.ts', + 'quick-action-workflow-{project}-{module}-{file}.md', + 'browser-history-cache-{project}-{module}-{file}.json', +]; + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function formatPattern(pattern, values) { + return pattern + .replaceAll('{project}', values.project) + .replaceAll('{module}', values.module) + .replaceAll('{file}', values.file); +} + +function readNumber(value, fallback, min = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function readOptionalNumber(value) { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function getArgValue(args, name) { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + if (index >= 0 && index + 1 < args.length) return args[index + 1]; + return undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function getUsage() { + return [ + 'Usage: node scripts/file-search-perf-harness.mjs [options]', + '', + 'Options:', + ' --projects Number of synthetic projects', + ' --modules Modules per project', + ' --files-per-module Files per module', + ' --query-runs Query repetitions per query', + ' --updates Watcher-style added paths', + ' --deletes Deleted paths in the tombstone batch', + ' --limit Search result limit', + ' --json Print JSON output', + ' --output Write the same output to a file', + ' --keep Keep the temp fixture for inspection', + ' --threshold-index-ms Fail if initial indexing exceeds n ms', + ' --threshold-normal-query-p95-ms Fail if normal query p95 exceeds n ms', + ' --threshold-path-query-p95-ms Fail if path-like query p95 exceeds n ms', + ' --threshold-event-loop-lag-p95-ms Fail if measured event-loop lag p95 exceeds n ms', + ' --threshold-watch-update-ms Fail if watcher-style batch exceeds n ms', + ' --threshold-delete-ms Fail if delete batch exceeds n ms', + ].join('\n'); +} + +export function parseFileSearchPerfHarnessArgs(args = process.argv.slice(2)) { + return { + projects: readNumber(getArgValue(args, '--projects'), DEFAULT_CONFIG.projects), + modulesPerProject: readNumber(getArgValue(args, '--modules'), DEFAULT_CONFIG.modulesPerProject), + filesPerModule: readNumber(getArgValue(args, '--files-per-module'), DEFAULT_CONFIG.filesPerModule), + queryRuns: readNumber(getArgValue(args, '--query-runs'), DEFAULT_CONFIG.queryRuns), + updateCount: readNumber(getArgValue(args, '--updates'), DEFAULT_CONFIG.updateCount), + deleteCount: readNumber(getArgValue(args, '--deletes'), DEFAULT_CONFIG.deleteCount), + limit: readNumber(getArgValue(args, '--limit'), DEFAULT_CONFIG.limit), + keep: hasFlag(args, '--keep'), + json: hasFlag(args, '--json'), + output: getArgValue(args, '--output') || '', + includeProtectedHomeRoots: !hasFlag(args, '--exclude-protected-home-roots'), + thresholds: { + initialIndexMs: readOptionalNumber(getArgValue(args, '--threshold-index-ms')), + normalQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-normal-query-p95-ms')), + pathQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-path-query-p95-ms')), + eventLoopLagP95Ms: readOptionalNumber(getArgValue(args, '--threshold-event-loop-lag-p95-ms')), + watchUpdateBatchMs: readOptionalNumber(getArgValue(args, '--threshold-watch-update-ms')), + deleteBatchMs: readOptionalNumber(getArgValue(args, '--threshold-delete-ms')), + }, + }; +} + +function mergeConfig(overrides = {}) { + return { + ...DEFAULT_CONFIG, + ...overrides, + thresholds: { + ...DEFAULT_CONFIG.thresholds, + ...(overrides.thresholds || {}), + }, + }; +} + +async function writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function createSyntheticHome(config) { + const tempRootRaw = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-file-search-perf-')); + const tempRoot = await fs.realpath(tempRootRaw); + const homeDir = path.join(tempRoot, 'home'); + const benchRoot = path.join(homeDir, 'BenchRoot'); + const projectsRoot = path.join(benchRoot, 'Projects'); + const archiveRoot = path.join(benchRoot, 'Archive'); + const referenceRoot = path.join(benchRoot, 'References'); + const filePaths = []; + const deleteCandidates = []; + + await fs.mkdir(projectsRoot, { recursive: true }); + await fs.mkdir(archiveRoot, { recursive: true }); + await fs.mkdir(referenceRoot, { recursive: true }); + + for (let projectIndex = 0; projectIndex < config.projects; projectIndex += 1) { + const project = `project-${pad(projectIndex)}`; + for (let moduleIndex = 0; moduleIndex < config.modulesPerProject; moduleIndex += 1) { + const moduleName = `module-${pad(moduleIndex, 2)}`; + const srcDir = path.join(projectsRoot, project, moduleName, 'src'); + const docsDir = path.join(projectsRoot, project, moduleName, 'docs'); + + for (let fileIndex = 0; fileIndex < config.filesPerModule; fileIndex += 1) { + const file = pad(fileIndex, 2); + const pattern = FILE_NAME_PATTERNS[fileIndex % FILE_NAME_PATTERNS.length]; + const targetDir = fileIndex % 2 === 0 ? srcDir : docsDir; + const fileName = formatPattern(pattern, { project, module: moduleName, file }); + const filePath = path.join(targetDir, fileName); + await writeFile( + filePath, + [ + `project=${project}`, + `module=${moduleName}`, + `file=${file}`, + `intent=${fileName}`, + '', + ].join('\n') + ); + filePaths.push(filePath); + if (fileName.startsWith('launch-plan-alpha') || fileName.startsWith('invoice-report')) { + deleteCandidates.push(filePath); + } + } + } + + await writeFile( + path.join(archiveRoot, `release-notes-command-palette-${project}.md`), + `release notes for ${project}\n` + ); + await writeFile( + path.join(referenceRoot, `path-query-reference-${project}.txt`), + `path reference for ${project}\n` + ); + } + + return { + tempRoot, + homeDir, + benchRoot, + projectsRoot, + filePaths, + deleteCandidates, + }; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1)); + return sorted[index]; +} + +function summarizeDurations(samples) { + const totalMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0); + const durations = samples.map((sample) => sample.durationMs); + return { + runs: samples.length, + minMs: Number(Math.min(...durations).toFixed(3)), + meanMs: Number((totalMs / Math.max(1, samples.length)).toFixed(3)), + medianMs: Number(percentile(durations, 50).toFixed(3)), + p95Ms: Number(percentile(durations, 95).toFixed(3)), + maxMs: Number(Math.max(...durations).toFixed(3)), + totalResults: samples.reduce((sum, sample) => sum + sample.resultCount, 0), + }; +} + +function summarizeLag(samples) { + if (samples.length === 0) { + return { + samples: 0, + p95Ms: 0, + maxMs: 0, + }; + } + return { + samples: samples.length, + p95Ms: Number(percentile(samples, 95).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)), + }; +} + +async function measureEventLoopLagDuring(fn, intervalMs = 10) { + const samples = []; + let expectedAt = performance.now() + intervalMs; + const timer = setInterval(() => { + const now = performance.now(); + samples.push(Math.max(0, now - expectedAt)); + expectedAt = now + intervalMs; + }, intervalMs); + timer.unref?.(); + + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + const result = await fn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { + result, + eventLoopLag: summarizeLag(samples), + }; + } finally { + clearInterval(timer); + } +} + +async function measureDuration(fn) { + const startedAt = performance.now(); + const measured = await measureEventLoopLagDuring(fn); + return { + durationMs: performance.now() - startedAt, + result: measured.result, + eventLoopLag: measured.eventLoopLag, + }; +} + +function summarizeEventLoopDelay(histogram) { + return { + meanMs: Number((histogram.mean / 1_000_000).toFixed(3)), + maxMs: Number((histogram.max / 1_000_000).toFixed(3)), + p95Ms: Number((histogram.percentile(95) / 1_000_000).toFixed(3)), + }; +} + +async function measureDurationWithEventLoopDelay(fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + await new Promise((resolve) => setImmediate(resolve)); + const measured = await measureDuration(fn); + await new Promise((resolve) => setImmediate(resolve)); + histogram.disable(); + return { + ...measured, + eventLoopDelay: summarizeEventLoopDelay(histogram), + }; +} + +async function measureQueries(searchIndexedFiles, queries, config) { + const samples = []; + const lagMeasured = await measureEventLoopLagDuring(async () => { + for (let run = 0; run < config.queryRuns; run += 1) { + for (const query of queries) { + const measured = await measureDuration(() => searchIndexedFiles(query, { limit: config.limit })); + samples.push({ + query, + run, + durationMs: measured.durationMs, + resultCount: measured.result.length, + eventLoopLagP95Ms: measured.eventLoopLag.p95Ms, + }); + } + } + }); + return { + ...summarizeDurations(samples), + eventLoopLag: lagMeasured.eventLoopLag, + samples: samples.map((sample) => ({ + ...sample, + durationMs: Number(sample.durationMs.toFixed(3)), + })), + }; +} + +function buildQueries(homeDir) { + return { + normal: [ + 'command palette', + 'launch plan alpha', + 'invoice report', + 'notes search index', + ], + pathLike: [ + '~/BenchRoot/Projects/project-', + '~/BenchRoot/Archive/release-notes-command', + `${homeDir}/BenchRoot/References/path-query-reference`, + ], + }; +} + +async function createUpdateFiles(fixture, config) { + const updatePaths = []; + for (let index = 0; index < config.updateCount; index += 1) { + const project = `project-${pad(index % config.projects)}`; + const moduleName = `module-${pad(index % config.modulesPerProject, 2)}`; + const filePath = path.join( + fixture.projectsRoot, + project, + moduleName, + 'src', + `watcher-added-command-${pad(index)}.ts` + ); + await writeFile(filePath, `watcher added command ${index}\n`); + updatePaths.push(filePath); + } + return updatePaths; +} + +async function deleteFixtureFiles(fixture, config) { + const deletePaths = fixture.deleteCandidates.slice(0, config.deleteCount); + for (const filePath of deletePaths) { + await fs.rm(filePath, { force: true }); + } + return deletePaths; +} + +function evaluateThresholds(metrics, thresholds = {}) { + const checks = [ + ['initialIndexMs', metrics.initialIndexMs, thresholds.initialIndexMs], + ['normalQueryP95Ms', metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms], + ['pathQueryP95Ms', metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms], + ['eventLoopLagP95Ms', metrics.eventLoopLag.p95Ms, thresholds.eventLoopLagP95Ms], + ['watchUpdateBatchMs', metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs], + ['deleteBatchMs', metrics.deleteBatchMs, thresholds.deleteBatchMs], + ]; + const failures = checks + .filter(([, actual, threshold]) => typeof threshold === 'number' && actual > threshold) + .map(([name, actual, threshold]) => `${name} ${actual.toFixed(3)}ms exceeded ${threshold}ms`); + + return { + passed: failures.length === 0, + failures, + applied: Object.fromEntries( + checks + .filter(([, , threshold]) => typeof threshold === 'number') + .map(([name, , threshold]) => [name, threshold]) + ), + }; +} + +function roundMetric(value) { + return Number(value.toFixed(3)); +} + +function toDisplayLines(summary) { + const thresholdLine = summary.thresholds.applied && Object.keys(summary.thresholds.applied).length > 0 + ? summary.thresholds.passed + ? 'Thresholds: passed' + : `Thresholds: failed (${summary.thresholds.failures.join('; ')})` + : 'Thresholds: not applied'; + + return [ + 'File search perf harness', + `Home fixture: ${summary.fixture.homeDir}`, + `Indexed entries: ${summary.fixture.initialEntryCount}`, + `Initial indexing: ${summary.metrics.initialIndexMs.toFixed(3)}ms (event-loop delay p95 ${summary.metrics.initialIndexEventLoopDelay.p95Ms.toFixed(3)}ms, max ${summary.metrics.initialIndexEventLoopDelay.maxMs.toFixed(3)}ms)`, + `Normal queries: mean ${summary.metrics.normalQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.normalQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.normalQueries.totalResults}`, + `Path-like queries: mean ${summary.metrics.pathLikeQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.pathLikeQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.pathLikeQueries.totalResults}`, + `Event-loop lag: p95 ${summary.metrics.eventLoopLag.p95Ms.toFixed(3)}ms, max ${summary.metrics.eventLoopLag.maxMs.toFixed(3)}ms`, + `Watcher-style updates: ${summary.metrics.watchUpdateBatchMs.toFixed(3)}ms for ${summary.fixture.updateCount} paths`, + `Delete batch: ${summary.metrics.deleteBatchMs.toFixed(3)}ms for ${summary.fixture.deleteCount} paths`, + `Post-update query: ${summary.metrics.postUpdateQueryMs.toFixed(3)}ms (${summary.verification.postUpdateResultCount} results)`, + `Deleted exact matches after tombstone: ${summary.verification.deletedExactMatches}`, + thresholdLine, + ]; +} + +export async function runFileSearchPerfHarness(overrides = {}) { + const config = mergeConfig(overrides); + const fixture = await createSyntheticHome(config); + let fileSearchIndexPerfHarness = null; + let summary = null; + let cleanupCompleted = false; + try { + const fileSearchIndex = await importTs(FILE_SEARCH_INDEX_TS); + const { + __fileSearchIndexPerfHarness, + getFileSearchIndexStatus, + searchIndexedFiles, + } = fileSearchIndex; + fileSearchIndexPerfHarness = __fileSearchIndexPerfHarness; + + if (!fileSearchIndexPerfHarness) { + throw new Error('Missing __fileSearchIndexPerfHarness export from file-search-index.ts'); + } + + const queries = buildQueries(fixture.homeDir); + const initialIndex = await measureDurationWithEventLoopDelay(() => + fileSearchIndexPerfHarness.rebuild({ + homeDir: fixture.homeDir, + includeProtectedHomeRoots: config.includeProtectedHomeRoots, + }) + ); + const statusAfterIndex = getFileSearchIndexStatus(); + + const normalQueries = await measureQueries(searchIndexedFiles, queries.normal, config); + const pathLikeQueries = await measureQueries(searchIndexedFiles, queries.pathLike, config); + + const updatePaths = await createUpdateFiles(fixture, config); + const watchUpdate = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(updatePaths) + ); + const postUpdateQuery = await measureDuration(() => + searchIndexedFiles('watcher added command', { limit: config.limit }) + ); + + const deletePaths = await deleteFixtureFiles(fixture, config); + const deleteBatch = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(deletePaths) + ); + const deletedNeedle = deletePaths[0] ? path.basename(deletePaths[0]) : ''; + const postDeleteQuery = await measureDuration(() => + deletedNeedle ? searchIndexedFiles(deletedNeedle, { limit: config.limit }) : Promise.resolve([]) + ); + const deletedExactMatches = deletedNeedle + ? postDeleteQuery.result.filter((result) => result.name === deletedNeedle).length + : 0; + + const metrics = { + initialIndexMs: roundMetric(initialIndex.durationMs), + initialIndexEventLoopDelay: initialIndex.eventLoopDelay, + normalQueries, + pathLikeQueries, + watchUpdateBatchMs: roundMetric(watchUpdate.durationMs), + postUpdateQueryMs: roundMetric(postUpdateQuery.durationMs), + deleteBatchMs: roundMetric(deleteBatch.durationMs), + postDeleteQueryMs: roundMetric(postDeleteQuery.durationMs), + }; + const eventLoopLagSamples = [ + initialIndex.eventLoopLag, + normalQueries.eventLoopLag, + pathLikeQueries.eventLoopLag, + watchUpdate.eventLoopLag, + postUpdateQuery.eventLoopLag, + deleteBatch.eventLoopLag, + postDeleteQuery.eventLoopLag, + ]; + metrics.eventLoopLag = { + p95Ms: Math.max(...eventLoopLagSamples.map((sample) => sample.p95Ms)), + maxMs: Math.max(...eventLoopLagSamples.map((sample) => sample.maxMs)), + samples: eventLoopLagSamples.reduce((sum, sample) => sum + sample.samples, 0), + }; + const realTempDir = await fs.realpath(os.tmpdir()); + + summary = { + config: { + projects: config.projects, + modulesPerProject: config.modulesPerProject, + filesPerModule: config.filesPerModule, + queryRuns: config.queryRuns, + updateCount: config.updateCount, + deleteCount: config.deleteCount, + limit: config.limit, + }, + fixture: { + tempRoot: fixture.tempRoot, + homeDir: fixture.homeDir, + initialFileCount: fixture.filePaths.length + config.projects * 2, + initialEntryCount: statusAfterIndex.indexedEntryCount, + updateCount: updatePaths.length, + deleteCount: deletePaths.length, + }, + metrics, + verification: { + postUpdateResultCount: postUpdateQuery.result.length, + postDeleteResultCount: postDeleteQuery.result.length, + deletedExactMatches, + homeDirectoryUsed: statusAfterIndex.homeDirectory, + homeIsTempDirectory: fixture.homeDir.startsWith(realTempDir), + }, + thresholds: evaluateThresholds(metrics, config.thresholds), + cleanup: { + keptFixture: Boolean(config.keep), + completed: false, + }, + }; + + return summary; + } finally { + fileSearchIndexPerfHarness?.reset(); + if (!config.keep) { + await fs.rm(fixture.tempRoot, { recursive: true, force: true }); + cleanupCompleted = true; + } + if (summary) { + summary.cleanup.completed = cleanupCompleted; + } + } +} + +async function runCli() { + if (hasFlag(process.argv, '--help')) { + process.stdout.write(`${getUsage()}\n`); + return; + } + + const config = parseFileSearchPerfHarnessArgs(); + const summary = await runFileSearchPerfHarness(config); + const output = config.json ? `${JSON.stringify(summary, null, 2)}\n` : `${toDisplayLines(summary).join('\n')}\n`; + + if (config.output) { + await fs.writeFile(path.resolve(config.output), output); + } + process.stdout.write(output); + + if (!summary.thresholds.passed) { + process.exitCode = 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/run-node-tests.mjs b/scripts/run-node-tests.mjs new file mode 100644 index 00000000..89c61062 --- /dev/null +++ b/scripts/run-node-tests.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const TEST_DIR = path.join(REPO_ROOT, 'scripts'); +const EXCLUDED_PERF_TESTS = new Set([ + 'test-browser-search-performance.mjs', + 'test-file-search-perf-harness.mjs', + 'test-root-search-perf.mjs', +]); + +const args = new Set(process.argv.slice(2)); +const excludePerf = args.has('--exclude-perf'); +const entries = await fs.readdir(TEST_DIR); +const testFiles = entries + .filter((entry) => /^test-.*\.mjs$/.test(entry)) + .filter((entry) => !excludePerf || !EXCLUDED_PERF_TESTS.has(entry)) + .sort() + .map((entry) => path.join('scripts', entry)); + +if (testFiles.length === 0) { + throw new Error('No Node test files matched the requested filters.'); +} + +if (excludePerf) { + console.log(`Running ${testFiles.length} Node test files; excluded ${EXCLUDED_PERF_TESTS.size} perf harness files.`); +} + +const child = spawn(process.execPath, ['--test', ...testFiles], { + cwd: REPO_ROOT, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/scripts/test-browser-search-performance.mjs b/scripts/test-browser-search-performance.mjs new file mode 100644 index 00000000..a34a7d4c --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,475 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const FIXED_NOW = Date.UTC(2026, 6, 3, 12, 0, 0); +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; +const IS_PERF_REPORT = IS_PERF_CI + || process.env.SUPERCMD_PERF_REPORT === '1' + || process.argv.includes('--report') + || process.argv.includes('--json'); + +class FixedDate extends Date { + constructor(...args) { + super(...(args.length ? args : [FIXED_NOW])); + } + + static now() { + return FIXED_NOW; + } +} + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return /\.[cm]?[tj]sx?$/.test(nextPath) ? loadTsModule(nextPath) : require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Intl, + Date: FixedDate, + Math, + String, + Number, + Boolean, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function buildSyntheticBrowserData(options = {}) { + const { + historyCount = 4_000, + bookmarkCount = 800, + tabCount = 120, + } = options; + const profiles = ['chrome:Default', 'chrome:Work', 'arc:Default', 'brave:Default']; + const topics = ['github', 'docs', 'linear', 'notion', 'calendar', 'supercmd', 'typescript', 'electron', 'raycast', 'browser']; + const entries = []; + const tabs = []; + + for (let index = 0; index < historyCount; index += 1) { + const topic = topics[index % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}${index % 700}.example.com`; + entries.push({ + id: `h-${index}`, + type: 'url', + query: `${topic} history page ${index}`, + url: `https://${host}/workspace/${index % 200}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 371_000, + useCount: (index % 23) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + }); + } + + for (let index = 0; index < bookmarkCount; index += 1) { + const topic = topics[(index * 3) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-bookmark${index % 600}.example.com`; + entries.push({ + id: `b-${index}`, + type: 'bookmark', + query: `${topic} bookmark reference ${index}`, + url: `https://${host}/saved/${index % 300}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 997_000, + useCount: (index % 11) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + bookmarkFolder: `Folder ${index % 25}`, + bookmarkOrder: index, + }); + } + + const nicknameBookmark = { + id: 'b-nickname-github', + type: 'bookmark', + query: 'GitHub Pull Requests', + url: 'https://github.com/SuperCmdLabs/SuperCmd/pulls', + host: 'github.com', + lastUsedAt: FIXED_NOW, + useCount: 40, + source: 'chrome', + sourceProfileId: 'chrome:Default', + sourceProfileName: 'Default', + bookmarkFolder: 'Development', + bookmarkOrder: -1, + }; + entries.push(nicknameBookmark); + + for (let index = 0; index < tabCount; index += 1) { + const topic = topics[(index * 7) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-tab${index % 120}.example.com`; + tabs.push({ + id: `t-${index}`, + browserId: source, + browserName: source, + profileId: profile.split(':')[1], + profileSourceId: profile, + profileName: profile.split(':')[1], + windowId: String(index % 8), + windowOrdinal: index % 8, + tabId: String(index), + tabIndex: index % 40, + favIconUrl: '', + title: `${topic} active tab ${index}`, + url: `https://${host}/open/${index}`, + host, + active: index % 37 === 0, + windowLastFocusedAt: FIXED_NOW - (index % 120) * 60_000, + updatedAt: FIXED_NOW - index * 60_000, + }); + } + + const nicknames = [{ + source: nicknameBookmark.source, + sourceProfileId: nicknameBookmark.sourceProfileId, + url: nicknameBookmark.url, + nickname: 'gh', + }]; + + return { entries, tabs, nicknames }; +} + +function resultSignature(results) { + return results.map((result) => [ + result.id, + result.kind, + result.title, + result.url, + result.completion || '', + result.matchKind || '', + Boolean(result.nicknameMatch), + ]); +} + +function measureAverageMs(iterations, queries, fn) { + const byQuery = {}; + for (const query of queries) { + const start = performance.now(); + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const results = fn(query); + checksum += results.length + (results[0]?.id?.length || 0); + } + byQuery[query] = { + avgMs: (performance.now() - start) / iterations, + checksum, + }; + } + return byQuery; +} + +async function measureBlockedTurn(fn) { + const scheduledAt = performance.now(); + const delayPromise = new Promise((resolve) => { + setTimeout(() => resolve(performance.now() - scheduledAt), 0); + }); + const start = performance.now(); + const result = fn(); + const durationMs = performance.now() - start; + const eventLoopDelayMs = await delayPromise; + return { + result, + durationMs, + eventLoopDelayMs, + }; +} + +function browserResultGroups() { + return [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; +} + +function browserQueries() { + return [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; +} + +function assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, +}) { + assert.deepEqual( + resultSignature(getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60)), + resultSignature(getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60)), + `ranked results should match the full scan for ${query}` + ); + assert.deepEqual( + resultSignature(getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true })), + resultSignature(getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true })), + `ordered results should match the full scan for ${query}` + ); +} + +function roundReportValues(values) { + return Object.fromEntries( + Object.entries(values).map(([query, item]) => [query, { + avgMs: Number(item.avgMs.toFixed(2)), + checksum: item.checksum, + }]) + ); +} + +function printBrowserSearchPerfReport(report) { + if (!IS_PERF_REPORT) return; + console.log(JSON.stringify({ browserSearchPerf: report }, null, 2)); +} + +function budgetEntry(actual, budget) { + return { + actualMs: actual, + budgetMs: budget, + budgetUsedPct: Number(((actual / budget) * 100).toFixed(1)), + }; +} + +test('browser search indexed harness preserves full-scan result order', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = browserResultGroups(); + const queries = browserQueries(); + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } +}); + +test('browser search indexed harness preserves profile filtering', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, filterBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 4 }, + { kind: 'open-tab', limit: 4 }, + { kind: 'history', limit: 4 }, + ]; + const profiles = [ + { id: 'chrome:Default', displayName: 'Chrome Default', detectedName: 'Default', profileId: 'Default', browserId: 'chrome', browserName: 'Chrome', order: 0 }, + { id: 'chrome:Work', displayName: 'Chrome Work', detectedName: 'Work', profileId: 'Work', browserId: 'chrome', browserName: 'Chrome', order: 1 }, + { id: 'arc:Default', displayName: 'Arc Default', detectedName: 'Default', profileId: 'Default', browserId: 'arc', browserName: 'Arc', order: 2 }, + { id: 'brave:Default', displayName: 'Brave Default', detectedName: 'Default', profileId: 'Default', browserId: 'brave', browserName: 'Brave', order: 3 }, + ]; + const filters = { + 'open-tab': ['chrome:Default'], + bookmark: ['chrome:Default'], + history: ['chrome:Default'], + }; + + const filtered = filterBrowserResults( + getRankedBrowserResults('github', groups, entries, entryIndex, tabs, nicknames, 60), + filters, + profiles + ); + + assert.ok(filtered.length > 0, 'profile-filtered search should keep matching results'); + assert.ok( + filtered.every((result) => !result.sourceProfileId || result.sourceProfileId === 'chrome:Default'), + 'profile filtering should only keep enabled profile IDs' + ); +}); + +test('browser search indexed harness stays under generous query thresholds', async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = browserResultGroups(); + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + const rankedIndexed = measureAverageMs(15, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + ); + const orderedIndexed = measureAverageMs(15, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedMaxMs = Math.max(...Object.values(rankedIndexed).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedIndexed).map((item) => item.avgMs)); + const report = { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedAvgMs: roundReportValues(rankedIndexed), + orderedIndexedAvgMs: roundReportValues(orderedIndexed), + }; + + if (IS_PERF_REPORT) { + report.rankedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + )); + report.orderedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + )); + } + printBrowserSearchPerfReport(report); + + assert.ok(indexMeasurement.durationMs < 1_500, `index build should stay below 1500ms, got ${indexMeasurement.durationMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < 120, `ranked indexed search should stay below 120ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < 120, `ordered indexed search should stay below 120ms, got ${orderedMaxMs.toFixed(2)}ms`); +}); + +test('browser search perf CI covers large indexed responsiveness budgets', { skip: !IS_PERF_CI }, async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const groups = browserResultGroups(); + const queries = ['github', 'supercmd', 'electron workspace', 'bookmark reference 42', 'https://typescript', 'gh']; + const datasets = [ + { + label: '25k', + options: { historyCount: 21_000, bookmarkCount: 3_999, tabCount: 240 }, + budgets: { indexMs: 3_500, eventLoopDelayMs: 4_000, queryAvgMs: 180, queryEventLoopDelayMs: 4_000 }, + }, + { + label: '50k', + options: { historyCount: 45_000, bookmarkCount: 4_999, tabCount: 360 }, + budgets: { indexMs: 7_000, eventLoopDelayMs: 7_500, queryAvgMs: 300, queryEventLoopDelayMs: 8_000 }, + }, + ]; + const reports = []; + + for (const dataset of datasets) { + const { entries, tabs, nicknames } = buildSyntheticBrowserData(dataset.options); + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } + + const rankedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + )); + const orderedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + )); + const rankedMaxMs = Math.max(...Object.values(rankedMeasurement.result).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedMeasurement.result).map((item) => item.avgMs)); + + reports.push({ + dataset: { label: dataset.label, entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedMaxAvgMs: Number(rankedMaxMs.toFixed(2)), + orderedIndexedMaxAvgMs: Number(orderedMaxMs.toFixed(2)), + rankedEventLoopDelayMs: Number(rankedMeasurement.eventLoopDelayMs.toFixed(2)), + orderedEventLoopDelayMs: Number(orderedMeasurement.eventLoopDelayMs.toFixed(2)), + budgets: { + indexMs: budgetEntry(Number(indexMeasurement.durationMs.toFixed(2)), dataset.budgets.indexMs), + indexEventLoopDelayMs: budgetEntry(Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.eventLoopDelayMs), + rankedIndexedMaxAvgMs: budgetEntry(Number(rankedMaxMs.toFixed(2)), dataset.budgets.queryAvgMs), + orderedIndexedMaxAvgMs: budgetEntry(Number(orderedMaxMs.toFixed(2)), dataset.budgets.queryAvgMs), + rankedEventLoopDelayMs: budgetEntry(Number(rankedMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.queryEventLoopDelayMs), + orderedEventLoopDelayMs: budgetEntry(Number(orderedMeasurement.eventLoopDelayMs.toFixed(2)), dataset.budgets.queryEventLoopDelayMs), + }, + }); + + assert.ok(indexMeasurement.durationMs < dataset.budgets.indexMs, `${dataset.label} index build should stay below ${dataset.budgets.indexMs}ms, got ${indexMeasurement.durationMs.toFixed(2)}ms`); + assert.ok(indexMeasurement.eventLoopDelayMs < dataset.budgets.eventLoopDelayMs, `${dataset.label} index event-loop delay should stay below ${dataset.budgets.eventLoopDelayMs}ms, got ${indexMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ranked indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ordered indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${orderedMaxMs.toFixed(2)}ms`); + assert.ok(rankedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ranked query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${rankedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(orderedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ordered query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${orderedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + } + + printBrowserSearchPerfReport({ largeDatasets: reports }); +}); diff --git a/scripts/test-file-search-perf-harness.mjs b/scripts/test-file-search-perf-harness.mjs new file mode 100644 index 00000000..e0f01085 --- /dev/null +++ b/scripts/test-file-search-perf-harness.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { runFileSearchPerfHarness } from './file-search-perf-harness.mjs'; + +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; +const IS_PERF_REPORT = IS_PERF_CI || process.env.SUPERCMD_PERF_REPORT === '1'; + +function budgetEntry(actual, budget) { + return { + actualMs: actual, + budgetMs: budget, + budgetUsedPct: budget > 0 ? Number(((actual / budget) * 100).toFixed(1)) : null, + }; +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function pickBudgetEvidence(summary) { + const thresholds = summary.thresholds.applied; + return { + label: summary.label, + config: summary.config, + fixture: { + indexedEntries: summary.fixture.initialEntryCount, + indexedFiles: summary.fixture.initialFileCount, + updateCount: summary.fixture.updateCount, + deleteCount: summary.fixture.deleteCount, + }, + budgets: { + initialIndexMs: budgetEntry(summary.metrics.initialIndexMs, thresholds.initialIndexMs), + normalQueryP95Ms: budgetEntry(summary.metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms), + pathQueryP95Ms: budgetEntry(summary.metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms), + eventLoopLagP95Ms: budgetEntry(summary.metrics.eventLoopLag.p95Ms, thresholds.eventLoopLagP95Ms), + watchUpdateBatchMs: budgetEntry(summary.metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs), + deleteBatchMs: budgetEntry(summary.metrics.deleteBatchMs, thresholds.deleteBatchMs), + }, + metrics: { + initialIndexMs: summary.metrics.initialIndexMs, + initialIndexEventLoopDelayP95Ms: summary.metrics.initialIndexEventLoopDelay.p95Ms, + normalQueryP95Ms: summary.metrics.normalQueries.p95Ms, + pathQueryP95Ms: summary.metrics.pathLikeQueries.p95Ms, + eventLoopLagP95Ms: summary.metrics.eventLoopLag.p95Ms, + eventLoopLagMaxMs: summary.metrics.eventLoopLag.maxMs, + watchUpdateBatchMs: summary.metrics.watchUpdateBatchMs, + deleteBatchMs: summary.metrics.deleteBatchMs, + postUpdateQueryMs: summary.metrics.postUpdateQueryMs, + postDeleteQueryMs: summary.metrics.postDeleteQueryMs, + }, + thresholds: summary.thresholds, + thresholdStatus: summary.thresholds.passed ? 'passed' : 'failed', + cleanup: summary.cleanup, + }; +} + +function printFileSearchPerfReport(label, summary) { + if (!IS_PERF_REPORT) return; + console.log(JSON.stringify({ fileSearchPerf: pickBudgetEvidence({ ...summary, label }) }, null, 2)); +} + +test('file search perf harness covers deterministic temp-home scenarios', async () => { + const summary = await runFileSearchPerfHarness({ + projects: 8, + modulesPerProject: 3, + filesPerModule: 6, + queryRuns: 2, + updateCount: 10, + deleteCount: 10, + limit: 8, + thresholds: { + initialIndexMs: 15_000, + normalQueryP95Ms: 2_000, + pathQueryP95Ms: 2_000, + eventLoopLagP95Ms: 2_000, + watchUpdateBatchMs: 5_000, + deleteBatchMs: 5_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + printFileSearchPerfReport('deterministic-small', summary); + assert.equal(summary.verification.homeDirectoryUsed, summary.fixture.homeDir); + assert.equal(summary.verification.homeIsTempDirectory, true); + assert.ok( + summary.fixture.homeDir.startsWith(await fs.realpath(os.tmpdir())), + 'harness must use an OS temp home' + ); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'temp fixture should be cleaned up'); + + assert.ok(summary.fixture.initialEntryCount >= summary.fixture.initialFileCount); + assert.ok(summary.metrics.initialIndexMs >= 0); + assert.ok(summary.metrics.normalQueries.totalResults > 0); + assert.ok(summary.metrics.pathLikeQueries.totalResults > 0); + assert.ok(summary.metrics.eventLoopLag.p95Ms >= 0); + assert.ok(summary.verification.postUpdateResultCount > 0); + assert.equal(summary.verification.deletedExactMatches, 0); +}); + +test('file search perf CI covers large path-query p95 and event-loop lag budgets', { skip: !IS_PERF_CI }, async () => { + const summary = await runFileSearchPerfHarness({ + projects: 120, + modulesPerProject: 8, + filesPerModule: 32, + queryRuns: 3, + updateCount: 64, + deleteCount: 64, + limit: 16, + thresholds: { + initialIndexMs: 60_000, + normalQueryP95Ms: 3_000, + pathQueryP95Ms: 3_000, + eventLoopLagP95Ms: 5_000, + watchUpdateBatchMs: 10_000, + deleteBatchMs: 10_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + printFileSearchPerfReport('ci-large', summary); + assert.ok( + summary.fixture.initialEntryCount >= 30_000 && summary.fixture.initialEntryCount <= 60_000, + `expected 30k-60k indexed entries, got ${summary.fixture.initialEntryCount}` + ); + assert.ok(summary.metrics.pathLikeQueries.p95Ms <= summary.thresholds.applied.pathQueryP95Ms); + assert.ok(summary.metrics.eventLoopLag.p95Ms <= summary.thresholds.applied.eventLoopLagP95Ms); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'large temp fixture should be cleaned up'); +}); diff --git a/scripts/test-package-manager-parity.mjs b/scripts/test-package-manager-parity.mjs new file mode 100644 index 00000000..8fd02743 --- /dev/null +++ b/scripts/test-package-manager-parity.mjs @@ -0,0 +1,171 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkPackageManagerParity } from "./check-package-manager-parity.mjs"; + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function createFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "supercmd-package-manager-parity-")); + const packageJson = { + name: "fixture", + version: "1.0.0", + packageManager: "npm@11.12.1", + dependencies: { + "@raycast/api": "^1.104.5", + }, + devDependencies: { + typescript: "^5.3.3", + }, + }; + const packageLock = { + name: "fixture", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + name: "fixture", + version: "1.0.0", + dependencies: { + "@raycast/api": "^1.104.5", + }, + devDependencies: { + typescript: "^5.3.3", + }, + }, + "node_modules/@raycast/api": { + version: "1.104.19", + }, + "node_modules/typescript": { + version: "5.9.3", + }, + }, + }; + + writeJson(path.join(rootDir, "package.json"), packageJson); + writeJson(path.join(rootDir, "package-lock.json"), packageLock); + writeJson(path.join(rootDir, "node_modules/@raycast/api/package.json"), { + name: "@raycast/api", + version: "1.104.19", + }); + writeJson(path.join(rootDir, "node_modules/typescript/package.json"), { + name: "typescript", + version: "5.9.3", + }); + + return rootDir; +} + +test("package manager parity passes for npm lockfile and matching installed packages", () => { + const rootDir = createFixture(); + + const result = checkPackageManagerParity({ + rootDir, + env: { + CI: "true", + npm_config_user_agent: "npm/11.12.1 node/v22.22.3 darwin arm64 workspaces/false", + npm_execpath: "/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js", + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.checkedInstalledPackages, 2); + assert.equal(result.strictInstalledVersions, true); + assert.equal(result.expectedPackageManager, "npm@11.12.1"); +}); + +test("package manager parity blocks pnpm lifecycle execution", () => { + const rootDir = createFixture(); + + const result = checkPackageManagerParity({ + rootDir, + env: { + npm_config_user_agent: "pnpm/11.7.0 npm/? node/v22.22.3 darwin arm64", + npm_execpath: "/opt/homebrew/lib/node_modules/pnpm/bin/pnpm.cjs", + }, + }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /running under pnpm/); +}); + +test("package manager parity blocks generated root pnpm lockfiles", () => { + const rootDir = createFixture(); + fs.writeFileSync(path.join(rootDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /pnpm-lock\.yaml exists/); +}); + +test("package manager parity catches package.json and package-lock root spec drift", () => { + const rootDir = createFixture(); + const packageJsonPath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + packageJson.dependencies["@raycast/api"] = "^1.104.21"; + writeJson(packageJsonPath, packageJson); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /dependencies\.@raycast\/api/); +}); + +test("package manager parity catches installed package versions that differ from package-lock", () => { + const rootDir = createFixture(); + writeJson(path.join(rootDir, "node_modules/@raycast/api/package.json"), { + name: "@raycast/api", + version: "1.104.21", + }); + + const result = checkPackageManagerParity({ rootDir, env: { CI: "true" } }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /@raycast\/api is installed at 1\.104\.21/); +}); + +test("package manager parity skips installed package version drift for local lifecycle commands", () => { + const rootDir = createFixture(); + writeJson(path.join(rootDir, "node_modules/@raycast/api/package.json"), { + name: "@raycast/api", + version: "1.104.21", + }); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, true); + assert.equal(result.checkedInstalledPackages, 0); + assert.equal(result.strictInstalledVersions, false); +}); + +test("package manager parity reads the expected npm version from package.json", () => { + const rootDir = createFixture(); + const packageJsonPath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + packageJson.packageManager = "npm@11.13.0"; + writeJson(packageJsonPath, packageJson); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, true); + assert.equal(result.expectedPackageManager, "npm@11.13.0"); +}); + +test("package manager parity requires packageManager to name npm", () => { + const rootDir = createFixture(); + const packageJsonPath = path.join(rootDir, "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + packageJson.packageManager = "pnpm@11.7.0"; + writeJson(packageJsonPath, packageJson); + + const result = checkPackageManagerParity({ rootDir, env: {} }); + + assert.equal(result.ok, false); + assert.match(result.failures.join("\n"), /packageManager must use npm/); +}); diff --git a/scripts/test-root-command-ranking.mjs b/scripts/test-root-command-ranking.mjs new file mode 100644 index 00000000..3f10e108 --- /dev/null +++ b/scripts/test-root-command-ranking.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import test from 'node:test'; +import { createRequire } from 'module'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') return { renderQuickLinkIconGlyph: () => null }; + if (request.startsWith('../icons/')) return { __esModule: true, default: makeComponent(path.basename(request)) }; + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const { + createRootCommandScoreIndex, + rankCommands, + rankCommandsWithIndex, +} = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); + +const commands = [ + { id: 'notes-app', title: 'Notes', subtitle: 'Application', category: 'app' }, + { id: 'search-notes', title: 'Search Notes', subtitle: 'Extension command', category: 'extension' }, + { id: 'release-notes', title: 'Release Notes', subtitle: 'Documentation', category: 'system' }, + { id: 'quick-note', title: 'Quick Note', subtitle: 'Create a note', category: 'system', alwaysOnTop: true }, + { id: 'clipboard', title: 'Clipboard History', subtitle: 'Recent copied text', category: 'system' }, +]; + +const aliases = { + 'search-notes': 'sn', + 'quick-note': 'note', +}; + +function compact(matches) { + return matches.map(({ command, matchKind, matchScore }) => ({ + id: command.id, + matchKind, + matchScore, + })); +} + +test('indexed root command ranking preserves deterministic order and match metadata', () => { + const index = createRootCommandScoreIndex(commands, aliases); + const indexedMatches = compact(rankCommandsWithIndex(index, 'note')); + + assert.deepEqual(indexedMatches.map((match) => match.id), [ + 'quick-note', + 'notes-app', + 'release-notes', + 'search-notes', + 'clipboard', + ]); + assert.deepEqual(indexedMatches, compact(rankCommands(commands, 'note', aliases))); + assert.equal(indexedMatches[0].matchKind, 'alias-exact'); + assert.ok(indexedMatches[0].matchScore > indexedMatches[1].matchScore); +}); diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..f1fcc30f --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') { + return { renderQuickLinkIconGlyph: () => null }; + } + if (request.startsWith('../icons/')) { + return { __esModule: true, default: makeComponent(path.basename(request)) }; + } + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); +const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); + +const { createRootCommandScoreIndex, filterCommands, rankCommands, rankCommandsWithIndex } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 2000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 1); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 0); +const INDEXED_SPEEDUP_MIN = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN || 0); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +const WORDS = [ + 'Notes', + 'Clipboard', + 'Window', + 'Browser', + 'Settings', + 'Canvas', + 'Timer', + 'Schedule', + 'Project', + 'Deploy', + 'Debug', + 'GitHub', + 'Snippet', + 'Search', + 'Memory', + 'Automation', +]; + +function normalizeSearchText(value) { + return String(value || '') + .normalize('NFKD') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +function makeCommands(count) { + const commands = []; + const aliases = {}; + + for (let i = 0; i < count; i += 1) { + const primary = WORDS[i % WORDS.length]; + const secondary = WORDS[(i * 7 + 3) % WORDS.length]; + const group = i % 4 === 0 ? 'extension' : i % 4 === 1 ? 'script' : i % 4 === 2 ? 'app' : 'system'; + const command = { + id: `synthetic-command-${i}`, + title: `${primary} ${secondary} Command ${i}`, + subtitle: `${secondary} tools for Project Alpha workspace ${i % 97}`, + category: group, + path: group === 'extension' ? `extension-${i % 80}/command-${i}` : undefined, + keywords: [ + primary, + secondary, + `cmd ${i}`, + i % 9 === 0 ? 'project alpha' : 'workspace', + i % 11 === 0 ? 'gh github repo' : 'local action', + ], + alwaysOnTop: i % 997 === 0, + }; + commands.push(command); + + if (i % 5 === 0) aliases[command.id] = `${primary.toLowerCase()}-${i}`; + if (i % 37 === 0) aliases[command.id] = 'gh'; + } + + return { commands, aliases }; +} + +function rootCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.08 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.68 })), + ]; +} + +function legacyRankCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.7 })), + ]; +} + +function legacyRootCommandMatches(commands, query, aliases) { + const normalizedQuery = normalizeSearchText(query); + const ranked = commands + .map((command) => { + const alias = aliases[command.id] || ''; + const firstPass = scoreRootSearchFields(query, legacyRankCommandFields(command, alias)); + if (!firstPass.matched) return null; + return { + command, + score: firstPass.matchScore, + hasExactAliasMatch: Boolean(alias) && normalizeSearchText(alias) === normalizedQuery, + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } + if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.command.title.localeCompare(b.command.title); + }); + + return ranked + .map(({ command }) => { + const scored = scoreRootSearchFields(query, rootCommandFields(command, aliases[command.id] || '')); + assert.equal(scored.matched, true, `${command.id} lost its second-pass match for ${query}`); + return { + id: command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function optimizedRootCommandMatches(commands, query, aliases) { + return rankCommands(commands, query, aliases) + .map((entry) => { + if (typeof entry.matchKind === 'string' && typeof entry.matchScore === 'number') { + return { + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + }; + } + + const scored = scoreRootSearchFields(query, rootCommandFields(entry.command, aliases[entry.command.id] || '')); + assert.equal(scored.matched, true, `${entry.command.id} lost its optimized fallback match for ${query}`); + return { + id: entry.command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function indexedRootCommandMatches(index, query) { + return rankCommandsWithIndex(index, query) + .map((entry) => ({ + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + })); +} + +function orderedSignature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`); +} + +function assertSameRootCommandMatches(commands, aliases) { + const index = createRootCommandScoreIndex(commands, aliases); + for (const query of QUERIES) { + const legacySignature = orderedSignature(legacyRootCommandMatches(commands, query, aliases)); + assert.deepEqual( + orderedSignature(optimizedRootCommandMatches(commands, query, aliases)), + legacySignature, + `root command match order changed for query "${query}"` + ); + assert.deepEqual( + orderedSignature(indexedRootCommandMatches(index, query)), + legacySignature, + `indexed root command match order changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +function measureSingle(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) fn(); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const start = performance.now(); + total = fn(); + samples.push(performance.now() - start); + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +const { commands, aliases } = makeCommands(COMMAND_COUNT); + +assertSameRootCommandMatches(commands, aliases); +const commandScoreIndex = createRootCommandScoreIndex(commands, aliases); + +const legacy = measure('legacy filter + two-pass command scoring', (query) => { + const filtered = filterCommands(commands, query, aliases); + const matches = legacyRootCommandMatches(commands, query, aliases); + return filtered.length + matches.length; +}); + +const optimized = measure('optimized command scoring path', (query) => { + const matches = optimizedRootCommandMatches(commands, query, aliases); + return matches.length; +}); + +const indexed = measure('indexed command scoring path', (query) => { + const matches = indexedRootCommandMatches(commandScoreIndex, query); + return matches.length; +}); + +const compile = measureSingle('command scoring index compile', () => { + return createRootCommandScoreIndex(commands, aliases).entries.length; +}); + +const optimizedSpeedup = legacy.median > 0 ? legacy.median / optimized.median : 0; +const indexedSpeedup = legacy.median > 0 ? legacy.median / indexed.median : 0; +const indexedVsOptimizedSpeedup = optimized.median > 0 ? optimized.median / indexed.median : 0; + +console.log('Root search perf harness'); +console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); +console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); +console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); +console.log(`${indexed.label}: median=${indexed.median.toFixed(2)}ms min=${indexed.min.toFixed(2)}ms max=${indexed.max.toFixed(2)}ms total=${indexed.total}`); +console.log(`${compile.label}: median=${compile.median.toFixed(2)}ms min=${compile.min.toFixed(2)}ms max=${compile.max.toFixed(2)}ms total=${compile.total}`); +console.log(`legacy-vs-optimized-speedup=${optimizedSpeedup.toFixed(2)}x`); +console.log(`legacy-vs-indexed-speedup=${indexedSpeedup.toFixed(2)}x`); +console.log(`optimized-vs-indexed-speedup=${indexedVsOptimizedSpeedup.toFixed(2)}x`); + +if (INDEXED_SPEEDUP_MIN > 0) { + assert.ok( + indexedSpeedup >= INDEXED_SPEEDUP_MIN, + `indexed root command scoring should be at least ${INDEXED_SPEEDUP_MIN.toFixed(2)}x faster than legacy, got ${indexedSpeedup.toFixed(2)}x` + ); +} diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..d7d34809 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -1096,3 +1096,25 @@ export async function searchIndexedFiles( return merged; } + +export const __fileSearchIndexPerfHarness = { + async rebuild(options?: { homeDir?: string; includeProtectedHomeRoots?: boolean }): Promise { + configuredHomeDir = resolveHomeDir(options?.homeDir); + includeProtectedHomeRoots = Boolean(options?.includeProtectedHomeRoots); + includeRoots = resolveIncludeRoots(configuredHomeDir); + lastBuildStartedAt = 0; + await rebuildFileSearchIndex('perf-harness'); + }, + applyWatchEventBatch, + reset(): void { + stopFileSearchIndexing(); + activeIndex = null; + rebuildPromise = null; + indexing = false; + configuredHomeDir = ''; + includeRoots = []; + lastIndexError = null; + lastBuildStartedAt = 0; + includeProtectedHomeRoots = false; + }, +}; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cd718edc..dc1f1ec8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1078,7 +1078,7 @@ const App: React.FC = () => { const pinToggleForCommand = useCallback( async (command: CommandInfo) => { - console.log('[PIN-TOGGLE] called for command:', command?.id, command?.name); + console.log('[PIN-TOGGLE] called for command:', command?.id, command?.title); const currentPinned = pinnedCommandsRef.current; const exists = currentPinned.includes(command.id); console.log('[PIN-TOGGLE] currentPinned:', currentPinned, 'exists:', exists); diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..02d43a3b 100644 --- a/src/renderer/src/CameraExtension.tsx +++ b/src/renderer/src/CameraExtension.tsx @@ -298,7 +298,7 @@ const CameraExtension: React.FC = ({ onClose }) => { }, 5000); capturePreviewClearTimerRef.current = window.setTimeout(() => { setCapturePreviewDataUrl(null); - setCapturePreviewClearTimerRef.current = null; + capturePreviewClearTimerRef.current = null; }, 5300); setFlashVisible(true); if (flashTimerRef.current != null) { diff --git a/src/renderer/src/QuickLinkManager.tsx b/src/renderer/src/QuickLinkManager.tsx index 2f749757..deaea4c2 100644 --- a/src/renderer/src/QuickLinkManager.tsx +++ b/src/renderer/src/QuickLinkManager.tsx @@ -1286,7 +1286,7 @@ const QuickLinkManager: React.FC = ({ onClose, initialVie const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineDynamicInputRefs = useRef>([]); + const inlineDynamicInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index 3e2e8c65..e5ef51dd 100644 --- a/src/renderer/src/SnippetManager.tsx +++ b/src/renderer/src/SnippetManager.tsx @@ -432,7 +432,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineArgumentInputRefs = useRef>([]); + const inlineArgumentInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..bce0b4a5 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -36,7 +36,7 @@ export interface UseAiChatReturn { setAiQuery: (value: string) => void; aiInputRef: React.RefObject; aiResponseRef: React.RefObject; - setAiAvailable: (value: boolean) => void; + setAiAvailable: React.Dispatch>; conversations: AiConversation[]; activeConversationId: string | null; startAiChat: (searchQuery: string) => void; diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index e7f3bf2a..6dd903b9 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -1572,3 +1572,10 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + filterBrowserResults, + getOrderedBrowserResults, + getRankedBrowserResults, +}; diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..b71011ae 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -9,7 +9,11 @@ import type { import type { BrowserSearchResult, useBrowserSearch } from './useBrowserSearch'; import type { CalcResult } from '../smart-calculator'; import { tryCalculate, tryCalculateAsync } from '../smart-calculator'; -import { filterCommands, rankCommands } from '../utils/command-helpers'; +import { + createRootCommandScoreIndex, + filterCommands, + rankCommandsWithIndex, +} from '../utils/command-helpers'; import { asTildePath, buildFileResultCommandId, @@ -187,7 +191,7 @@ function getFileDepthPenalty(result: IndexedFileSearchResult): number { type BrowserLauncherProfile = { id?: string; - browserId?: BrowserSearchSource | string; + browserId?: BrowserSearchSource; displayName: string; detectedName?: string; profileId: string; @@ -371,6 +375,17 @@ export function useLauncherCommandModel({ }), [sourceCommands, hiddenListOnlyCommandIds, hasSearchQuery, t, webSearchDefaultBangKey, effectiveSearchBangs] ); + const rootSearchableCommands = useMemo( + () => contextualCommands.filter((cmd) => + cmd.id !== WEB_SEARCH_COMMAND_ID && + (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) + ), + [contextualCommands, hiddenListOnlyCommandIds, hasSearchQuery] + ); + const rootCommandScoreIndex = useMemo( + () => createRootCommandScoreIndex(rootSearchableCommands, commandAliases), + [rootSearchableCommands, commandAliases] + ); const fileResultCommands = useMemo( () => @@ -522,20 +537,8 @@ export function useLauncherCommandModel({ const commandCandidates = useMemo(() => { if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - const searchableCommands = contextualCommands.filter((cmd) => - cmd.id !== WEB_SEARCH_COMMAND_ID && - (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) - ); - return rankCommands(searchableCommands, searchQuery, commandAliases) - .map(({ command }) => { - const alias = commandAliases[command.id] || ''; - const scored = scoreRootSearchFields(searchQuery, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), - ]); - if (!scored.matched) return null; + return rankCommandsWithIndex(rootCommandScoreIndex, searchQuery) + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; return scoreRootSearchCandidate({ @@ -551,8 +554,8 @@ export function useLauncherCommandModel({ label: command.title, description: command.subtitle, pathOrUrl: command.path, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + matchKind, + matchScore, sourceQualityBoost: command.alwaysOnTop ? 80 : 0, freshnessBoost: 0, pathLocationBoost: 0, @@ -561,7 +564,7 @@ export function useLauncherCommandModel({ }, searchQuery, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, contextualCommands, hiddenListOnlyCommandIds, searchQuery, commandAliases, rootSearchRanking]); + }, [hasSearchQuery, aiMode, rootBangState, rootCommandScoreIndex, searchQuery, rootSearchRanking]); const fileCandidates = useMemo(() => { if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; diff --git a/src/renderer/src/hooks/useLauncherKeyboardControls.ts b/src/renderer/src/hooks/useLauncherKeyboardControls.ts index a385562f..e965d9f1 100644 --- a/src/renderer/src/hooks/useLauncherKeyboardControls.ts +++ b/src/renderer/src/hooks/useLauncherKeyboardControls.ts @@ -91,6 +91,7 @@ export type UseLauncherKeyboardControlsOptions = { kind?: CommandInfo['browserResultKind']; url?: string; sourceProfileId?: string; + openInSourceProfile?: boolean; windowId?: string | number; tabId?: string | number; } diff --git a/src/renderer/src/i18n/locales/it.json b/src/renderer/src/i18n/locales/it.json index 75ae34be..82538b88 100644 --- a/src/renderer/src/i18n/locales/it.json +++ b/src/renderer/src/i18n/locales/it.json @@ -375,6 +375,12 @@ "elevenlabsWarning": "STT di ElevenLabs selezionato. {action} la chiave API di ElevenLabs in Chiavi API.", "elevenlabsReady": "STT di ElevenLabs selezionato. La trascrizione cloud userà la tua chiave ElevenLabs.", "recognitionLanguage": "Lingua di riconoscimento", + "vocabulary": { + "label": "Vocabolario personalizzato", + "placeholder": "Kotlin, Electron, Raycast, SuperCmd…", + "hint": "Facoltativo. Elenca parole poco comuni, nomi o termini tecnici (separati da virgole o da nuove righe) per orientare il riconoscimento verso l'ortografia corretta.", + "tokenWarning": "Circa {count} token. Whisper usa all'incirca solo i primi 224: le parole oltre quel limite vengono ignorate." + }, "providerInfo": { "parakeet": "Trascrizione offline sul dispositivo con Parakeet TDT v3. Viene eseguita su Apple Neural Engine. Richiede un riscaldamento del modello al primo utilizzo. Scarica il modello qui sotto prima di usare la dettatura.", "qwen3": "Trascrizione offline sul dispositivo con Qwen3 ASR. Supporta oltre 30 lingue, richiede macOS 15+ e si riscalda al primo utilizzo.", @@ -611,6 +617,18 @@ "openedExisting": "Cartella degli script personalizzati aperta.", "failed": "Impossibile aprire la cartella degli script personalizzati." }, + "appSearchScope": { + "title": "Ambito di ricerca delle applicazioni", + "description": "Cartelle in cui SuperCmd cerca le applicazioni installate da mostrare nel launcher.", + "add": "Aggiungi cartella", + "remove": "Rimuovi", + "empty": "Nessuna cartella aggiunta. Verranno usate le cartelle applicazioni di sistema predefinite.", + "added": "Cartella aggiunta all'ambito di ricerca.", + "removed": "Cartella rimossa dall'ambito di ricerca.", + "duplicate": "Cartella già presente nell'elenco.", + "failed": "Impossibile aggiornare l'ambito di ricerca.", + "restartHint": "Riavvia SuperCmd affinché le modifiche alle cartelle abbiano effetto." + }, "emojiPicker": { "enabled": "Abilita il selettore emoji", "enabledDesc": "Mostra suggerimenti emoji quando si digita un prefisso trigger.", diff --git a/src/renderer/src/i18n/runtime.ts b/src/renderer/src/i18n/runtime.ts index edf181c9..e64e92dd 100644 --- a/src/renderer/src/i18n/runtime.ts +++ b/src/renderer/src/i18n/runtime.ts @@ -12,7 +12,9 @@ import itMessages from './locales/it.json'; export type SupportedAppLocale = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'fr' | 'de' | 'es' | 'ru' | 'it'; export type AppLanguageSetting = 'system' | SupportedAppLocale; export type TranslationValues = Record; -type MessageTree = Record; +interface MessageTree { + [key: string]: string | MessageTree; +} export const DEFAULT_APP_LANGUAGE: AppLanguageSetting = 'system'; export const FALLBACK_APP_LOCALE: SupportedAppLocale = 'en'; diff --git a/src/renderer/src/raycast-api/action-runtime-overlay.tsx b/src/renderer/src/raycast-api/action-runtime-overlay.tsx index 02896d10..e623136c 100644 --- a/src/renderer/src/raycast-api/action-runtime-overlay.tsx +++ b/src/renderer/src/raycast-api/action-runtime-overlay.tsx @@ -112,7 +112,8 @@ export function createActionOverlayRuntime(deps: OverlayDeps) { } if (source && typeof source === 'object') { - const variants = [source.light, source.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + const sourceVariants = source as Record; + const variants = [sourceVariants.light, sourceVariants.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); if (variants.length > 0) { const assetLikeVariants = variants.filter((value) => hasImageExtension(value)); if (assetLikeVariants.length === 0) return true; diff --git a/src/renderer/src/raycast-api/context-scope-runtime.ts b/src/renderer/src/raycast-api/context-scope-runtime.ts index 440b0bc1..410737cc 100644 --- a/src/renderer/src/raycast-api/context-scope-runtime.ts +++ b/src/renderer/src/raycast-api/context-scope-runtime.ts @@ -89,7 +89,7 @@ export function withExtensionContext(ctx: ExtensionContextSnapshot | undefine try { const value = fn(); if (value && typeof (value as any).then === 'function') { - return (value as Promise).finally(restore) as T; + return (value as unknown as Promise).finally(restore) as T; } restore(); return value; diff --git a/src/renderer/src/raycast-api/detail-runtime.tsx b/src/renderer/src/raycast-api/detail-runtime.tsx index 88d513ff..0566bb26 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -90,7 +90,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { for (const node of allChildren) { if (React.isValidElement(node)) { - const typeRecord = node.type as Record | null; + const typeRecord = node.type as unknown as Record | null; if (typeRecord?.[DETAIL_METADATA_RUNTIME_MARKER] === true) { metadataNodes.push(node); continue; @@ -290,7 +290,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { ); const MetadataComponent = ({ children }: { children?: React.ReactNode }) =>
{children}
; - (MetadataComponent as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; + (MetadataComponent as unknown as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; MetadataComponent.displayName = 'Detail.Metadata'; const Metadata = Object.assign( diff --git a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts index 45b06294..bec30deb 100644 --- a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts @@ -129,7 +129,7 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev: any) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; @@ -156,7 +156,7 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev: any) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..c7dcf1ab 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import * as Phosphor from '../../../../node_modules/@phosphor-icons/react/dist/index.es.js'; +import * as Phosphor from '@phosphor-icons/react'; import { RAYCAST_ICON_NAMES, RAYCAST_ICON_VALUE_TO_NAME, type RaycastIconName } from './raycast-icon-enum'; type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index e9d66b13..24da47c9 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -434,8 +434,10 @@ export enum ToastStyle { Failure = 'failure', } +type ToastStyleInput = ToastStyle | 'animated' | 'success' | 'failure'; + export class Toast { - static Style = ToastStyle; + static Style: typeof ToastStyle = ToastStyle; private static _activeToast: Toast | null = null; private _title = ''; @@ -483,7 +485,7 @@ export class Toast { return this._style; } - public set style(value: ToastStyle | Toast.Style | string) { + public set style(value: ToastStyleInput | Toast.Style | string) { this._style = this.normalizeStyle(value); this.refresh(); } @@ -506,7 +508,7 @@ export class Toast { this.refresh(); } - private normalizeStyle(value: ToastStyle | Toast.Style | string | undefined): ToastStyle { + private normalizeStyle(value: ToastStyleInput | Toast.Style | string | undefined): ToastStyle { if (value === ToastStyle.Animated || value === Toast.Style.Animated || value === 'animated') { return ToastStyle.Animated; } @@ -894,16 +896,12 @@ export class Toast { // Toast namespace for types (merged with class) export namespace Toast { - export enum Style { - Animated = 'animated', - Success = 'success', - Failure = 'failure', - } + export type Style = ToastStyle; export interface Options { title: string; message?: string; - style?: ToastStyle | Toast.Style; + style?: ToastStyleInput | Toast.Style; primaryAction?: Alert.ActionOptions; secondaryAction?: Alert.ActionOptions; } @@ -924,9 +922,9 @@ function shouldSuppressBenignGitMissingPathToast(options: Toast.Options): boolea } export async function showToast(options: Toast.Options): Promise; -export async function showToast(style: ToastStyle | Toast.Style, title: string, message?: string): Promise; +export async function showToast(style: ToastStyleInput | Toast.Style, title: string, message?: string): Promise; export async function showToast( - optionsOrStyle: Toast.Options | ToastStyle | Toast.Style, + optionsOrStyle: Toast.Options | ToastStyleInput | Toast.Style, title?: string, message?: string ): Promise { diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..cd14b3b3 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -19,7 +19,7 @@ interface ListRendererDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (tintColor?: string) => string | undefined; resolveReadableTintColor: (tintColor?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; } export function createListRenderers(deps: ListRendererDeps) { @@ -102,7 +102,7 @@ export function createListRenderers(deps: ListRendererDeps) { {icon &&
{renderIcon(icon, iconClassName, assetsPath)}
}
{primaryText}
{secondaryText && {secondaryText}} - {accessories?.map((accessory, index) => { + {accessories?.map((accessory: NonNullable[number], index: number) => { const accessoryText = typeof accessory?.text === 'string' ? accessory.text : typeof accessory?.text === 'object' ? accessory.text?.value || '' : ''; const accessoryTextColorRaw = typeof accessory?.text === 'object' ? accessory.text?.color : undefined; const tagText = typeof accessory?.tag === 'string' ? accessory.tag : typeof accessory?.tag === 'object' ? accessory.tag?.value || '' : ''; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..c951e63c 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -34,7 +34,7 @@ interface ListRuntimeDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (value?: string) => string | undefined; resolveReadableTintColor: (value?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; getExtensionContext: () => { assetsPath: string; extensionDisplayName?: string; @@ -390,7 +390,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const children = React.Children.toArray((rawDetail.props as { children?: React.ReactNode }).children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; diff --git a/src/renderer/src/raycast-api/oauth/oauth-service-core.ts b/src/renderer/src/raycast-api/oauth/oauth-service-core.ts index 0a923781..7fb945ae 100644 --- a/src/renderer/src/raycast-api/oauth/oauth-service-core.ts +++ b/src/renderer/src/raycast-api/oauth/oauth-service-core.ts @@ -25,6 +25,10 @@ export class OAuthServiceCore { return (override && override.trim()) || this.options.clientId; } + getPersonalAccessToken(): string | undefined { + return this.options.personalAccessToken; + } + setClientIdOverride(value: string): void { const key = oauthClientIdOverrideKey(this.getProviderKey()); const trimmed = (value || '').trim(); diff --git a/src/renderer/src/raycast-api/oauth/with-access-token.tsx b/src/renderer/src/raycast-api/oauth/with-access-token.tsx index 83b356d8..e2440719 100644 --- a/src/renderer/src/raycast-api/oauth/with-access-token.tsx +++ b/src/renderer/src/raycast-api/oauth/with-access-token.tsx @@ -16,10 +16,11 @@ export function withAccessToken(options: any) { const shouldInvokeOnAuthorize = !(options instanceof OAuthService); const authorizeForNoView = async (): Promise => { if (options instanceof OAuthService) { - if (options?.personalAccessToken) { - accessTokenValue = options.personalAccessToken; + const personalAccessToken = options.getPersonalAccessToken(); + if (personalAccessToken) { + accessTokenValue = personalAccessToken; accessTokenType = 'personal'; - await Promise.resolve(options.onAuthorize?.({ token: accessTokenValue, type: 'personal' })); + await Promise.resolve(options.onAuthorize?.({ token: personalAccessToken, type: 'personal' })); return; } diff --git a/src/renderer/src/settings/AITab.tsx b/src/renderer/src/settings/AITab.tsx index c27befdd..6813afd9 100644 --- a/src/renderer/src/settings/AITab.tsx +++ b/src/renderer/src/settings/AITab.tsx @@ -1224,7 +1224,7 @@ const AITab: React.FC = () => {

{t('settings.ai.llm.ollama.models')}

{ollamaRunning && (