From 9e37fa1aa84d23d4900ea8e24ebbc7939bb38195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Tue, 14 Apr 2026 08:15:48 +0000 Subject: [PATCH] =?UTF-8?q?test(e2e):=20T1=20init=20=E2=86=92=20tick=20?= =?UTF-8?q?=E2=86=92=20daemon=20status=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add E2E test helper: createE2EContext, cleanupE2EContext, runUpulse, queryEventsDb, queryVitalsDb, listObjects - Add T1 test suite (12 tests): init structure, git repo, staging worktree, node_modules, idempotent re-init, tick --dry-run, tick with DB writes, objects creation, collect events, daemon status - Fix tick command: collectors/ → executors/ path (matches init scaffold) - Add test:e2e script to package.json ref #14 --- packages/upulse/package.json | 3 +- packages/upulse/src/commands/tick.ts | 2 +- packages/upulse/src/e2e/helper.ts | 144 +++++++++++++++++ .../src/e2e/t1-init-daemon-tick.test.ts | 153 ++++++++++++++++++ 4 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 packages/upulse/src/e2e/helper.ts create mode 100644 packages/upulse/src/e2e/t1-init-daemon-tick.test.ts diff --git a/packages/upulse/package.json b/packages/upulse/package.json index 8cdf4f7..9089d0e 100644 --- a/packages/upulse/package.json +++ b/packages/upulse/package.json @@ -12,7 +12,8 @@ ], "scripts": { "build": "tsc", - "dev": "tsx src/cli.ts" + "dev": "tsx src/cli.ts", + "test:e2e": "npx tsx --test src/e2e/t1-init-daemon-tick.test.ts" }, "dependencies": { "@uncaged/pulse": "file:../pulse", diff --git a/packages/upulse/src/commands/tick.ts b/packages/upulse/src/commands/tick.ts index 37b012a..4cc81e3 100644 --- a/packages/upulse/src/commands/tick.ts +++ b/packages/upulse/src/commands/tick.ts @@ -54,7 +54,7 @@ export function registerTickCommand(program: Command): void { } // Import collector - const collectSystemPath = pathToFileURL(join(distDir, 'collectors', 'system.js')).href; + const collectSystemPath = pathToFileURL(join(distDir, 'executors', 'system.js')).href; const collectorModule = await import(collectSystemPath); const collectSystem = collectorModule.collectSystem ?? collectorModule.collect; diff --git a/packages/upulse/src/e2e/helper.ts b/packages/upulse/src/e2e/helper.ts new file mode 100644 index 0000000..5f8bd29 --- /dev/null +++ b/packages/upulse/src/e2e/helper.ts @@ -0,0 +1,144 @@ +/** + * E2E Test Helper — shared utilities for upulse E2E tests. + * + * Provides isolated temp directories, CLI runner, and DB query helpers. + */ + +import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Path to the upulse CLI entrypoint (src/cli.ts). */ +const CLI_PATH = join(__dirname, '..', 'cli.ts'); + +export interface E2EContext { + /** Temp directory used as HOME for the test. */ + baseDir: string; + /** Path to .upulse inside baseDir. */ + upulseDir: string; + /** Path to events.db. */ + eventsDbPath: string; + /** Path to vitals.db. */ + vitalsDbPath: string; + /** Path to objects/. */ + objectsDir: string; + /** Path to engine/. */ + engineDir: string; + /** Path to staging/. */ + stagingDir: string; +} + +/** + * Create an isolated E2E test environment with a fresh temp directory. + */ +export function createE2EContext(): E2EContext { + const baseDir = mkdtempSync(join(tmpdir(), 'pulse-e2e-')); + const upulseDir = join(baseDir, '.upulse'); + return { + baseDir, + upulseDir, + eventsDbPath: join(upulseDir, 'events.db'), + vitalsDbPath: join(upulseDir, 'vitals.db'), + objectsDir: join(upulseDir, 'objects'), + engineDir: join(upulseDir, 'engine'), + stagingDir: join(upulseDir, 'staging'), + }; +} + +/** + * Clean up an E2E test environment (remove temp directory). + */ +export function cleanupE2EContext(ctx: E2EContext): void { + try { + rmSync(ctx.baseDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup; CI temp dirs are ephemeral anyway + } +} + +export interface RunOptions { + /** If true, expect the command to exit non-zero. Returns stderr. */ + expectFail?: boolean; + /** Timeout in ms (default: 120_000 for npm install during init). */ + timeout?: number; +} + +/** + * Run an upulse CLI command in the E2E context. + * + * - Sets HOME so `homedir()` → ctx.baseDir → config lands in ctx.baseDir/.upulse/ + * - Sets PULSE_BASE_DIR so engine's pulse.config.ts uses the right paths. + * - Uses `npx tsx` to run the TypeScript CLI directly. + */ +export function runUpulse(ctx: E2EContext, args: string, options?: RunOptions): string { + const timeout = options?.timeout ?? 120_000; + const env: Record = { + ...process.env as Record, + HOME: ctx.baseDir, + PULSE_BASE_DIR: ctx.upulseDir, + }; + + try { + const result = execSync(`npx tsx ${CLI_PATH} ${args}`, { + env, + cwd: ctx.baseDir, + timeout, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (options?.expectFail) { + throw new Error(`Expected command to fail but it succeeded: upulse ${args}\nstdout: ${result}`); + } + return result; + } catch (err: unknown) { + if (options?.expectFail) { + const e = err as { stderr?: string; stdout?: string }; + return e.stderr || e.stdout || ''; + } + const e = err as { stdout?: string; stderr?: string; message?: string }; + throw new Error( + `upulse ${args} failed:\nstdout: ${e.stdout ?? ''}\nstderr: ${e.stderr ?? ''}\nmessage: ${e.message ?? ''}`, + ); + } +} + +/** + * Open events.db and run a query. Returns empty array if DB does not exist. + */ +export function queryEventsDb(ctx: E2EContext, sql: string): unknown[] { + if (!existsSync(ctx.eventsDbPath)) return []; + const db = new Database(ctx.eventsDbPath); + try { + return db.prepare(sql).all(); + } finally { + db.close(); + } +} + +/** + * Open vitals.db and run a query. Returns empty array if DB does not exist. + */ +export function queryVitalsDb(ctx: E2EContext, sql: string): unknown[] { + if (!existsSync(ctx.vitalsDbPath)) return []; + const db = new Database(ctx.vitalsDbPath); + try { + return db.prepare(sql).all(); + } finally { + db.close(); + } +} + +/** + * List .json files in the objects/ directory. + */ +export function listObjects(ctx: E2EContext): string[] { + if (!existsSync(ctx.objectsDir)) return []; + return readdirSync(ctx.objectsDir).filter(f => f.endsWith('.json')); +} diff --git a/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts b/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts new file mode 100644 index 0000000..ab597e4 --- /dev/null +++ b/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts @@ -0,0 +1,153 @@ +/** + * E2E T1 — Init → Daemon → Tick 完整链路测试 + * + * Validates the full lifecycle: + * upulse init → creates directories, git repo, npm install + * upulse tick → compiles engine, runs one tick, writes to DB + * upulse daemon status → reports daemon state + * + * Run: npx tsx --test src/e2e/t1-init-daemon-tick.test.ts + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { + createE2EContext, + cleanupE2EContext, + runUpulse, + queryEventsDb, + queryVitalsDb, + listObjects, + type E2EContext, +} from './helper.js'; + +describe('E2E T1: Init → Daemon → Tick', () => { + let ctx: E2EContext; + + // Use a single context for all tests in this suite because + // `upulse init` runs npm install which is slow (~15-30s). + // Tests are ordered: init → tick → daemon. + before(() => { + ctx = createE2EContext(); + }); + + after(() => { + cleanupE2EContext(ctx); + }); + + // ── Init ───────────────────────────────────────────────────── + + it('upulse init creates .upulse directory', () => { + const output = runUpulse(ctx, 'init'); + assert.ok(output.includes('initialized'), `init output should mention initialized, got:\n${output}`); + assert.ok(existsSync(ctx.upulseDir), '.upulse/ should exist'); + }); + + it('init creates config.json', () => { + const cfgPath = join(ctx.upulseDir, 'config.json'); + assert.ok(existsSync(cfgPath), 'config.json should exist'); + const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); + assert.equal(cfg.dir, ctx.upulseDir, 'config.dir should match upulse dir'); + }); + + it('init creates complete engine directory structure', () => { + assert.ok(existsSync(ctx.engineDir), 'engine/ should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'rules')), 'engine/rules/ should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'executors')), 'engine/executors/ should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'AGENTS.md')), 'engine/AGENTS.md should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'types.ts')), 'engine/types.ts should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'pulse.config.ts')), 'engine/pulse.config.ts should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'tsconfig.json')), 'engine/tsconfig.json should exist'); + assert.ok(existsSync(join(ctx.engineDir, '.gitignore')), 'engine/.gitignore should exist'); + assert.ok(existsSync(join(ctx.engineDir, 'package.json')), 'engine/package.json should exist'); + }); + + it('init creates git repo with initial commit', () => { + assert.ok(existsSync(join(ctx.engineDir, '.git')), 'engine/ should be a git repo'); + + // Verify there is at least one commit + const log = execSync('git log --oneline', { + cwd: ctx.engineDir, + encoding: 'utf-8', + }).trim(); + assert.ok(log.length > 0, 'git log should have at least one commit'); + assert.ok(log.includes('init'), 'initial commit should mention "init"'); + }); + + it('init creates staging worktree', () => { + assert.ok(existsSync(ctx.stagingDir), 'staging/ should exist'); + }); + + it('init installs node_modules in engine', () => { + assert.ok( + existsSync(join(ctx.engineDir, 'node_modules')), + 'engine/node_modules/ should exist after init', + ); + assert.ok( + existsSync(join(ctx.engineDir, 'node_modules', '@uncaged', 'pulse')), + '@uncaged/pulse should be installed in engine', + ); + }); + + it('init is idempotent — running again fails gracefully', () => { + const output = runUpulse(ctx, 'init', { expectFail: true }); + assert.ok( + output.includes('already initialized') || output.includes('config.json'), + `re-init should warn about existing config, got:\n${output}`, + ); + }); + + // ── Tick ───────────────────────────────────────────────────── + + it('upulse tick --dry-run succeeds', () => { + const output = runUpulse(ctx, 'tick --dry-run'); + assert.ok(output.includes('dry-run'), `tick --dry-run output should mention dry-run, got:\n${output}`); + }); + + it('upulse tick runs and writes events to DB', () => { + const output = runUpulse(ctx, 'tick'); + + // tick should produce snapshot output + assert.ok(output.includes('Snapshot') || output.includes('Result'), `tick output should show snapshot/result, got:\n${output}`); + + // Verify events.db has entries + assert.ok(existsSync(ctx.eventsDbPath), 'events.db should exist after tick'); + const events = queryEventsDb(ctx, 'SELECT * FROM events'); + assert.ok(events.length > 0, `events.db should have entries after tick, found ${events.length}`); + }); + + it('tick creates objects', () => { + // After tick, objects/ should have at least one snapshot file + const objects = listObjects(ctx); + assert.ok(objects.length > 0, `objects/ should have files after tick, found ${objects.length}`); + }); + + it('tick records collect events', () => { + const collectEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'collect'") as Array<{ + kind: string; + key: string; + hash: string; + }>; + assert.ok(collectEvents.length > 0, 'should have collect events'); + // Should have collected 'system' sense + const systemCollects = collectEvents.filter(e => e.key === 'system'); + assert.ok(systemCollects.length > 0, 'should have system collect events'); + // Each collect event should have a hash pointing to an object + for (const evt of systemCollects) { + assert.ok(evt.hash, 'collect event should have a hash'); + } + }); + + // ── Daemon Status ──────────────────────────────────────────── + + it('upulse daemon status shows STOPPED when daemon is not running', () => { + const output = runUpulse(ctx, 'daemon status'); + assert.ok( + output.includes('STOPPED'), + `daemon status should show STOPPED, got:\n${output}`, + ); + }); +});