pi-thread-engine 0.4.7 → 0.4.9
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/PLAN.md +30 -53
- package/README.md +221 -214
- package/_lib/contract.ts +116 -0
- package/docs/HELLO_PYTHON.md +68 -0
- package/extensions/index.ts +111 -78
- package/package.json +15 -6
- package/src/core/executor.ts +12 -6
- package/src/core/registry.test.ts +32 -0
- package/src/core/registry.ts +290 -290
- package/src/core/types.ts +107 -107
- package/src/core/worktree.ts +309 -263
- package/src/hello-world.test.ts +30 -0
- package/src/hello-world.ts +25 -0
- package/src/worktree-lifecycle.test.ts +124 -0
package/src/core/worktree.ts
CHANGED
|
@@ -1,263 +1,309 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worktree management for pi-thread-engine
|
|
3
|
-
*
|
|
4
|
-
* Mirrors Grok CLI's `isolation: "worktree"` pattern.
|
|
5
|
-
* Each worktree gets its own branch + working directory so parallel
|
|
6
|
-
* agents never conflict on file edits.
|
|
7
|
-
*
|
|
8
|
-
* Worktrees stored under `<repo>/.git/worktrees-pi/` for easy cleanup.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
-
import { join, resolve } from "path";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Worktree management for pi-thread-engine
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Grok CLI's `isolation: "worktree"` pattern.
|
|
5
|
+
* Each worktree gets its own branch + working directory so parallel
|
|
6
|
+
* agents never conflict on file edits.
|
|
7
|
+
*
|
|
8
|
+
* Worktrees stored under `<repo>/.git/worktrees-pi/` for easy cleanup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { join, resolve, relative, sep, isAbsolute } from "path";
|
|
14
|
+
|
|
15
|
+
export interface WorktreeInfo {
|
|
16
|
+
path: string;
|
|
17
|
+
branch: string;
|
|
18
|
+
threadId: string;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
ahead: number;
|
|
21
|
+
behind: number;
|
|
22
|
+
dirty: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const WORKTREE_DIR = ".git/worktrees-pi";
|
|
26
|
+
let worktreeRegistry: WorktreeInfo[] = [];
|
|
27
|
+
|
|
28
|
+
function git(args: string[], cwd: string): string {
|
|
29
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shortHash(input: string): string {
|
|
33
|
+
let hash = 0x811c9dc5;
|
|
34
|
+
for (let i = 0; i < input.length; i++) {
|
|
35
|
+
hash ^= input.charCodeAt(i);
|
|
36
|
+
hash = Math.imul(hash, 0x01000193);
|
|
37
|
+
}
|
|
38
|
+
return (hash >>> 0).toString(36);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeThreadId(threadId: string): string {
|
|
42
|
+
const safe = threadId.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
+
if (!safe) throw new Error("Invalid threadId");
|
|
44
|
+
const suffix = shortHash(threadId);
|
|
45
|
+
return safe.length > 48 ? `${safe.slice(0, 48)}-${suffix}` : `${safe}-${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isContained(parent: string, child: string): boolean {
|
|
49
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
50
|
+
return rel === "" || (!rel.startsWith("..") && !rel.includes(`..${sep}`) && !isAbsolute(rel));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertContainedWorktreePath(repoPath: string, wtPath: string): string {
|
|
54
|
+
const root = resolve(repoPath, WORKTREE_DIR);
|
|
55
|
+
const resolved = resolve(wtPath);
|
|
56
|
+
if (!isContained(root, resolved)) throw new Error("Worktree path escapes worktree directory");
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateBranchRef(ref: string, cwd: string, label: string): string {
|
|
61
|
+
if (!ref || ref.startsWith("-") || ref.includes("\0")) throw new Error(`Invalid ${label}`);
|
|
62
|
+
try {
|
|
63
|
+
git(["check-ref-format", "--branch", ref], cwd);
|
|
64
|
+
return ref;
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Invalid ${label}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateStartPoint(ref: string, cwd: string, label: string): string {
|
|
71
|
+
if (!ref || ref.startsWith("-") || /[\0\r\n]/.test(ref)) throw new Error(`Invalid ${label}`);
|
|
72
|
+
try {
|
|
73
|
+
git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd);
|
|
74
|
+
return ref;
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(`Invalid ${label}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadRegistry(repoPath: string): WorktreeInfo[] {
|
|
81
|
+
const regPath = join(repoPath, WORKTREE_DIR, "registry.json");
|
|
82
|
+
if (existsSync(regPath)) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(regPath, "utf8"));
|
|
85
|
+
} catch { /* corrupt, start fresh */ }
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveRegistry(repoPath: string, reg: WorktreeInfo[]) {
|
|
91
|
+
const regDir = join(repoPath, WORKTREE_DIR);
|
|
92
|
+
if (!existsSync(regDir)) mkdirSync(regDir, { recursive: true });
|
|
93
|
+
writeFileSync(join(regDir, "registry.json"), JSON.stringify(reg, null, 2), "utf8");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Resolve the git repo root from a path */
|
|
97
|
+
export function findRepoRoot(cwd: string): string | null {
|
|
98
|
+
try {
|
|
99
|
+
return git(["rev-parse", "--show-toplevel"], cwd);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Check if we're in a git repo */
|
|
106
|
+
export function isGitRepo(cwd: string): boolean {
|
|
107
|
+
return findRepoRoot(cwd) !== null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Generate a unique branch name for a thread */
|
|
111
|
+
export function branchName(threadId: string): string {
|
|
112
|
+
return `pi-thread/${sanitizeThreadId(threadId)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Worktree storage path for a thread */
|
|
116
|
+
export function worktreePath(repoPath: string, threadId: string): string {
|
|
117
|
+
return assertContainedWorktreePath(repoPath, join(repoPath, WORKTREE_DIR, sanitizeThreadId(threadId)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a worktree for a thread.
|
|
122
|
+
* Creates a new branch off the current HEAD, checks it out in an isolated dir.
|
|
123
|
+
*/
|
|
124
|
+
export function createWorktree(cwd: string, threadId: string, baseBranch?: string): WorktreeInfo {
|
|
125
|
+
const repoPath = findRepoRoot(cwd);
|
|
126
|
+
if (!repoPath) throw new Error("Not in a git repository");
|
|
127
|
+
|
|
128
|
+
const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
|
|
129
|
+
const base = validateStartPoint(baseBranch ?? "HEAD", repoPath, "base branch ref");
|
|
130
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
131
|
+
|
|
132
|
+
// Create the branch if it doesn't exist
|
|
133
|
+
try {
|
|
134
|
+
git(["branch", branch, base], repoPath);
|
|
135
|
+
} catch {
|
|
136
|
+
// Branch may already exist from a previous run — that's fine
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Create worktree
|
|
140
|
+
git(["worktree", "add", wtPath, branch], repoPath);
|
|
141
|
+
|
|
142
|
+
const info: WorktreeInfo = {
|
|
143
|
+
path: wtPath,
|
|
144
|
+
branch,
|
|
145
|
+
threadId,
|
|
146
|
+
createdAt: Date.now(),
|
|
147
|
+
ahead: 0,
|
|
148
|
+
behind: 0,
|
|
149
|
+
dirty: false,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const reg = loadRegistry(repoPath);
|
|
153
|
+
// Remove any stale entry for this thread
|
|
154
|
+
const filtered = reg.filter((w) => w.threadId !== threadId);
|
|
155
|
+
filtered.push(info);
|
|
156
|
+
saveRegistry(repoPath, filtered);
|
|
157
|
+
worktreeRegistry = filtered;
|
|
158
|
+
|
|
159
|
+
return info;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove a worktree and its branch
|
|
164
|
+
*/
|
|
165
|
+
export function removeWorktree(cwd: string, threadId: string): boolean {
|
|
166
|
+
const repoPath = findRepoRoot(cwd);
|
|
167
|
+
if (!repoPath) return false;
|
|
168
|
+
|
|
169
|
+
const branch = validateBranchRef(branchName(threadId), repoPath, "branch ref");
|
|
170
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
171
|
+
|
|
172
|
+
let success = true;
|
|
173
|
+
|
|
174
|
+
// Remove worktree
|
|
175
|
+
try {
|
|
176
|
+
git(["worktree", "remove", wtPath], repoPath);
|
|
177
|
+
} catch {
|
|
178
|
+
// Force remove if locked
|
|
179
|
+
try {
|
|
180
|
+
git(["worktree", "remove", "--force", wtPath], repoPath);
|
|
181
|
+
} catch {
|
|
182
|
+
success = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Delete branch
|
|
187
|
+
try {
|
|
188
|
+
git(["branch", "-D", branch], repoPath);
|
|
189
|
+
} catch {
|
|
190
|
+
// Branch may not exist
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Clean up directory after re-checking containment
|
|
194
|
+
try {
|
|
195
|
+
rmSync(assertContainedWorktreePath(repoPath, wtPath), { recursive: true, force: true });
|
|
196
|
+
} catch { /* best effort */ }
|
|
197
|
+
|
|
198
|
+
// Update registry
|
|
199
|
+
const reg = loadRegistry(repoPath);
|
|
200
|
+
const filtered = reg.filter((w) => w.threadId !== threadId);
|
|
201
|
+
saveRegistry(repoPath, filtered);
|
|
202
|
+
worktreeRegistry = filtered;
|
|
203
|
+
|
|
204
|
+
return success;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get divergence stats for a worktree branch
|
|
209
|
+
*/
|
|
210
|
+
function getDivergence(repoPath: string, branch: string): { ahead: number; behind: number } {
|
|
211
|
+
try {
|
|
212
|
+
const ref = validateBranchRef(branch, repoPath, "branch ref");
|
|
213
|
+
const ahead = parseInt(git(["rev-list", "--count", `HEAD..origin/${ref}`], repoPath) || "0", 10);
|
|
214
|
+
const behind = parseInt(git(["rev-list", "--count", `origin/${ref}..HEAD`], repoPath) || "0", 10);
|
|
215
|
+
return { ahead, behind };
|
|
216
|
+
} catch {
|
|
217
|
+
return { ahead: 0, behind: 0 };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if a worktree has uncommitted changes
|
|
223
|
+
*/
|
|
224
|
+
function isDirty(path: string): boolean {
|
|
225
|
+
try {
|
|
226
|
+
const status = git(["status", "--porcelain"], path);
|
|
227
|
+
return status.length > 0;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* List all worktrees with their divergence stats
|
|
235
|
+
*/
|
|
236
|
+
export function listWorktrees(cwd: string): WorktreeInfo[] {
|
|
237
|
+
const repoPath = findRepoRoot(cwd);
|
|
238
|
+
if (!repoPath) return [];
|
|
239
|
+
|
|
240
|
+
const reg = loadRegistry(repoPath);
|
|
241
|
+
|
|
242
|
+
// Refresh stats
|
|
243
|
+
return reg.map((w) => {
|
|
244
|
+
const div = getDivergence(repoPath, w.branch);
|
|
245
|
+
const dirty = existsSync(w.path) ? isDirty(w.path) : false;
|
|
246
|
+
return { ...w, ...div, dirty };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Collect worktree results — reads the output file from a finished thread
|
|
252
|
+
*/
|
|
253
|
+
export function collectWorktreeResult(threadId: string, worktree: WorktreeInfo): string | null {
|
|
254
|
+
const resultFile = join(worktree.path, ".pi-thread-result.json");
|
|
255
|
+
if (existsSync(resultFile)) {
|
|
256
|
+
try {
|
|
257
|
+
return readFileSync(resultFile, "utf8");
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Clean up ALL worktrees for this repo (emergency / force cleanup)
|
|
267
|
+
*/
|
|
268
|
+
export function cleanupAll(cwd: string): { removed: number; failed: number } {
|
|
269
|
+
const repoPath = findRepoRoot(cwd);
|
|
270
|
+
if (!repoPath) return { removed: 0, failed: 0 };
|
|
271
|
+
|
|
272
|
+
const reg = loadRegistry(repoPath);
|
|
273
|
+
let removed = 0;
|
|
274
|
+
let failed = 0;
|
|
275
|
+
|
|
276
|
+
for (const w of reg) {
|
|
277
|
+
if (removeWorktree(cwd, w.threadId)) removed++;
|
|
278
|
+
else failed++;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
saveRegistry(repoPath, []);
|
|
282
|
+
worktreeRegistry = [];
|
|
283
|
+
|
|
284
|
+
return { removed, failed };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Push worktree changes back to the main repo
|
|
289
|
+
*/
|
|
290
|
+
export function pushWorktreeChanges(cwd: string, threadId: string, message?: string): boolean {
|
|
291
|
+
const repoPath = findRepoRoot(cwd);
|
|
292
|
+
if (!repoPath) return false;
|
|
293
|
+
|
|
294
|
+
const wtPath = worktreePath(repoPath, threadId);
|
|
295
|
+
if (!existsSync(wtPath)) return false;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
// Commit any uncommitted changes
|
|
299
|
+
const status = git(["status", "--porcelain"], wtPath);
|
|
300
|
+
if (status) {
|
|
301
|
+
git(["add", "-A"], wtPath);
|
|
302
|
+
const defaultMsg = `[pi-threads] ${threadId} worktree changes`;
|
|
303
|
+
git(["commit", "-m", message ?? defaultMsg], wtPath);
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { helloWorld, DEFAULT_GREETING } from "./hello-world.js";
|
|
3
|
+
|
|
4
|
+
describe("helloWorld", () => {
|
|
5
|
+
it("returns 'Hello, World!' when called with no arguments", () => {
|
|
6
|
+
expect(helloWorld()).toBe("Hello, World!");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns 'Hello, World!' when called with undefined", () => {
|
|
10
|
+
expect(helloWorld(undefined)).toBe("Hello, World!");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns 'Hello, Alice!' when called with 'Alice'", () => {
|
|
14
|
+
expect(helloWorld("Alice")).toBe("Hello, Alice!");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("trims whitespace from the name", () => {
|
|
18
|
+
expect(helloWorld(" Charlie ")).toBe("Hello, Charlie!");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns 'Hello, World!' for empty string", () => {
|
|
22
|
+
expect(helloWorld("")).toBe("Hello, World!");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("DEFAULT_GREETING", () => {
|
|
27
|
+
it("is defined and equals 'Hello, World!'", () => {
|
|
28
|
+
expect(DEFAULT_GREETING).toBe("Hello, World!");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default greeting string.
|
|
3
|
+
*/
|
|
4
|
+
export const DEFAULT_GREETING = "Hello, World!";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns a greeting string.
|
|
8
|
+
*
|
|
9
|
+
* @param name - Optional name to include in the greeting.
|
|
10
|
+
* `undefined`, empty string, or whitespace-only values
|
|
11
|
+
* produce the default greeting.
|
|
12
|
+
* @returns The greeting string.
|
|
13
|
+
*/
|
|
14
|
+
export function helloWorld(name?: string): string {
|
|
15
|
+
if (name === undefined || name === null) {
|
|
16
|
+
return DEFAULT_GREETING;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const trimmed = name.trim();
|
|
20
|
+
if (trimmed === "") {
|
|
21
|
+
return DEFAULT_GREETING;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return `Hello, ${trimmed}!`;
|
|
25
|
+
}
|