@webappwiz/arbor 0.0.28 → 0.0.30
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/AGENTS.md +7 -0
- package/README.md +34 -12
- package/add.test.ts +125 -0
- package/add.ts +192 -0
- package/age.ts +11 -0
- package/arbor.test.ts +145 -0
- package/arbor.ts +429 -0
- package/assets.d.ts +9 -0
- package/attachments.ts +87 -0
- package/build.ts +98 -0
- package/claim.test.ts +89 -0
- package/claim.ts +73 -0
- package/components.json +21 -0
- package/config.ts +58 -0
- package/dev/api.ts +120 -0
- package/dev/app.test.tsx +645 -0
- package/dev/app.tsx +80 -0
- package/dev/assets.ts +16 -0
- package/dev/build/main.txt +68 -0
- package/dev/build/shell.txt +21 -0
- package/dev/build/styles.txt +2263 -0
- package/dev/components/ui/badge.tsx +51 -0
- package/dev/components/ui/button.tsx +57 -0
- package/dev/components/ui/dialog.tsx +154 -0
- package/dev/components/ui/empty.tsx +100 -0
- package/dev/components/ui/input-group.tsx +155 -0
- package/dev/components/ui/input.tsx +19 -0
- package/dev/components/ui/progress.tsx +80 -0
- package/dev/components/ui/textarea.tsx +17 -0
- package/dev/components/ui/toast.tsx +227 -0
- package/dev/feed.ts +69 -0
- package/dev/files.tsx +191 -0
- package/dev/index.html +21 -0
- package/dev/lib/utils.ts +7 -0
- package/dev/main.tsx +22 -0
- package/dev/markdown.test.tsx +133 -0
- package/dev/markdown.tsx +124 -0
- package/dev/mentions.tsx +319 -0
- package/dev/shadcn.css +649 -0
- package/dev/styles.css +107 -0
- package/dev/tags.tsx +44 -0
- package/dev/tasks.tsx +151 -0
- package/dev/todos.tsx +759 -0
- package/dev.test.ts +439 -0
- package/dev.ts +386 -0
- package/e2e.test.ts +256 -0
- package/escalate.test.ts +76 -0
- package/escalate.ts +112 -0
- package/exit.test.ts +71 -0
- package/exit.ts +74 -0
- package/git.ts +294 -0
- package/index.ts +9 -0
- package/journal.ts +89 -0
- package/list.test.ts +82 -0
- package/list.ts +137 -0
- package/load-config.test.ts +121 -0
- package/load-config.ts +62 -0
- package/log.test.ts +65 -0
- package/log.ts +40 -0
- package/merge.test.ts +332 -0
- package/merge.ts +283 -0
- package/package.json +32 -15
- package/path.test.ts +35 -0
- package/path.ts +38 -0
- package/plan.test.ts +318 -0
- package/plan.ts +302 -0
- package/progress.test.ts +54 -0
- package/progress.ts +31 -0
- package/remove.test.ts +68 -0
- package/remove.ts +88 -0
- package/repository.ts +83 -0
- package/retry.test.ts +43 -0
- package/retry.ts +48 -0
- package/shell.ts +44 -0
- package/show.test.ts +91 -0
- package/show.ts +151 -0
- package/snapshot.test.ts +33 -0
- package/snapshot.ts +84 -0
- package/table.ts +20 -0
- package/testing.ts +229 -0
- package/todo.test.ts +490 -0
- package/todo.ts +834 -0
- package/wait.test.ts +61 -0
- package/wait.ts +77 -0
- package/worktree-service.test.ts +158 -0
- package/worktree-service.ts +193 -0
- package/worktree.ts +239 -0
- package/add.d.ts +0 -23
- package/age.d.ts +0 -2
- package/arbor.d.ts +0 -11
- package/attachments.d.ts +0 -36
- package/claim.d.ts +0 -11
- package/config.d.ts +0 -55
- package/config.js +0 -7
- package/dev/assets.d.ts +0 -11
- package/dev.d.ts +0 -47
- package/escalate.d.ts +0 -28
- package/exit.d.ts +0 -42
- package/git.d.ts +0 -77
- package/index.d.ts +0 -2
- package/index.js +0 -4669
- package/journal.d.ts +0 -29
- package/list.d.ts +0 -18
- package/load-config.d.ts +0 -15
- package/log.d.ts +0 -14
- package/merge.d.ts +0 -24
- package/path.d.ts +0 -13
- package/plan.d.ts +0 -83
- package/remove.d.ts +0 -19
- package/repository.d.ts +0 -25
- package/retry.d.ts +0 -17
- package/shell.d.ts +0 -17
- package/show.d.ts +0 -53
- package/snapshot.d.ts +0 -31
- package/table.d.ts +0 -5
- package/todo.d.ts +0 -230
- package/wait.d.ts +0 -22
- package/worktree-service.d.ts +0 -51
- package/worktree.d.ts +0 -95
package/show.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { color, type Logger } from "webappwiz/log";
|
|
2
|
+
import { type Fs, NodeFs } from "webappwiz/system";
|
|
3
|
+
import { age } from "./age";
|
|
4
|
+
import { fail } from "./exit";
|
|
5
|
+
import { checkPlan, PLAN_FILE } from "./plan";
|
|
6
|
+
import type { Worktree } from "./worktree";
|
|
7
|
+
import type { WorktreeService } from "./worktree-service";
|
|
8
|
+
|
|
9
|
+
export interface Details {
|
|
10
|
+
task: string;
|
|
11
|
+
status: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
base: string;
|
|
14
|
+
worktree: string;
|
|
15
|
+
lease: "held" | "stale" | "none";
|
|
16
|
+
ahead: number | null;
|
|
17
|
+
added: number | null;
|
|
18
|
+
removed: number | null;
|
|
19
|
+
age: string | null;
|
|
20
|
+
escalation: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* While escalated to be approved, the question that asks for it, `4`;
|
|
23
|
+
* null otherwise.
|
|
24
|
+
*/
|
|
25
|
+
review: string | null;
|
|
26
|
+
plan: string | null;
|
|
27
|
+
/** How the `ARBOR.md` departs from the shape the skill prescribes. */
|
|
28
|
+
planProblems: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ShowOptions {
|
|
32
|
+
/** Print the details as JSON instead of prose. */
|
|
33
|
+
json?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One task in full: what `list` shows for it, plus the `ARBOR.md` its agent
|
|
38
|
+
* left at the worktree root. Reading a tree this way takes no lease, so it
|
|
39
|
+
* cannot knock the agent driving it off its own work.
|
|
40
|
+
*/
|
|
41
|
+
export async function show(
|
|
42
|
+
{ service, fs, log }: { service: WorktreeService; fs: Fs; log: Logger },
|
|
43
|
+
task: string,
|
|
44
|
+
opts: ShowOptions = {},
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const worktree = await service.find(task);
|
|
47
|
+
if (worktree.gone) {
|
|
48
|
+
fail(
|
|
49
|
+
"not_found",
|
|
50
|
+
`no task '${task}': run \`arbor list\` to see what there is`,
|
|
51
|
+
{ task },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const details = await new TaskDetails({ fs }).get(worktree);
|
|
55
|
+
|
|
56
|
+
if (opts.json ?? false) {
|
|
57
|
+
log.info(JSON.stringify(details, null, "\t"));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
log.info(report(details));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Configures how {@link TaskDetails} gathers a task's info. */
|
|
64
|
+
export interface TaskDetailsOptions {
|
|
65
|
+
/** What the worktree is read through; the real filesystem by default. */
|
|
66
|
+
fs?: Fs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything there is to say about one task. Split out so `dev` can render the
|
|
71
|
+
* same fields this prints, rather than assembling its own and drifting.
|
|
72
|
+
*/
|
|
73
|
+
export class TaskDetails {
|
|
74
|
+
private readonly fs: Fs;
|
|
75
|
+
|
|
76
|
+
constructor(opts: TaskDetailsOptions = {}) {
|
|
77
|
+
this.fs = opts.fs ?? new NodeFs();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async get(worktree: Worktree): Promise<Details> {
|
|
81
|
+
const { state } = worktree;
|
|
82
|
+
// A record whose worktree or branch is gone is still worth showing: the
|
|
83
|
+
// status names what is wrong, and asking git about a branch that is not
|
|
84
|
+
// there would only fill the fields with nulls.
|
|
85
|
+
const stat = worktree.hasBranch ? await worktree.diffStat() : null;
|
|
86
|
+
const plan = worktree.exists
|
|
87
|
+
? await this.fs.read(`${worktree.path}/${PLAN_FILE}`).catch(() => null)
|
|
88
|
+
: null;
|
|
89
|
+
return {
|
|
90
|
+
task: worktree.task,
|
|
91
|
+
status: worktree.status,
|
|
92
|
+
branch: worktree.branch,
|
|
93
|
+
base: worktree.base,
|
|
94
|
+
worktree: worktree.path,
|
|
95
|
+
lease: worktree.leaseStatus,
|
|
96
|
+
ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
|
|
97
|
+
added: stat?.added ?? null,
|
|
98
|
+
removed: stat?.removed ?? null,
|
|
99
|
+
age: state ? age(state.createdAt) : null,
|
|
100
|
+
escalation: state?.escalations?.at(-1)?.reason ?? null,
|
|
101
|
+
review:
|
|
102
|
+
state?.status === "escalated"
|
|
103
|
+
? (state.escalations?.at(-1)?.review ?? null)
|
|
104
|
+
: null,
|
|
105
|
+
plan,
|
|
106
|
+
planProblems:
|
|
107
|
+
plan === null
|
|
108
|
+
? []
|
|
109
|
+
: checkPlan(plan, {
|
|
110
|
+
task: worktree.task,
|
|
111
|
+
escalated: worktree.status === "escalated",
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function report(details: Details): string {
|
|
118
|
+
const lines = [
|
|
119
|
+
`${color.bold(details.task)} ${details.status}`,
|
|
120
|
+
` branch: ${details.branch}`,
|
|
121
|
+
` base: ${details.base}`,
|
|
122
|
+
` worktree: ${details.worktree}`,
|
|
123
|
+
` lease: ${details.lease}`,
|
|
124
|
+
` ahead: ${details.ahead === null ? "?" : details.ahead} ${diff(details)}`,
|
|
125
|
+
` age: ${details.age ?? "?"}`,
|
|
126
|
+
];
|
|
127
|
+
if (details.escalation !== null) {
|
|
128
|
+
lines.push(` ${color.yellow(`escalated: ${details.escalation}`)}`);
|
|
129
|
+
}
|
|
130
|
+
if (details.plan !== null) {
|
|
131
|
+
lines.push("", color.bold(PLAN_FILE), details.plan.trimEnd());
|
|
132
|
+
for (const problem of details.planProblems) {
|
|
133
|
+
lines.push(color.yellow(` ${problem}`));
|
|
134
|
+
}
|
|
135
|
+
} else if (details.status !== "orphaned") {
|
|
136
|
+
lines.push(
|
|
137
|
+
"",
|
|
138
|
+
color.yellow(
|
|
139
|
+
`no ${PLAN_FILE}: whoever picks this up starts from the diff`,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function diff(details: Details): string {
|
|
147
|
+
if (details.added === null || details.removed === null) {
|
|
148
|
+
return "?";
|
|
149
|
+
}
|
|
150
|
+
return `${color.green(`+${details.added}`)} ${color.red(`-${details.removed}`)}`;
|
|
151
|
+
}
|
package/snapshot.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { snapshot } from "./snapshot";
|
|
4
|
+
import { Testing } from "./testing";
|
|
5
|
+
|
|
6
|
+
describe("snapshot", () => {
|
|
7
|
+
let deps: Testing;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
deps = await Testing.open();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => deps.disposeAsync());
|
|
14
|
+
|
|
15
|
+
it("writes a repo under home with ~", async () => {
|
|
16
|
+
const { path } = await snapshot({ ...deps, home: dirname(deps.root) });
|
|
17
|
+
|
|
18
|
+
expect(path).toBe(`~/${basename(deps.root)}`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("writes home itself as ~", async () => {
|
|
22
|
+
const { path } = await snapshot({ ...deps, home: deps.root });
|
|
23
|
+
|
|
24
|
+
expect(path).toBe("~");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves a repo outside home, or one only sharing its prefix, whole", async () => {
|
|
28
|
+
const { path } = await snapshot({ ...deps, home: deps.root.slice(0, -1) });
|
|
29
|
+
|
|
30
|
+
expect(path).toBe(deps.root);
|
|
31
|
+
expect((await snapshot(deps)).path).toBe(deps.root);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/snapshot.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { Fs } from "webappwiz/system";
|
|
3
|
+
import { type Details, TaskDetails } from "./show";
|
|
4
|
+
import type { TodoState, Todos } from "./todo";
|
|
5
|
+
import type { WorktreeService } from "./worktree-service";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything one page shows: `todo list`, and `list` and `show` for each
|
|
9
|
+
* task.
|
|
10
|
+
*/
|
|
11
|
+
export interface Snapshot {
|
|
12
|
+
/** The repository's directory name, so a page among many says whose it is. */
|
|
13
|
+
repo: string;
|
|
14
|
+
/** Where the repository sits, with the home directory as `~`. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Past this age a todo is offered for removal rather than recommended. */
|
|
17
|
+
todoStalenessMs: number;
|
|
18
|
+
todos: TodoState[];
|
|
19
|
+
tasks: Details[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reads the whole repo the way the CLI's read commands do. Takes no lease, so
|
|
24
|
+
* the page can sit open on a live tree without disturbing the agent in it.
|
|
25
|
+
*/
|
|
26
|
+
export async function snapshot({
|
|
27
|
+
service,
|
|
28
|
+
todos,
|
|
29
|
+
fs,
|
|
30
|
+
home,
|
|
31
|
+
}: {
|
|
32
|
+
service: WorktreeService;
|
|
33
|
+
todos: Todos;
|
|
34
|
+
fs: Fs;
|
|
35
|
+
/** The home directory to show as `~`, when there is one. */
|
|
36
|
+
home?: string;
|
|
37
|
+
}): Promise<Snapshot> {
|
|
38
|
+
const details = new TaskDetails({ fs });
|
|
39
|
+
const tasks: Details[] = [];
|
|
40
|
+
for (const worktree of await service.list()) {
|
|
41
|
+
tasks.push(await details.get(worktree));
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
repo: basename(service.git.root),
|
|
45
|
+
path: tilde(service.git.root, home),
|
|
46
|
+
todoStalenessMs: service.config.todoStalenessMs,
|
|
47
|
+
todos: (await todos.all()).map((todo) => todo.state),
|
|
48
|
+
tasks,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the page is actually showing, minus the fields that move on their own.
|
|
54
|
+
* `age` ticks every minute, and hashing it would push to every open page for
|
|
55
|
+
* nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function fingerprint({ todos, tasks }: Snapshot): string {
|
|
58
|
+
return JSON.stringify([
|
|
59
|
+
todos,
|
|
60
|
+
tasks.map((task) => [
|
|
61
|
+
task.task,
|
|
62
|
+
task.status,
|
|
63
|
+
task.lease,
|
|
64
|
+
task.ahead,
|
|
65
|
+
task.added,
|
|
66
|
+
task.removed,
|
|
67
|
+
task.escalation,
|
|
68
|
+
task.review,
|
|
69
|
+
task.plan,
|
|
70
|
+
task.planProblems,
|
|
71
|
+
]),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `root` with a leading `home` written as `~`, the way a shell prompt shows it. */
|
|
76
|
+
function tilde(root: string, home: string | undefined): string {
|
|
77
|
+
if (!home) {
|
|
78
|
+
return root;
|
|
79
|
+
}
|
|
80
|
+
if (root === home) {
|
|
81
|
+
return "~";
|
|
82
|
+
}
|
|
83
|
+
return root.startsWith(`${home}/`) ? `~${root.slice(home.length)}` : root;
|
|
84
|
+
}
|
package/table.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { color } from "webappwiz/log";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Columns padded to their widest cell. Padding goes by visible width, not
|
|
5
|
+
* string length: cells carry color codes.
|
|
6
|
+
*/
|
|
7
|
+
export function table(header: string[], rows: string[][]): string {
|
|
8
|
+
const width = (cell: string): number => color.strip(cell).length;
|
|
9
|
+
const widths = header.map((heading, i) =>
|
|
10
|
+
Math.max(heading.length, ...rows.map((row) => width(row[i] ?? ""))),
|
|
11
|
+
);
|
|
12
|
+
const line = (cells: string[]): string =>
|
|
13
|
+
cells
|
|
14
|
+
.map((cell, i) =>
|
|
15
|
+
cell.padEnd((widths[i] ?? 0) + cell.length - width(cell)),
|
|
16
|
+
)
|
|
17
|
+
.join(" ")
|
|
18
|
+
.trimEnd();
|
|
19
|
+
return [color.dim(line(header)), ...rows.map(line)].join("\n");
|
|
20
|
+
}
|
package/testing.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { expect } from "bun:test";
|
|
2
|
+
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CounterIdProvider } from "webappwiz/id";
|
|
6
|
+
import { color, MemoryLogger } from "webappwiz/log";
|
|
7
|
+
import { FileLock, NodeFs, NodePs } from "webappwiz/system";
|
|
8
|
+
import { FakeProcess } from "webappwiz/system/testing";
|
|
9
|
+
import type { Config } from "./config";
|
|
10
|
+
import { assets } from "./dev/assets";
|
|
11
|
+
import { Exit, type Reason } from "./exit";
|
|
12
|
+
import { Git } from "./git";
|
|
13
|
+
import { Journal } from "./journal";
|
|
14
|
+
import { Shell } from "./shell";
|
|
15
|
+
import { Todos } from "./todo";
|
|
16
|
+
import { WorktreeService } from "./worktree-service";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A throwaway git repo with one commit on `main`, holding every dependency an
|
|
20
|
+
* arbor command runs against, wired to it. A command takes the whole thing as
|
|
21
|
+
* its deps: `await add(deps, "alpha")`.
|
|
22
|
+
*
|
|
23
|
+
* It coordinates the dependencies and nothing else. What a test is about, the
|
|
24
|
+
* tasks, the commits, the config it needs to differ, the test does itself.
|
|
25
|
+
*/
|
|
26
|
+
export class Testing implements AsyncDisposable {
|
|
27
|
+
readonly root: string;
|
|
28
|
+
readonly arborDir: string;
|
|
29
|
+
readonly fs = new NodeFs();
|
|
30
|
+
/** Spawns for real; its exits and signals are the fake process's to keep. */
|
|
31
|
+
readonly proc = new FakeProcess();
|
|
32
|
+
readonly ps: NodePs;
|
|
33
|
+
readonly log = new MemoryLogger();
|
|
34
|
+
/** Stored file names count up from 0, so a test can name them. */
|
|
35
|
+
readonly ids = new CounterIdProvider();
|
|
36
|
+
// The real ones, which publish: `dev` is tested by serving a page and
|
|
37
|
+
// fetching it, and asserting on what comes back is the point.
|
|
38
|
+
readonly assets = assets;
|
|
39
|
+
/** Arbitrary but valid settings; a test changes what it cares about. */
|
|
40
|
+
readonly config: Config;
|
|
41
|
+
readonly git: Git;
|
|
42
|
+
readonly service: WorktreeService;
|
|
43
|
+
readonly shell: Shell;
|
|
44
|
+
readonly journal: Journal;
|
|
45
|
+
readonly lockPath: string;
|
|
46
|
+
readonly lock: FileLock;
|
|
47
|
+
readonly todos: Todos;
|
|
48
|
+
|
|
49
|
+
private constructor(private readonly base: string) {
|
|
50
|
+
this.root = join(base, "repo");
|
|
51
|
+
this.arborDir = join(this.root, ".git", "arbor");
|
|
52
|
+
this.ps = new NodePs({ proc: this.proc });
|
|
53
|
+
this.config = {
|
|
54
|
+
trunk: "main",
|
|
55
|
+
worktreeRoot: `${this.root}-arbor`,
|
|
56
|
+
postCheckout: null,
|
|
57
|
+
postRewrite: null,
|
|
58
|
+
// Keeps merge's gate green unless a test asks otherwise.
|
|
59
|
+
preMerge: "true",
|
|
60
|
+
postMerge: null,
|
|
61
|
+
leaseStalenessMs: 90_000,
|
|
62
|
+
removedCapacity: 50,
|
|
63
|
+
mergeRetryCount: 2,
|
|
64
|
+
logCapacity: 200,
|
|
65
|
+
todoStalenessMs: 30 * 24 * 60 * 60 * 1000,
|
|
66
|
+
};
|
|
67
|
+
this.git = new Git(this.root, { ps: this.ps, fs: this.fs });
|
|
68
|
+
this.service = new WorktreeService(this.git, this.config, this.arborDir, {
|
|
69
|
+
fs: this.fs,
|
|
70
|
+
ps: this.ps,
|
|
71
|
+
});
|
|
72
|
+
this.shell = new Shell({ ps: this.ps });
|
|
73
|
+
this.journal = new Journal(
|
|
74
|
+
join(this.arborDir, "log.jsonl"),
|
|
75
|
+
this.config.logCapacity,
|
|
76
|
+
{ fs: this.fs },
|
|
77
|
+
);
|
|
78
|
+
this.lockPath = join(this.arborDir, "merge.lock");
|
|
79
|
+
this.lock = new FileLock(this.lockPath, {
|
|
80
|
+
fs: this.fs,
|
|
81
|
+
ps: this.ps,
|
|
82
|
+
log: this.log,
|
|
83
|
+
stalenessMs: this.config.leaseStalenessMs,
|
|
84
|
+
});
|
|
85
|
+
this.todos = new Todos(
|
|
86
|
+
join(this.arborDir, "todos"),
|
|
87
|
+
new FileLock(join(this.arborDir, "todos.lock"), {
|
|
88
|
+
fs: this.fs,
|
|
89
|
+
ps: this.ps,
|
|
90
|
+
log: this.log,
|
|
91
|
+
}),
|
|
92
|
+
{ fs: this.fs, ids: this.ids },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A repo of its own, seeded and ready for a command. */
|
|
97
|
+
static async open(): Promise<Testing> {
|
|
98
|
+
const base = await realpath(await mkdtemp(join(tmpdir(), "arbor-")));
|
|
99
|
+
const testing = new Testing(base);
|
|
100
|
+
await testing.seed();
|
|
101
|
+
return testing;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Runs git in `cwd`, named apart from the `git` a command is handed. */
|
|
105
|
+
async gitCli(cwd: string, ...args: string[]): Promise<string> {
|
|
106
|
+
const { exitCode, stdout, stderr } = await this.ps.spawnCapture([
|
|
107
|
+
"git",
|
|
108
|
+
"-C",
|
|
109
|
+
cwd,
|
|
110
|
+
...args,
|
|
111
|
+
]);
|
|
112
|
+
if (exitCode !== 0) {
|
|
113
|
+
throw new Error(`git ${args.join(" ")}: ${stderr}`);
|
|
114
|
+
}
|
|
115
|
+
return stdout.trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Writes a file in `cwd` and commits it. */
|
|
119
|
+
async commit(
|
|
120
|
+
cwd: string,
|
|
121
|
+
file: string,
|
|
122
|
+
content: string,
|
|
123
|
+
message: string,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await this.fs.write(join(cwd, file), content);
|
|
126
|
+
await this.gitCli(cwd, "add", file);
|
|
127
|
+
await this.gitCli(cwd, "commit", "-m", message);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Everything logged so far, one line each, without colour. */
|
|
131
|
+
out(): string {
|
|
132
|
+
return color.strip(
|
|
133
|
+
this.log.entries.map((entry) => String(entry.message)).join("\n"),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Deletes the repo, its worktrees and the temporary directory holding them. */
|
|
138
|
+
disposeAsync(): Promise<void> {
|
|
139
|
+
return rm(this.base, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// so a concurrent test can own its repo with `await using`, instead of
|
|
143
|
+
// afterEach handing every test in the file the same one
|
|
144
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
145
|
+
return this.disposeAsync();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async seed(): Promise<void> {
|
|
149
|
+
await this.fs.mkdir(this.root);
|
|
150
|
+
await this.gitCli(this.root, "init", "-b", "main");
|
|
151
|
+
await this.gitCli(this.root, "config", "user.email", "arbor@example.com");
|
|
152
|
+
await this.gitCli(this.root, "config", "user.name", "arbor");
|
|
153
|
+
await this.gitCli(this.root, "config", "commit.gpgsign", "false");
|
|
154
|
+
await this.commit(this.root, "README.md", "seed\n", "seed");
|
|
155
|
+
await this.service.init();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** pid 1 always exists, so it stands in for another agent that is still running. */
|
|
160
|
+
export const LIVE_PID = 1;
|
|
161
|
+
|
|
162
|
+
/** What a refusal carries beyond its reason, when a test says. */
|
|
163
|
+
export interface BailOptions {
|
|
164
|
+
/** A fragment of the message the person reading it gets, or every one. */
|
|
165
|
+
message?: string | string[];
|
|
166
|
+
/** Everything the refusal reports alongside the reason. */
|
|
167
|
+
data?: Record<string, unknown>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
declare module "bun:test" {
|
|
171
|
+
interface Matchers<T> {
|
|
172
|
+
/** Asserts the work refuses: `await expect(add(t, "x")).toBail("usage")`. */
|
|
173
|
+
toBail(reason: Reason, expected?: BailOptions): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Registered here because every test file that asserts a refusal already
|
|
178
|
+
// imports `Testing`, and `expect.extend` has to have run before the first one.
|
|
179
|
+
expect.extend({
|
|
180
|
+
async toBail(work, reason, expected = {}) {
|
|
181
|
+
const exit = await refusal(work);
|
|
182
|
+
if (exit === null) {
|
|
183
|
+
return {
|
|
184
|
+
pass: false,
|
|
185
|
+
message: () => `expected a ${reason} refusal, but it succeeded`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (exit.reason !== reason) {
|
|
189
|
+
return {
|
|
190
|
+
pass: false,
|
|
191
|
+
message: () =>
|
|
192
|
+
`expected a ${reason} refusal, got ${exit.reason}: ${exit.message}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const fragments = [expected.message ?? []].flat();
|
|
196
|
+
const missing = fragments.filter((text) => !exit.message.includes(text));
|
|
197
|
+
if (missing.length > 0) {
|
|
198
|
+
return {
|
|
199
|
+
pass: false,
|
|
200
|
+
message: () =>
|
|
201
|
+
`expected the ${reason} message to contain ${this.utils.printExpected(missing)}, got ${this.utils.printReceived(exit.message)}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (expected.data !== undefined && !this.equals(exit.data, expected.data)) {
|
|
205
|
+
return {
|
|
206
|
+
pass: false,
|
|
207
|
+
message: () =>
|
|
208
|
+
`expected the ${reason} data to be ${this.utils.printExpected(expected.data)}, got ${this.utils.printReceived(exit.data)}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { pass: true, message: () => `expected no ${reason} refusal` };
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The `Exit` some work refused with, or null when it did not refuse. Anything
|
|
217
|
+
* else it threw is a real failure and is left to blow up the test as itself.
|
|
218
|
+
*/
|
|
219
|
+
async function refusal(work: unknown): Promise<Exit | null> {
|
|
220
|
+
try {
|
|
221
|
+
await work;
|
|
222
|
+
return null;
|
|
223
|
+
} catch (entry) {
|
|
224
|
+
if (entry instanceof Exit) {
|
|
225
|
+
return entry;
|
|
226
|
+
}
|
|
227
|
+
throw entry;
|
|
228
|
+
}
|
|
229
|
+
}
|