hamster-wheel-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
package/src/webhook.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { Logger } from './logger';
2
+ import { isoNow } from './utils';
3
+ import type { WebhookConfig } from './types';
4
+
5
+ export type WebhookEvent = 'task_start' | 'iteration_start' | 'task_end';
6
+
7
+ export interface WebhookPayload {
8
+ readonly event: WebhookEvent;
9
+ readonly task: string;
10
+ readonly branch: string;
11
+ readonly iteration: number;
12
+ readonly stage: string;
13
+ readonly timestamp: string;
14
+ }
15
+
16
+ export interface WebhookPayloadInput {
17
+ readonly event: WebhookEvent;
18
+ readonly task: string;
19
+ readonly branch?: string;
20
+ readonly iteration: number;
21
+ readonly stage: string;
22
+ readonly timestamp?: string;
23
+ }
24
+
25
+ export type FetchLikeResponse = {
26
+ readonly ok: boolean;
27
+ readonly status: number;
28
+ readonly statusText: string;
29
+ };
30
+
31
+ export type FetchLike = (url: string, init: {
32
+ readonly method: string;
33
+ readonly headers: Record<string, string>;
34
+ readonly body: string;
35
+ readonly signal?: AbortSignal;
36
+ }) => Promise<FetchLikeResponse>;
37
+
38
+ const DEFAULT_TIMEOUT_MS = 8000;
39
+
40
+ export function normalizeWebhookUrls(urls?: string[]): string[] {
41
+ if (!urls || urls.length === 0) return [];
42
+ return urls
43
+ .map(url => url.trim())
44
+ .filter(url => url.length > 0);
45
+ }
46
+
47
+ export function buildWebhookPayload(input: WebhookPayloadInput): WebhookPayload {
48
+ return {
49
+ event: input.event,
50
+ task: input.task,
51
+ branch: input.branch ?? '',
52
+ iteration: input.iteration,
53
+ stage: input.stage,
54
+ timestamp: input.timestamp ?? isoNow()
55
+ };
56
+ }
57
+
58
+ function resolveFetcher(fetcher?: FetchLike): FetchLike | null {
59
+ if (fetcher) return fetcher;
60
+ const globalFetcher = globalThis.fetch;
61
+ if (typeof globalFetcher !== 'function') return null;
62
+ return (globalFetcher as typeof fetch) as FetchLike;
63
+ }
64
+
65
+ async function postWebhook(url: string, payload: WebhookPayload, timeoutMs: number, logger: Logger, fetcher: FetchLike): Promise<void> {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
68
+ try {
69
+ const response = await fetcher(url, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'content-type': 'application/json'
73
+ },
74
+ body: JSON.stringify(payload),
75
+ signal: controller.signal
76
+ });
77
+ if (!response.ok) {
78
+ logger.warn(`webhook 请求失败(${response.status} ${response.statusText}):${url}`);
79
+ } else {
80
+ logger.debug(`webhook 通知成功:${url}`);
81
+ }
82
+ } catch (error) {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ logger.warn(`webhook 通知异常:${url}|${message}`);
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
88
+ }
89
+
90
+ export async function sendWebhookNotifications(
91
+ config: WebhookConfig | null | undefined,
92
+ payload: WebhookPayload,
93
+ logger: Logger,
94
+ fetcher?: FetchLike
95
+ ): Promise<void> {
96
+ const urls = normalizeWebhookUrls(config?.urls);
97
+ if (urls.length === 0) return;
98
+
99
+ const resolvedFetcher = resolveFetcher(fetcher);
100
+ if (!resolvedFetcher) {
101
+ logger.warn('当前 Node 环境不支持 fetch,已跳过 webhook 通知');
102
+ return;
103
+ }
104
+
105
+ const timeoutMs = config?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
+ await Promise.all(urls.map(url => postWebhook(url, payload, timeoutMs, logger, resolvedFetcher)));
107
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { buildInstallCommand, resolvePackageManager } from '../src/deps';
4
+
5
+ const baseHints = {
6
+ hasYarnLock: false,
7
+ hasPnpmLock: false,
8
+ hasNpmLock: false,
9
+ hasNpmShrinkwrap: false
10
+ };
11
+
12
+ test('resolvePackageManager 优先 packageManager 字段', () => {
13
+ const resolution = resolvePackageManager({
14
+ ...baseHints,
15
+ packageManagerField: 'pnpm@8.0.0',
16
+ hasYarnLock: true,
17
+ hasNpmLock: true
18
+ });
19
+
20
+ assert.equal(resolution.manager, 'pnpm');
21
+ assert.equal(resolution.source, 'packageManager');
22
+ assert.equal(resolution.hasLock, false);
23
+ });
24
+
25
+ test('resolvePackageManager 会根据锁文件选择 yarn', () => {
26
+ const resolution = resolvePackageManager({
27
+ ...baseHints,
28
+ hasYarnLock: true
29
+ });
30
+
31
+ assert.equal(resolution.manager, 'yarn');
32
+ assert.equal(resolution.source, 'lockfile');
33
+ assert.equal(resolution.hasLock, true);
34
+ });
35
+
36
+ test('resolvePackageManager 无提示时默认使用 yarn', () => {
37
+ const resolution = resolvePackageManager({
38
+ ...baseHints
39
+ });
40
+
41
+ assert.equal(resolution.manager, 'yarn');
42
+ assert.equal(resolution.source, 'default');
43
+ assert.equal(resolution.hasLock, false);
44
+ });
45
+
46
+ test('buildInstallCommand 会根据包管理器生成命令', () => {
47
+ const yarnCommand = buildInstallCommand({
48
+ manager: 'yarn',
49
+ source: 'lockfile',
50
+ hasLock: true
51
+ });
52
+ const yarnNoLockCommand = buildInstallCommand({
53
+ manager: 'yarn',
54
+ source: 'default',
55
+ hasLock: false
56
+ });
57
+ const npmCommand = buildInstallCommand({
58
+ manager: 'npm',
59
+ source: 'lockfile',
60
+ hasLock: true
61
+ });
62
+ const pnpmCommand = buildInstallCommand({
63
+ manager: 'pnpm',
64
+ source: 'default',
65
+ hasLock: false
66
+ });
67
+
68
+ assert.equal(yarnCommand, 'yarn install --frozen-lockfile');
69
+ assert.equal(yarnNoLockCommand, 'yarn install --no-lockfile');
70
+ assert.equal(npmCommand, 'npm ci --no-audit --no-fund');
71
+ assert.equal(pnpmCommand, 'pnpm install');
72
+ });
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFile } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { test } from 'node:test';
6
+
7
+ test('CLI 帮助信息可正常输出', async () => {
8
+ const execFileAsync = promisify(execFile);
9
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
10
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'run', '--help'], {
11
+ env: {
12
+ ...process.env,
13
+ FORCE_COLOR: '0'
14
+ }
15
+ });
16
+
17
+ assert.ok(stdout.includes('Usage: wheel-ai run'));
18
+ assert.ok(!stdout.includes('--ai-env-file'));
19
+ assert.ok(!stdout.includes('--ai-env'));
20
+ assert.ok(stdout.includes('--log-file'));
21
+ assert.ok(stdout.includes('--background'));
22
+ assert.ok(stdout.includes('--skip-install'));
23
+ assert.ok(stdout.includes('--webhook'));
24
+ assert.ok(stdout.includes('--webhook-timeout'));
25
+ });
26
+
27
+ test('CLI monitor 帮助信息可正常输出', async () => {
28
+ const execFileAsync = promisify(execFile);
29
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
30
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'monitor', '--help'], {
31
+ env: {
32
+ ...process.env,
33
+ FORCE_COLOR: '0'
34
+ }
35
+ });
36
+
37
+ assert.ok(stdout.includes('Usage: wheel-ai monitor'));
38
+ });
39
+
40
+ test('CLI monitor 在非 TTY 下输出提示', async () => {
41
+ const execFileAsync = promisify(execFile);
42
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
43
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'monitor'], {
44
+ env: {
45
+ ...process.env,
46
+ FORCE_COLOR: '0'
47
+ }
48
+ });
49
+
50
+ assert.ok(stdout.includes('当前终端不支持交互式 monitor。'));
51
+ });
52
+
53
+ test('CLI logs 帮助信息可正常输出', async () => {
54
+ const execFileAsync = promisify(execFile);
55
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
56
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'logs', '--help'], {
57
+ env: {
58
+ ...process.env,
59
+ FORCE_COLOR: '0'
60
+ }
61
+ });
62
+
63
+ assert.ok(stdout.includes('Usage: wheel-ai logs'));
64
+ });
65
+
66
+ test('CLI logs 在非 TTY 下输出提示', async () => {
67
+ const execFileAsync = promisify(execFile);
68
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
69
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'logs'], {
70
+ env: {
71
+ ...process.env,
72
+ FORCE_COLOR: '0'
73
+ }
74
+ });
75
+
76
+ assert.ok(stdout.includes('当前终端不支持交互式 logs。'));
77
+ });
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { createPr } from '../../src/gh';
7
+ import { Logger } from '../../src/logger';
8
+ import type { PrConfig } from '../../src/types';
9
+
10
+ test('createPr 遇到已存在 PR 提示时视为成功', async () => {
11
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wheel-ai-gh-'));
12
+ const ghPath = path.join(tempDir, 'gh');
13
+ const prInfo = {
14
+ number: 12,
15
+ title: 'feat: demo',
16
+ url: 'https://example.com/pr/12',
17
+ state: 'OPEN',
18
+ headRefName: 'feat/demo'
19
+ };
20
+ const script = `#!/usr/bin/env node
21
+ const args = process.argv.slice(2);
22
+ const isCreate = args[0] === 'pr' && args[1] === 'create';
23
+ const isView = args[0] === 'pr' && args[1] === 'view';
24
+ if (isCreate) {
25
+ const message = 'a pull request for branch "feat/demo" into branch "main" already exists';
26
+ process.stderr.write(message);
27
+ process.exit(1);
28
+ }
29
+ if (isView) {
30
+ process.stdout.write('${JSON.stringify(prInfo)}');
31
+ process.exit(0);
32
+ }
33
+ process.stderr.write('unknown command');
34
+ process.exit(1);
35
+ `;
36
+
37
+ await fs.writeFile(ghPath, script, 'utf8');
38
+ await fs.chmod(ghPath, 0o755);
39
+
40
+ const previousPath = process.env.PATH ?? '';
41
+ process.env.PATH = `${tempDir}${path.delimiter}${previousPath}`;
42
+
43
+ try {
44
+ const logger = new Logger({ verbose: false });
45
+ const config: PrConfig = { enable: true };
46
+ const result = await createPr('feat/demo', config, process.cwd(), logger);
47
+
48
+ assert.ok(result);
49
+ assert.equal(result?.url, prInfo.url);
50
+ assert.equal(result?.number, prInfo.number);
51
+ } finally {
52
+ process.env.PATH = previousPath;
53
+ await fs.rm(tempDir, { recursive: true, force: true });
54
+ }
55
+ });
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { listFailedRuns } from '../../src/gh';
7
+ import { Logger } from '../../src/logger';
8
+
9
+ test('listFailedRuns 可识别失败的 Actions 运行', async () => {
10
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wheel-ai-gh-'));
11
+ const ghPath = path.join(tempDir, 'gh');
12
+ const output = [
13
+ {
14
+ databaseId: 201,
15
+ name: 'CI',
16
+ status: 'completed',
17
+ conclusion: 'failure',
18
+ url: 'https://example.com/ci'
19
+ },
20
+ {
21
+ databaseId: 202,
22
+ name: 'Lint',
23
+ status: 'completed',
24
+ conclusion: 'success',
25
+ url: 'https://example.com/lint'
26
+ }
27
+ ];
28
+ const script = `#!/usr/bin/env node\nprocess.stdout.write('${JSON.stringify(output)}');\n`;
29
+
30
+ await fs.writeFile(ghPath, script, 'utf8');
31
+ await fs.chmod(ghPath, 0o755);
32
+
33
+ const previousPath = process.env.PATH ?? '';
34
+ process.env.PATH = `${tempDir}${path.delimiter}${previousPath}`;
35
+
36
+ try {
37
+ const logger = new Logger({ verbose: false });
38
+ const runs = await listFailedRuns('demo-branch', process.cwd(), logger);
39
+
40
+ assert.equal(runs.length, 1);
41
+ assert.equal(runs[0].name, 'CI');
42
+ assert.equal(runs[0].conclusion, 'failure');
43
+ } finally {
44
+ process.env.PATH = previousPath;
45
+ await fs.rm(tempDir, { recursive: true, force: true });
46
+ }
47
+ });
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { buildPrCreateArgs, isPrAlreadyExistsMessage, resolvePrTitle } from '../src/gh';
4
+ import type { PrConfig } from '../src/types';
5
+
6
+ test('resolvePrTitle 在未提供标题时生成默认标题', () => {
7
+ const title = resolvePrTitle('feat/demo', ' ');
8
+
9
+ assert.equal(title, 'chore: 自动 PR (feat/demo)');
10
+ });
11
+
12
+ test('buildPrCreateArgs 始终包含 --title 与 --body 参数', () => {
13
+ const config: PrConfig = {
14
+ enable: true,
15
+ draft: false,
16
+ reviewers: []
17
+ };
18
+ const args = buildPrCreateArgs('feat/demo', config);
19
+
20
+ const titleIndex = args.indexOf('--title');
21
+ const bodyIndex = args.indexOf('--body');
22
+
23
+ assert.ok(titleIndex >= 0, '应包含 --title');
24
+ assert.ok(bodyIndex >= 0, '应包含 --body');
25
+ assert.equal(args[titleIndex + 1], 'chore: 自动 PR (feat/demo)');
26
+ });
27
+
28
+ test('buildPrCreateArgs 支持使用 body 文件', () => {
29
+ const config: PrConfig = {
30
+ enable: true,
31
+ title: 'chore: ready',
32
+ bodyPath: 'memory/pr-body.md',
33
+ draft: true,
34
+ reviewers: ['alice', 'bob']
35
+ };
36
+ const args = buildPrCreateArgs('feat/demo', config);
37
+
38
+ assert.ok(args.includes('--body-file'));
39
+ assert.ok(args.includes('memory/pr-body.md'));
40
+ assert.ok(args.includes('--draft'));
41
+ assert.ok(args.includes('--reviewer'));
42
+ assert.ok(args.includes('alice,bob'));
43
+ });
44
+
45
+ test('isPrAlreadyExistsMessage 可识别已有 PR 提示', () => {
46
+ assert.equal(
47
+ isPrAlreadyExistsMessage('a pull request for branch "feat/demo" into branch "main" already exists'),
48
+ true
49
+ );
50
+ assert.equal(isPrAlreadyExistsMessage('A PR already exists for branch feat/demo on branch main'), true);
51
+ assert.equal(isPrAlreadyExistsMessage('针对分支 feat/demo 到分支 main 的拉取请求已存在'), true);
52
+ assert.equal(isPrAlreadyExistsMessage('分支 feat/demo 已存在 PR,无法重复创建'), true);
53
+ assert.equal(isPrAlreadyExistsMessage('The draft already exists for PR #123'), false);
54
+ assert.equal(isPrAlreadyExistsMessage('some other error'), false);
55
+ });
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { parseGhRunList } from '../src/gh';
4
+
5
+ test('parseGhRunList 可解析 databaseId 与 null conclusion', () => {
6
+ const output = JSON.stringify([
7
+ {
8
+ databaseId: 101,
9
+ name: 'CI',
10
+ status: 'completed',
11
+ conclusion: null,
12
+ url: 'https://example.com/ci'
13
+ },
14
+ {
15
+ databaseId: 102,
16
+ name: 'Lint',
17
+ status: 'completed',
18
+ conclusion: 'failure',
19
+ url: 'https://example.com/lint'
20
+ }
21
+ ]);
22
+
23
+ const runs = parseGhRunList(output);
24
+
25
+ assert.equal(runs.length, 2);
26
+ assert.equal(runs[0].databaseId, 101);
27
+ assert.equal(runs[0].conclusion, null);
28
+ assert.equal(runs[1].conclusion, 'failure');
29
+ });
30
+
31
+ test('parseGhRunList 遇到非法 JSON 返回空数组', () => {
32
+ const runs = parseGhRunList('not-json');
33
+
34
+ assert.deepEqual(runs, []);
35
+ });
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { applyShortcutArgv, parseGlobalConfig, splitCommandArgs } from '../src/global-config';
4
+
5
+ test('parseGlobalConfig 读取 shortcut 配置', () => {
6
+ const content = `
7
+ # comment
8
+ [shortcut]
9
+ name = "daily"
10
+ command = "--task \\\"修复 #123\\\" --run-tests" # inline comment
11
+ `;
12
+ const config = parseGlobalConfig(content);
13
+ assert.deepEqual(config, {
14
+ shortcut: {
15
+ name: 'daily',
16
+ command: '--task "修复 #123" --run-tests'
17
+ }
18
+ });
19
+ });
20
+
21
+ test('parseGlobalConfig 缺失字段时返回空配置', () => {
22
+ const content = `
23
+ [shortcut]
24
+ name = "daily run"
25
+ `;
26
+ const config = parseGlobalConfig(content);
27
+ assert.deepEqual(config, {});
28
+ });
29
+
30
+ test('splitCommandArgs 支持引号与转义', () => {
31
+ const args = splitCommandArgs('--task "fix bug" --ai-args "--model" "claude-3-opus"');
32
+ assert.deepEqual(args, ['--task', 'fix bug', '--ai-args', '--model', 'claude-3-opus']);
33
+ });
34
+
35
+ test('applyShortcutArgv 展开快捷指令并追加参数', () => {
36
+ const config = parseGlobalConfig(`
37
+ [shortcut]
38
+ name = "daily"
39
+ command = "run --task \\\"demo\\\" --run-tests"
40
+ `);
41
+ const argv = ['node', '/path/cli.js', 'daily', '--run-e2e'];
42
+ const result = applyShortcutArgv(argv, config);
43
+ assert.deepEqual(result, [
44
+ 'node',
45
+ '/path/cli.js',
46
+ 'run',
47
+ '--task',
48
+ 'demo',
49
+ '--run-tests',
50
+ '--run-e2e'
51
+ ]);
52
+ });
@@ -0,0 +1,56 @@
1
+ import assert from 'node:assert/strict';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { afterEach, test } from 'node:test';
6
+ import { Logger } from '../src/logger';
7
+
8
+ const timestampPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} /;
9
+ const tempRoot = path.join(process.cwd(), 'tests', '.tmp');
10
+
11
+ let tempFile: string | null = null;
12
+
13
+ const prepareTempFile = async (): Promise<string> => {
14
+ await fs.mkdir(tempRoot, { recursive: true });
15
+ const filePath = path.join(tempRoot, `logger-${randomUUID()}.log`);
16
+ tempFile = filePath;
17
+ return filePath;
18
+ };
19
+
20
+ const readLines = async (filePath: string): Promise<string[]> => {
21
+ const content = await fs.readFile(filePath, 'utf8');
22
+ return content.split(/\r?\n/).filter(line => line.length > 0);
23
+ };
24
+
25
+ afterEach(async () => {
26
+ if (!tempFile) return;
27
+ await fs.rm(tempFile, { force: true });
28
+ tempFile = null;
29
+ });
30
+
31
+ test('Logger 写入日志文件(非 verbose 不写 debug)', async () => {
32
+ const filePath = await prepareTempFile();
33
+ const logger = new Logger({ verbose: false, logFile: filePath });
34
+
35
+ logger.info('hello');
36
+ logger.debug('detail');
37
+ logger.warn('warning');
38
+
39
+ const lines = await readLines(filePath);
40
+ assert.equal(lines.length, 2);
41
+ assert.match(lines[0], new RegExp(`${timestampPattern.source}info\\s+hello$`));
42
+ assert.match(lines[1], new RegExp(`${timestampPattern.source}warn\\s+warning$`));
43
+ });
44
+
45
+ test('Logger verbose 时写入 debug', async () => {
46
+ const filePath = await prepareTempFile();
47
+ const logger = new Logger({ verbose: true, logFile: filePath });
48
+
49
+ logger.success('ok');
50
+ logger.debug('detail');
51
+
52
+ const lines = await readLines(filePath);
53
+ assert.equal(lines.length, 2);
54
+ assert.match(lines[0], new RegExp(`${timestampPattern.source}ok\\s+ok$`));
55
+ assert.match(lines[1], new RegExp(`${timestampPattern.source}dbg\\s+detail$`));
56
+ });
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict';
2
+ import { afterEach, beforeEach, test } from 'node:test';
3
+ import { Logger } from '../src/logger';
4
+
5
+ type ConsoleMethodName = 'log' | 'warn' | 'error';
6
+
7
+ const ansiPattern = /\u001b\[[0-9;]*m/g;
8
+ const timestampPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} /;
9
+
10
+ const originalConsole: Record<ConsoleMethodName, Console['log']> = {
11
+ log: console.log,
12
+ warn: console.warn,
13
+ error: console.error
14
+ };
15
+
16
+ const outputs: Record<ConsoleMethodName, string[]> = {
17
+ log: [],
18
+ warn: [],
19
+ error: []
20
+ };
21
+
22
+ const stripAnsi = (value: string): string => value.replace(ansiPattern, '');
23
+
24
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+
26
+ const capture = (method: ConsoleMethodName, sink: string[]): void => {
27
+ const writer = (...data: unknown[]): void => {
28
+ sink.push(data.map(item => String(item)).join(' '));
29
+ };
30
+ console[method] = writer as Console['log'];
31
+ };
32
+
33
+ const expectLine = (raw: string, label: string, message: string): void => {
34
+ const value = stripAnsi(raw);
35
+ const pattern = new RegExp(`^${timestampPattern.source}${label}\\s+${escapeRegExp(message)}$`);
36
+ assert.match(value, pattern);
37
+ };
38
+
39
+ beforeEach(() => {
40
+ outputs.log = [];
41
+ outputs.warn = [];
42
+ outputs.error = [];
43
+ capture('log', outputs.log);
44
+ capture('warn', outputs.warn);
45
+ capture('error', outputs.error);
46
+ });
47
+
48
+ afterEach(() => {
49
+ console.log = originalConsole.log;
50
+ console.warn = originalConsole.warn;
51
+ console.error = originalConsole.error;
52
+ });
53
+
54
+ test('Logger 输出包含时间戳前缀', () => {
55
+ const logger = new Logger({ verbose: true });
56
+
57
+ logger.info('hello');
58
+ logger.success('ok');
59
+ logger.warn('warning');
60
+ logger.error('boom');
61
+ logger.debug('detail');
62
+
63
+ assert.equal(outputs.log.length, 3);
64
+ assert.equal(outputs.warn.length, 1);
65
+ assert.equal(outputs.error.length, 1);
66
+
67
+ expectLine(outputs.log[0], 'info', 'hello');
68
+ expectLine(outputs.log[1], 'ok', 'ok');
69
+ expectLine(outputs.log[2], 'dbg', 'detail');
70
+ expectLine(outputs.warn[0], 'warn', 'warning');
71
+ expectLine(outputs.error[0], 'err', 'boom');
72
+ });
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { test } from 'node:test';
5
+ import fs from 'fs-extra';
6
+ import { CurrentRegistry } from '../src/logs';
7
+ import { buildRunningLogKeys, loadLogEntries } from '../src/logs-viewer';
8
+
9
+ test('buildRunningLogKeys 会合并 registry key 与 logFile 名称', () => {
10
+ const registry: CurrentRegistry = {
11
+ 'alpha.log': {
12
+ command: 'cmd',
13
+ round: 1,
14
+ tokenUsed: 2,
15
+ path: '/tmp',
16
+ logFile: '/var/log/alpha.log'
17
+ },
18
+ 'beta.log': {
19
+ command: 'cmd',
20
+ round: 1,
21
+ tokenUsed: 2,
22
+ path: '/tmp'
23
+ }
24
+ };
25
+
26
+ const keys = buildRunningLogKeys(registry);
27
+ assert.ok(keys.has('alpha.log'));
28
+ assert.ok(keys.has('beta.log'));
29
+ });
30
+
31
+ test('loadLogEntries 会排除运行中的日志与非 .log 文件', async () => {
32
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wheel-ai-logs-'));
33
+ try {
34
+ const logA = path.join(tempDir, 'alpha.log');
35
+ const logB = path.join(tempDir, 'beta.log');
36
+ const txt = path.join(tempDir, 'note.txt');
37
+ await fs.writeFile(logA, 'alpha', 'utf8');
38
+ await fs.writeFile(logB, 'beta', 'utf8');
39
+ await fs.writeFile(txt, 'note', 'utf8');
40
+
41
+ const registry: CurrentRegistry = {
42
+ 'beta.log': {
43
+ command: 'cmd',
44
+ round: 1,
45
+ tokenUsed: 2,
46
+ path: '/tmp',
47
+ logFile: logB
48
+ }
49
+ };
50
+
51
+ const entries = await loadLogEntries(tempDir, registry);
52
+ const names = entries.map(entry => entry.fileName);
53
+ assert.deepEqual(names, ['alpha.log']);
54
+ } finally {
55
+ await fs.remove(tempDir);
56
+ }
57
+ });