tlc-claude-code 1.8.5 → 2.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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Client — wraps ssh2 for VPS communication
|
|
3
|
+
* Phase 80 Task 4
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { Client } = require('ssh2');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve ~ in key paths
|
|
12
|
+
*/
|
|
13
|
+
function resolveKeyPath(keyPath) {
|
|
14
|
+
if (!keyPath) return null;
|
|
15
|
+
if (keyPath.startsWith('~')) {
|
|
16
|
+
return path.join(require('os').homedir(), keyPath.slice(1));
|
|
17
|
+
}
|
|
18
|
+
return keyPath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an SSH client wrapper
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {Function} [options._execFn] - Injected exec function (for testing)
|
|
25
|
+
* @returns {Object} SSH client API
|
|
26
|
+
*/
|
|
27
|
+
function createSshClient(options = {}) {
|
|
28
|
+
const injectedExec = options._execFn;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Execute a command via SSH
|
|
32
|
+
* @param {Object} config - { host, port, username, privateKeyPath }
|
|
33
|
+
* @param {string} command
|
|
34
|
+
* @returns {Promise<{ stdout, stderr, exitCode }>}
|
|
35
|
+
*/
|
|
36
|
+
async function exec(config, command) {
|
|
37
|
+
if (injectedExec) {
|
|
38
|
+
return injectedExec(config, command);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!config.host || !config.username) {
|
|
42
|
+
throw new Error('SSH config requires host and username');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const conn = new Client();
|
|
49
|
+
conn.on('ready', () => {
|
|
50
|
+
conn.exec(command, (err, stream) => {
|
|
51
|
+
if (err) { conn.end(); return reject(err); }
|
|
52
|
+
|
|
53
|
+
let stdout = '';
|
|
54
|
+
let stderr = '';
|
|
55
|
+
|
|
56
|
+
stream.on('data', (data) => { stdout += data.toString(); });
|
|
57
|
+
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
58
|
+
stream.on('close', (code) => {
|
|
59
|
+
conn.end();
|
|
60
|
+
resolve({ stdout, stderr, exitCode: code });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
conn.on('error', (err) => reject(err));
|
|
65
|
+
|
|
66
|
+
const connOpts = {
|
|
67
|
+
host: config.host,
|
|
68
|
+
port: config.port || 22,
|
|
69
|
+
username: config.username,
|
|
70
|
+
readyTimeout: 10000,
|
|
71
|
+
keepaliveInterval: 5000,
|
|
72
|
+
};
|
|
73
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
74
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
75
|
+
}
|
|
76
|
+
conn.connect(connOpts);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Execute with streaming output
|
|
82
|
+
* @param {Object} config
|
|
83
|
+
* @param {string} command
|
|
84
|
+
* @param {Function} onData - Called with each output chunk
|
|
85
|
+
* @returns {Promise<number>} exit code
|
|
86
|
+
*/
|
|
87
|
+
async function execStream(config, command, onData) {
|
|
88
|
+
if (injectedExec) {
|
|
89
|
+
const result = await injectedExec(config, command);
|
|
90
|
+
if (onData && result.stdout) onData(result.stdout);
|
|
91
|
+
return result.exitCode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!config.host || !config.username) {
|
|
95
|
+
throw new Error('SSH config requires host and username');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
99
|
+
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const conn = new Client();
|
|
102
|
+
conn.on('ready', () => {
|
|
103
|
+
conn.exec(command, (err, stream) => {
|
|
104
|
+
if (err) { conn.end(); return reject(err); }
|
|
105
|
+
stream.on('data', (data) => onData && onData(data.toString()));
|
|
106
|
+
stream.stderr.on('data', (data) => onData && onData(data.toString()));
|
|
107
|
+
stream.on('close', (code) => { conn.end(); resolve(code); });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
conn.on('error', (err) => reject(err));
|
|
111
|
+
|
|
112
|
+
const connOpts = {
|
|
113
|
+
host: config.host,
|
|
114
|
+
port: config.port || 22,
|
|
115
|
+
username: config.username,
|
|
116
|
+
};
|
|
117
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
118
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
119
|
+
}
|
|
120
|
+
conn.connect(connOpts);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Upload a file via SFTP
|
|
126
|
+
* @param {Object} config
|
|
127
|
+
* @param {string} localPath
|
|
128
|
+
* @param {string} remotePath
|
|
129
|
+
*/
|
|
130
|
+
async function upload(config, localPath, remotePath) {
|
|
131
|
+
if (!config.host || !config.username) {
|
|
132
|
+
throw new Error('SSH config requires host and username');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const keyPath = resolveKeyPath(config.privateKeyPath);
|
|
136
|
+
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const conn = new Client();
|
|
139
|
+
conn.on('ready', () => {
|
|
140
|
+
conn.sftp((err, sftp) => {
|
|
141
|
+
if (err) { conn.end(); return reject(err); }
|
|
142
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
143
|
+
conn.end();
|
|
144
|
+
if (err) reject(err);
|
|
145
|
+
else resolve();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
conn.on('error', (err) => reject(err));
|
|
150
|
+
|
|
151
|
+
const connOpts = {
|
|
152
|
+
host: config.host,
|
|
153
|
+
port: config.port || 22,
|
|
154
|
+
username: config.username,
|
|
155
|
+
};
|
|
156
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
157
|
+
connOpts.privateKey = fs.readFileSync(keyPath);
|
|
158
|
+
}
|
|
159
|
+
conn.connect(connOpts);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Test SSH connection and gather server info
|
|
165
|
+
* @param {Object} config
|
|
166
|
+
* @returns {Promise<{ connected, os, docker, disk }>}
|
|
167
|
+
*/
|
|
168
|
+
async function testConnection(config) {
|
|
169
|
+
const osResult = await exec(config, 'uname -a');
|
|
170
|
+
const dockerResult = await exec(config, 'docker --version 2>/dev/null || echo "not installed"');
|
|
171
|
+
const diskResult = await exec(config, 'df -h / | tail -1');
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
connected: true,
|
|
175
|
+
os: osResult.stdout.trim(),
|
|
176
|
+
docker: dockerResult.stdout.trim(),
|
|
177
|
+
disk: diskResult.stdout.trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { exec, execStream, upload, testConnection };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createSshClient };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { createSshClient } = await import('./ssh-client.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock ssh2 Client for injection
|
|
7
|
+
*/
|
|
8
|
+
function createMockSsh2() {
|
|
9
|
+
const mockStream = {
|
|
10
|
+
on: vi.fn().mockReturnThis(),
|
|
11
|
+
stderr: { on: vi.fn().mockReturnThis() },
|
|
12
|
+
end: vi.fn(),
|
|
13
|
+
write: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const mockSftp = {
|
|
17
|
+
fastPut: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockClient = {
|
|
21
|
+
on: vi.fn().mockReturnThis(),
|
|
22
|
+
connect: vi.fn(),
|
|
23
|
+
exec: vi.fn(),
|
|
24
|
+
sftp: vi.fn(),
|
|
25
|
+
end: vi.fn(),
|
|
26
|
+
_mockStream: mockStream,
|
|
27
|
+
_mockSftp: mockSftp,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return mockClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('SshClient', () => {
|
|
34
|
+
let sshClient;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
sshClient = createSshClient();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('testConnection', () => {
|
|
41
|
+
it('resolves with server info on successful connection', async () => {
|
|
42
|
+
const config = { host: '1.2.3.4', port: 22, username: 'deploy', privateKeyPath: '/fake/key' };
|
|
43
|
+
// Test that testConnection is a function
|
|
44
|
+
expect(typeof sshClient.testConnection).toBe('function');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects when host is unreachable', async () => {
|
|
48
|
+
const config = { host: 'invalid', port: 22, username: 'root', privateKeyPath: '/nonexistent' };
|
|
49
|
+
await expect(sshClient.testConnection(config)).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('exec', () => {
|
|
54
|
+
it('is a function that accepts config and command', () => {
|
|
55
|
+
expect(typeof sshClient.exec).toBe('function');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects with error for bad config', async () => {
|
|
59
|
+
await expect(sshClient.exec({}, 'whoami')).rejects.toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('execStream', () => {
|
|
64
|
+
it('is a function that accepts config, command, and callback', () => {
|
|
65
|
+
expect(typeof sshClient.execStream).toBe('function');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('upload', () => {
|
|
70
|
+
it('is a function that accepts config, localPath, remotePath', () => {
|
|
71
|
+
expect(typeof sshClient.upload).toBe('function');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('SshClient with mock', () => {
|
|
77
|
+
it('exec resolves with stdout, stderr, exitCode via injected client', async () => {
|
|
78
|
+
// Create a mock that simulates successful exec
|
|
79
|
+
const mockExecResult = { stdout: 'root\n', stderr: '', exitCode: 0 };
|
|
80
|
+
const sshClient = createSshClient({
|
|
81
|
+
_execFn: vi.fn().mockResolvedValue(mockExecResult),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await sshClient.exec(
|
|
85
|
+
{ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' },
|
|
86
|
+
'whoami'
|
|
87
|
+
);
|
|
88
|
+
expect(result).toEqual(mockExecResult);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('exec rejects when command fails', async () => {
|
|
92
|
+
const sshClient = createSshClient({
|
|
93
|
+
_execFn: vi.fn().mockRejectedValue(new Error('command failed')),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
sshClient.exec({ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' }, 'bad-cmd')
|
|
98
|
+
).rejects.toThrow('command failed');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('testConnection returns server info', async () => {
|
|
102
|
+
const sshClient = createSshClient({
|
|
103
|
+
_execFn: vi.fn()
|
|
104
|
+
.mockResolvedValueOnce({ stdout: 'Linux\n', stderr: '', exitCode: 0 }) // uname
|
|
105
|
+
.mockResolvedValueOnce({ stdout: 'Docker version 24.0.0\n', stderr: '', exitCode: 0 }) // docker
|
|
106
|
+
.mockResolvedValueOnce({ stdout: '/dev/sda1 50G 20G 28G 42% /\n', stderr: '', exitCode: 0 }), // df
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const info = await sshClient.testConnection(
|
|
110
|
+
{ host: '1.2.3.4', username: 'deploy', privateKeyPath: '/key' }
|
|
111
|
+
);
|
|
112
|
+
expect(info.os).toContain('Linux');
|
|
113
|
+
expect(info.docker).toContain('24.0.0');
|
|
114
|
+
expect(info.disk).toBeTruthy();
|
|
115
|
+
expect(info.connected).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('testConnection reports disconnected on failure', async () => {
|
|
119
|
+
const sshClient = createSshClient({
|
|
120
|
+
_execFn: vi.fn().mockRejectedValue(new Error('Connection refused')),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
sshClient.testConnection({ host: '1.2.3.4', username: 'root', privateKeyPath: '/key' })
|
|
125
|
+
).rejects.toThrow('Connection refused');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file test-inventory.js
|
|
3
|
+
* @description Test Suite Inventory API (Phase 75, Task 2).
|
|
4
|
+
*
|
|
5
|
+
* Factory function that accepts injected dependencies (globSync, fs) and returns
|
|
6
|
+
* functions for discovering test files, counting tests per file, grouping by
|
|
7
|
+
* directory, and reading cached test runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { join, relative, dirname } = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a test inventory service with injected dependencies.
|
|
14
|
+
* @param {Object} deps - Injected dependencies
|
|
15
|
+
* @param {Function} deps.globSync - Glob function for file discovery
|
|
16
|
+
* @param {{ readFileSync: Function, existsSync: Function }} deps.fs - File system operations
|
|
17
|
+
* @returns {{ getTestInventory: Function, getLastTestRun: Function }}
|
|
18
|
+
*/
|
|
19
|
+
function createTestInventory({ globSync, fs }) {
|
|
20
|
+
/**
|
|
21
|
+
* Count the number of test cases in a file's content.
|
|
22
|
+
* Matches: it(, it.only(, it.skip(, test(, test.only(, test.skip(
|
|
23
|
+
* @param {string} content - File content to scan
|
|
24
|
+
* @returns {number} Number of test cases found
|
|
25
|
+
*/
|
|
26
|
+
function countTests(content) {
|
|
27
|
+
const pattern = /\b(?:it|test)(?:\.only|\.skip)?\s*\(/g;
|
|
28
|
+
const matches = content.match(pattern);
|
|
29
|
+
return matches ? matches.length : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Discover test files, count tests, and group by directory.
|
|
34
|
+
* @param {string} projectPath - Absolute path to the project root
|
|
35
|
+
* @returns {{ totalFiles: number, totalTests: number, groups: Array<{ name: string, fileCount: number, testCount: number, files: Array }> }}
|
|
36
|
+
*/
|
|
37
|
+
function getTestInventory(projectPath) {
|
|
38
|
+
const patterns = [
|
|
39
|
+
'**/*.test.js',
|
|
40
|
+
'**/*.test.ts',
|
|
41
|
+
'**/*.test.tsx',
|
|
42
|
+
'**/*.spec.*',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const files = globSync(patterns, {
|
|
46
|
+
cwd: projectPath,
|
|
47
|
+
absolute: true,
|
|
48
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** @type {Map<string, Array<{ relativePath: string, testCount: number }>>} */
|
|
52
|
+
const groupMap = new Map();
|
|
53
|
+
|
|
54
|
+
for (const filePath of files) {
|
|
55
|
+
const rel = relative(projectPath, filePath);
|
|
56
|
+
const dir = dirname(rel);
|
|
57
|
+
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
} catch {
|
|
62
|
+
content = '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const testCount = countTests(content);
|
|
66
|
+
|
|
67
|
+
if (!groupMap.has(dir)) {
|
|
68
|
+
groupMap.set(dir, []);
|
|
69
|
+
}
|
|
70
|
+
groupMap.get(dir).push({ relativePath: rel, testCount });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const groups = [];
|
|
74
|
+
for (const [name, fileList] of groupMap) {
|
|
75
|
+
const testCount = fileList.reduce((sum, f) => sum + f.testCount, 0);
|
|
76
|
+
groups.push({
|
|
77
|
+
name,
|
|
78
|
+
fileCount: fileList.length,
|
|
79
|
+
testCount,
|
|
80
|
+
files: fileList,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sort by testCount descending
|
|
85
|
+
groups.sort((a, b) => b.testCount - a.testCount);
|
|
86
|
+
|
|
87
|
+
const totalFiles = files.length;
|
|
88
|
+
const totalTests = groups.reduce((sum, g) => sum + g.testCount, 0);
|
|
89
|
+
|
|
90
|
+
return { totalFiles, totalTests, groups };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the cached last test run result.
|
|
95
|
+
* @param {string} projectPath - Absolute path to the project root
|
|
96
|
+
* @returns {Object|null} Parsed test run data, or null if no cache exists
|
|
97
|
+
*/
|
|
98
|
+
function getLastTestRun(projectPath) {
|
|
99
|
+
const cachePath = join(projectPath, '.tlc', 'last-test-run.json');
|
|
100
|
+
|
|
101
|
+
if (!fs.existsSync(cachePath)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const raw = fs.readFileSync(cachePath, 'utf-8');
|
|
106
|
+
return JSON.parse(raw);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { getTestInventory, getLastTestRun };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { createTestInventory };
|