#!/usr/bin/env node /** * Memind scoped test runner. * Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF] */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; const root = path.resolve(new URL('..', import.meta.url).pathname); const MODES = new Set(['quick', 'changed', 'guards', 'release', 'full']); const QUICK_TESTS = [ 'capabilities.test.mjs', 'llm-providers.test.mjs', 'user-auth.test.mjs', 'wechat-mp.test.mjs', ]; const GUARD_SCRIPTS = [ 'verify:mindspace-publish-guards', 'verify:mindspace-page-sync-guards', ]; const RELEASE_TESTS = [ ...QUICK_TESTS, 'wechat-oauth.test.mjs', 'wechat-pay.test.mjs', 'mindspace-public-finish-sync.test.mjs', 'chat-finish-sync.test.mjs', 'conversation-display.test.mjs', 'mindspace-pages.test.mjs', 'mindspace-page-sync-service.test.mjs', ]; const RELEASE_CHECKS = [ 'check:mindspace-public-links', 'verify:mindspace-publish-guards:full', ]; const SCOPE_RULES = [ { id: 'publish-chat-finish', patterns: [ /mindspace-public-finish/i, /chat-finish-sync/i, /conversation-display/i, /useTKMindChat/i, /message\.ts$/, /mindspace-h5-html-finish/i, ], tests: [ 'mindspace-public-finish-sync.test.mjs', 'mindspace-h5-html-finish-guard.test.mjs', 'chat-finish-sync.test.mjs', 'conversation-display.test.mjs', ], verify: ['verify:mindspace-publish-guards'], }, { id: 'page-sync-thumbnail', patterns: [ /mindspace-page-sync/i, /mindspace-pages/i, /public-site-bases/i, /publicUrl\.ts$/, /mindspace-thumbnails/i, /mindspace-workspace-thumbnails/i, ], tests: [ 'mindspace-pages.test.mjs', 'mindspace-page-sync-service.test.mjs', 'public-site-bases.test.mjs', 'mindspace-thumbnails.test.mjs', 'mindspace-workspace-thumbnails.test.mjs', ], verify: ['verify:mindspace-page-sync-guards'], }, { id: 'wechat', patterns: [/wechat/i], tests: [ 'wechat-mp.test.mjs', 'wechat-oauth.test.mjs', 'wechat-pay.test.mjs', 'user-auth.test.mjs', ], verify: ['verify:wechat-channel-isolation'], }, { id: 'billing', patterns: [/billing/i, /sse-billing/i], tests: [ 'billing.test.mjs', 'billing-recharge.test.mjs', 'billing-subscription.test.mjs', 'sse-billing.test.mjs', ], }, { id: 'agent-run', patterns: [/agent-run/i, /tool-gateway/i, /goosed-proxy/i], tests: [ 'agent-run-gateway.test.mjs', 'agent-run-routes.test.mjs', 'agent-run-stream.test.mjs', 'tool-gateway.test.mjs', 'goosed-proxy-boundary.test.mjs', 'chat-agent-run-gate.test.mjs', ], verify: ['verify:goosed-proxy-boundary'], }, { id: 'memory-v2', patterns: [/memory-v2/i, /scripts\/check-memory-v2/i, /scripts\/smoke-memory-v2/i], tests: [ 'memory-v2.test.mjs', 'memory-v2-runtime.test.mjs', 'memory-v2-health.test.mjs', 'memory-v2-backend-contract.test.mjs', ], }, { id: 'plaza', patterns: [/plaza/i], tests: [ 'plaza-posts.test.mjs', 'plaza-interactions.test.mjs', 'plaza-algorithm.test.mjs', 'plaza-seo.test.mjs', 'plaza-ops.test.mjs', ], }, { id: 'page-data', patterns: [/page-data/i, /page-access-policy/i, /user-data-space-service/i, /mindspace-sandbox-mcp/i], tests: [ 'user-data-space-service.test.mjs', 'page-access-policy.test.mjs', 'page-data-routes.test.mjs', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs', 'page-data-acceptance.test.mjs', 'page-data-log-store.test.mjs', 'page-access-visitor.test.mjs', 'page-data-ops.test.mjs', 'page-data-session-store.test.mjs', 'page-data-browser-client.test.mjs', 'page-data-policy-index.test.mjs', 'page-data-captcha.test.mjs', 'mindspace-sandbox-mcp.test.mjs', ], }, { id: 'mindspace-core', patterns: [/mindspace/i, /server\.mjs$/, /src\/hooks\/useTKMind/i], tests: [ 'mindspace.test.mjs', 'mindspace-public-route.test.mjs', 'mindspace-public-links.test.mjs', 'mindspace-runtime-config.test.mjs', ], }, ]; function usage() { console.log(`Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF] Modes: changed Run tests matched to git diff (default) quick Fast smoke for routine checks guards Regression guard scripts only release Pre-portal-release checklist full Entire npm test suite`); } function parseArgs(argv) { let mode = 'changed'; let base = 'HEAD'; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--mode') { mode = argv[++i] ?? ''; } else if (arg === '--base') { base = argv[++i] ?? 'HEAD'; } else if (arg === '-h' || arg === '--help') { usage(); process.exit(0); } else { throw new Error(`Unknown argument: ${arg}`); } } if (!MODES.has(mode)) { throw new Error(`Invalid mode: ${mode}`); } return { mode, base }; } function runStep(label, fn) { console.log(`\n==> ${label}`); const ok = fn(); if (!ok) { throw new Error(`${label} failed`); } } function runNodeTest(files) { const existing = files.filter((file) => fs.existsSync(path.join(root, file))); if (existing.length === 0) { console.log('(skip: no matching test files on disk)'); return true; } const result = spawnSync(process.execPath, ['--test', ...existing], { cwd: root, stdio: 'inherit', }); return result.status === 0; } function runNpmScript(script) { const result = spawnSync('npm', ['run', script], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32', }); return result.status === 0; } function gitChangedFiles(base) { const result = spawnSync('git', ['diff', '--name-only', `${base}...HEAD`], { cwd: root, encoding: 'utf8', }); if (result.status !== 0) { const fallback = spawnSync('git', ['diff', '--name-only', base], { cwd: root, encoding: 'utf8', }); if (fallback.status !== 0) { return []; } return fallback.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean); } return result.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean); } function gitStagedAndUnstaged() { const result = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', }); if (result.status !== 0) { return []; } return result.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) .map((line) => line.replace(/^. [^ ]+ -> /, '').replace(/^.. /, '')); } function directTestForSource(file) { const base = path.basename(file).replace(/\.(mjs|ts|tsx|js)$/, ''); const candidates = [ `${base}.test.mjs`, path.join('scripts', `${base}.test.mjs`), path.join('mindspace-service', `${base}.test.mjs`), ]; return candidates.filter((candidate) => fs.existsSync(path.join(root, candidate))); } function resolveChangedScope(files) { const tests = new Set(QUICK_TESTS); const verify = new Set(); for (const file of files) { for (const test of directTestForSource(file)) { tests.add(test); } for (const rule of SCOPE_RULES) { if (rule.patterns.some((pattern) => pattern.test(file))) { for (const test of rule.tests) { tests.add(test); } for (const script of rule.verify ?? []) { verify.add(script); } } } } return { files, tests: [...tests], verify: [...verify], }; } function summarize(scope) { console.log('\n--- planned scope ---'); if (scope.files?.length) { console.log(`changed files (${scope.files.length}):`); for (const file of scope.files) { console.log(` - ${file}`); } } console.log(`tests (${scope.tests.length}):`); for (const test of scope.tests) { console.log(` - ${test}`); } if (scope.verify.length) { console.log(`verify scripts (${scope.verify.length}):`); for (const script of scope.verify) { console.log(` - npm run ${script}`); } } } function main() { const { mode, base } = parseArgs(process.argv); let scope; switch (mode) { case 'quick': scope = { tests: QUICK_TESTS, verify: [] }; break; case 'guards': scope = { tests: [], verify: GUARD_SCRIPTS }; break; case 'release': scope = { tests: RELEASE_TESTS, verify: RELEASE_CHECKS }; break; case 'full': runStep('full test suite (npm test)', () => runNpmScript('test')); console.log('\nmemind tests ok (full)'); return; case 'changed': default: { const changed = [...new Set([...gitChangedFiles(base), ...gitStagedAndUnstaged()])]; if (changed.length === 0) { console.log('No changed files detected; falling back to quick smoke.'); scope = { tests: QUICK_TESTS, verify: [], files: [] }; } else { scope = resolveChangedScope(changed); } break; } } summarize(scope); for (const test of scope.tests) { runStep(`node --test ${test}`, () => runNodeTest([test])); } for (const script of scope.verify) { runStep(`npm run ${script}`, () => runNpmScript(script)); } console.log(`\nmemind tests ok (${mode})`); } try { main(); } catch (error) { console.error(`\n${error.message}`); process.exit(1); }