@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/dev.test.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { OpenPortProvider } from "webappwiz/system";
|
|
3
|
+
import { add } from "./add";
|
|
4
|
+
import { dev, devPorts } from "./dev";
|
|
5
|
+
import type { Snapshot } from "./snapshot";
|
|
6
|
+
import { Testing } from "./testing";
|
|
7
|
+
|
|
8
|
+
async function readUntil(
|
|
9
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
10
|
+
needle: string,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
let text = "";
|
|
13
|
+
while (!text.includes(needle)) {
|
|
14
|
+
const { value, done } = await reader.read();
|
|
15
|
+
if (done) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
text += new TextDecoder().decode(value);
|
|
19
|
+
}
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("dev", () => {
|
|
24
|
+
let deps: Testing;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
deps = await Testing.open();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => deps.disposeAsync());
|
|
31
|
+
|
|
32
|
+
/** Any port, so concurrent test files cannot collide on a fixed one. */
|
|
33
|
+
const serving = async (
|
|
34
|
+
body: (snapshot: () => Promise<Snapshot>, port: number) => Promise<void>,
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
const server = await dev(deps, { ports: OpenPortProvider.any() });
|
|
37
|
+
try {
|
|
38
|
+
await body(
|
|
39
|
+
async () =>
|
|
40
|
+
(await (
|
|
41
|
+
await fetch(`http://localhost:${server.port}/api/snapshot`)
|
|
42
|
+
).json()) as Snapshot,
|
|
43
|
+
server.port,
|
|
44
|
+
);
|
|
45
|
+
} finally {
|
|
46
|
+
await server.disposeAsync();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("moves up to an open port when the one it asks for is taken", async () => {
|
|
51
|
+
const held = await dev(deps, { ports: OpenPortProvider.any() });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const server = await dev(deps, { ports: devPorts(held.port) });
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
expect(server.port).toBeGreaterThan(held.port);
|
|
58
|
+
} finally {
|
|
59
|
+
await server.disposeAsync();
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await held.disposeAsync();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("serves each task's fields and its ARBOR.md as data", async () => {
|
|
67
|
+
await deps.journal.record("add", "alpha", () => add(deps, "alpha"));
|
|
68
|
+
const alpha = (await deps.service.find("alpha")).path;
|
|
69
|
+
await deps.fs.write(
|
|
70
|
+
`${alpha}/ARBOR.md`,
|
|
71
|
+
"# alpha\n\n## Goal\nland it\n\n## Next\n- [ ] the rest\n",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
await serving(async (snapshot) => {
|
|
75
|
+
const { tasks } = await snapshot();
|
|
76
|
+
|
|
77
|
+
expect(tasks).toHaveLength(1);
|
|
78
|
+
expect(tasks[0]?.task).toBe("alpha");
|
|
79
|
+
expect(tasks[0]?.branch).toBe("task/alpha");
|
|
80
|
+
expect(tasks[0]?.status).toBe("working");
|
|
81
|
+
expect(tasks[0]?.plan).toContain("- [ ] the rest");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reports an escalated task with the reason a person has to read", async () => {
|
|
86
|
+
await add(deps, "alpha");
|
|
87
|
+
await (await deps.service.find("alpha")).save({
|
|
88
|
+
status: "escalated",
|
|
89
|
+
escalations: [{ reason: "needs a human", at: new Date().toISOString() }],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await serving(async (snapshot) => {
|
|
93
|
+
const { tasks } = await snapshot();
|
|
94
|
+
|
|
95
|
+
expect(tasks[0]?.status).toBe("escalated");
|
|
96
|
+
expect(tasks[0]?.escalation).toBe("needs a human");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("reports a task whose worktree is gone as orphaned", async () => {
|
|
101
|
+
await add(deps, "alpha");
|
|
102
|
+
await deps.fs.rm((await deps.service.find("alpha")).path, {
|
|
103
|
+
recursive: true,
|
|
104
|
+
force: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await serving(async (snapshot) => {
|
|
108
|
+
expect((await snapshot()).tasks[0]?.status).toBe("orphaned");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("serves the ARBOR.md verbatim, leaving the page to render it", async () => {
|
|
113
|
+
await add(deps, "alpha");
|
|
114
|
+
const alpha = (await deps.service.find("alpha")).path;
|
|
115
|
+
await deps.fs.write(
|
|
116
|
+
`${alpha}/ARBOR.md`,
|
|
117
|
+
"# alpha\n\n## Next\n- [ ] drop <script>alert(1)</script>\n",
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await serving(async (snapshot) => {
|
|
121
|
+
// The server is a data source now: escaping is React's job, and a
|
|
122
|
+
// server that pre-escaped would double-escape once it got there.
|
|
123
|
+
expect((await snapshot()).tasks[0]?.plan).toContain(
|
|
124
|
+
"<script>alert(1)</script>",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("serves the page the browser asks for its assets from", async () => {
|
|
130
|
+
await add(deps, "alpha");
|
|
131
|
+
|
|
132
|
+
await serving(async (_snapshot, port) => {
|
|
133
|
+
const html = await (await fetch(`http://localhost:${port}/`)).text();
|
|
134
|
+
|
|
135
|
+
expect(html).toContain("<title>arbor</title>");
|
|
136
|
+
expect(html).toContain(`<div id="root">`);
|
|
137
|
+
expect(html).toContain(`href="/styles.css"`);
|
|
138
|
+
expect(html).toContain(`src="/main.js"`);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("answers a path it does not serve with a 404", async () => {
|
|
143
|
+
await add(deps, "alpha");
|
|
144
|
+
|
|
145
|
+
await serving(async (_snapshot, port) => {
|
|
146
|
+
const response = await fetch(`http://localhost:${port}/nope`);
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(404);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("serves the React app already bundled, with nothing left to fetch", async () => {
|
|
153
|
+
await add(deps, "alpha");
|
|
154
|
+
|
|
155
|
+
await serving(async (_snapshot, port) => {
|
|
156
|
+
const response = await fetch(`http://localhost:${port}/main.js`);
|
|
157
|
+
const js = await response.text();
|
|
158
|
+
|
|
159
|
+
expect(response.headers.get("content-type")).toContain("text/javascript");
|
|
160
|
+
// React is in the file rather than imported from anywhere, which is what
|
|
161
|
+
// makes the page work with no network and no import map.
|
|
162
|
+
expect(js).not.toContain('from "react"');
|
|
163
|
+
// A name like `createRoot` can be minified away; the symbol React tags
|
|
164
|
+
// its elements with is a string, and stays.
|
|
165
|
+
expect(js).toContain("react.transitional.element");
|
|
166
|
+
// The page's own markup reached the bundle, so this is the app and not
|
|
167
|
+
// an empty entry module that failed to pull anything in.
|
|
168
|
+
expect(js).toContain("arbor");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("serves the stylesheet Tailwind compiled, not the import that asks for it", async () => {
|
|
173
|
+
await add(deps, "alpha");
|
|
174
|
+
|
|
175
|
+
await serving(async (_snapshot, port) => {
|
|
176
|
+
const css = await (
|
|
177
|
+
await fetch(`http://localhost:${port}/styles.css`)
|
|
178
|
+
).text();
|
|
179
|
+
|
|
180
|
+
// Either at-rule surviving into the output means Tailwind did not run,
|
|
181
|
+
// and the page would come out with no classes at all.
|
|
182
|
+
expect(css).not.toContain('@import "tailwindcss"');
|
|
183
|
+
expect(css).not.toContain("@tailwind utilities");
|
|
184
|
+
expect(css).toContain("color-scheme: dark");
|
|
185
|
+
// Real utilities, not just the theme block: Tailwind emits only the
|
|
186
|
+
// classes it can see, and it compiles happily to nothing at all when it
|
|
187
|
+
// is pointed at no sources. One class from the page and one from the
|
|
188
|
+
// `Markdown` component beside it, since they are found by
|
|
189
|
+
// separate `@source` lines.
|
|
190
|
+
expect(css).toContain("max-w-xl");
|
|
191
|
+
expect(css).toContain("list-disc");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("names every file of the page as a source for Tailwind", async () => {
|
|
196
|
+
const dir = `${import.meta.dir}/dev`;
|
|
197
|
+
const styles = await Bun.file(`${dir}/styles.css`).text();
|
|
198
|
+
const listed = [...styles.matchAll(/^@source "\.\/(.+\.tsx)";$/gm)].map(
|
|
199
|
+
([, file]) => file,
|
|
200
|
+
);
|
|
201
|
+
// A file left off compiles to a page missing its classes, with no error.
|
|
202
|
+
const pages = [...new Bun.Glob("*.tsx").scanSync(dir)].filter(
|
|
203
|
+
(file) => !file.includes(".test."),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// `main.tsx` only mounts the app, and has no classes to find.
|
|
207
|
+
expect(listed.sort()).toEqual(
|
|
208
|
+
pages.filter((file) => file !== "main.tsx").sort(),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("pushes over SSE when a task changes, and stays quiet when it does not", async () => {
|
|
213
|
+
await add(deps, "alpha");
|
|
214
|
+
|
|
215
|
+
await serving(async (_snapshot, port) => {
|
|
216
|
+
const events = await fetch(`http://localhost:${port}/events`);
|
|
217
|
+
const reader = (events.body ?? new ReadableStream()).getReader();
|
|
218
|
+
await add(deps, "beta");
|
|
219
|
+
|
|
220
|
+
// The poll only pushes on a change, so this drains the connect comment
|
|
221
|
+
// and then blocks until the new task lands. A server that pushed nothing
|
|
222
|
+
// fails here by timing the test out.
|
|
223
|
+
const sse = await readUntil(reader, "data: changed");
|
|
224
|
+
|
|
225
|
+
expect(sse).toContain("data: changed");
|
|
226
|
+
await reader.cancel();
|
|
227
|
+
});
|
|
228
|
+
}, 15_000);
|
|
229
|
+
|
|
230
|
+
describe("writes", () => {
|
|
231
|
+
/** A post the way the page makes one: same origin as the host it asked. */
|
|
232
|
+
const post = (port: number, path: string, body: BodyInit, headers = {}) =>
|
|
233
|
+
fetch(`http://127.0.0.1:${port}${path}`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { origin: `http://127.0.0.1:${port}`, ...headers },
|
|
236
|
+
body,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("serves a file a todo holds, and nothing else", async () => {
|
|
240
|
+
await add(deps, "alpha");
|
|
241
|
+
const tree = (await deps.service.find("alpha")).path;
|
|
242
|
+
await deps.fs.writeBytes(`${tree}/page.png`, new Uint8Array([7]));
|
|
243
|
+
const todo = await deps.todos.add("look", null, {
|
|
244
|
+
files: [
|
|
245
|
+
{ name: "shot.png", bytes: new Uint8Array([8]) },
|
|
246
|
+
{ name: "trace.log", bytes: new Uint8Array([9]) },
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await serving(async (_snapshot, port) => {
|
|
251
|
+
const file = (path: string) =>
|
|
252
|
+
fetch(
|
|
253
|
+
`http://127.0.0.1:${port}/api/file?${new URLSearchParams({ path })}`,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const shown = await file(todo.files[0] ?? "");
|
|
257
|
+
expect(shown.status).toBe(200);
|
|
258
|
+
expect(shown.headers.get("content-type")).toBe("image/png");
|
|
259
|
+
expect(new Uint8Array(await shown.arrayBuffer())).toEqual(
|
|
260
|
+
new Uint8Array([8]),
|
|
261
|
+
);
|
|
262
|
+
const stored = await file(todo.files[1] ?? "");
|
|
263
|
+
expect(stored.status).toBe(200);
|
|
264
|
+
expect(stored.headers.get("content-disposition")).toBe("attachment");
|
|
265
|
+
expect((await file(`${tree}/page.png`)).status).toBe(404);
|
|
266
|
+
expect((await file(`${deps.todos.dir}/1/../../../config`)).status).toBe(
|
|
267
|
+
404,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/** A todo write the way the page makes one. */
|
|
273
|
+
const todoForm = (
|
|
274
|
+
subject: string | null,
|
|
275
|
+
files: File[] = [],
|
|
276
|
+
keep: string[] = [],
|
|
277
|
+
) => {
|
|
278
|
+
const form = new FormData();
|
|
279
|
+
if (subject !== null) {
|
|
280
|
+
form.set("subject", subject);
|
|
281
|
+
}
|
|
282
|
+
for (const file of files) {
|
|
283
|
+
form.append("file", file);
|
|
284
|
+
}
|
|
285
|
+
for (const path of keep) {
|
|
286
|
+
form.append("keep", path);
|
|
287
|
+
}
|
|
288
|
+
return form;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
it("adds a todo, files and all", async () => {
|
|
292
|
+
await serving(async (snapshot, port) => {
|
|
293
|
+
await post(port, "/api/todos", todoForm("lower"));
|
|
294
|
+
const form = todoForm("write the docs", [new File(["x"], "notes.md")]);
|
|
295
|
+
form.set("text", "the CLI first");
|
|
296
|
+
form.set("position", "1");
|
|
297
|
+
const response = await post(port, "/api/todos", form);
|
|
298
|
+
|
|
299
|
+
expect(response.status).toBe(200);
|
|
300
|
+
const [todo, lower] = (await snapshot()).todos;
|
|
301
|
+
expect(todo).toMatchObject({
|
|
302
|
+
subject: "write the docs",
|
|
303
|
+
text: "the CLI first",
|
|
304
|
+
position: 1,
|
|
305
|
+
});
|
|
306
|
+
expect(todo?.files[0]).toEndWith("-notes.md");
|
|
307
|
+
expect(lower).toMatchObject({ subject: "lower", position: 2 });
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("moves a todo, leaving its words and files alone", async () => {
|
|
312
|
+
await deps.todos.add("first", null);
|
|
313
|
+
await deps.todos.add("second", null, {
|
|
314
|
+
files: [{ name: "a.png", bytes: new Uint8Array([1]) }],
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await serving(async (snapshot, port) => {
|
|
318
|
+
const move = (path: string, position: unknown) =>
|
|
319
|
+
fetch(`http://127.0.0.1:${port}${path}`, {
|
|
320
|
+
method: "PUT",
|
|
321
|
+
headers: {
|
|
322
|
+
origin: `http://127.0.0.1:${port}`,
|
|
323
|
+
"content-type": "application/json",
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify({ position }),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect((await move("/api/todos/2/position", 1)).status).toBe(200);
|
|
329
|
+
const [top] = (await snapshot()).todos;
|
|
330
|
+
expect(top).toMatchObject({ id: 2, subject: "second", position: 1 });
|
|
331
|
+
expect(top?.files).toHaveLength(1);
|
|
332
|
+
expect((await move("/api/todos/2/position", 0)).status).toBe(400);
|
|
333
|
+
expect((await move("/api/todos/9/position", 1)).status).toBe(404);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("updates and removes a todo", async () => {
|
|
338
|
+
const todo = await deps.todos.add("write docs", null, {
|
|
339
|
+
files: [
|
|
340
|
+
{ name: "a.png", bytes: new Uint8Array([1]) },
|
|
341
|
+
{ name: "b.png", bytes: new Uint8Array([2]) },
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
await deps.todos.add("above", null);
|
|
345
|
+
const [kept, dropped] = todo.files;
|
|
346
|
+
|
|
347
|
+
await serving(async (snapshot, port) => {
|
|
348
|
+
const write = (method: string, path: string, body?: FormData) =>
|
|
349
|
+
fetch(`http://127.0.0.1:${port}${path}`, {
|
|
350
|
+
method,
|
|
351
|
+
headers: { origin: `http://127.0.0.1:${port}` },
|
|
352
|
+
body,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const form = todoForm("write the docs", [], [kept ?? ""]);
|
|
356
|
+
form.set("position", "2");
|
|
357
|
+
form.set("tags", "docs, dev-page");
|
|
358
|
+
const updated = await write("PATCH", "/api/todos/1", form);
|
|
359
|
+
expect(updated.status).toBe(200);
|
|
360
|
+
expect((await snapshot()).todos[1]).toMatchObject({
|
|
361
|
+
id: 1,
|
|
362
|
+
subject: "write the docs",
|
|
363
|
+
position: 2,
|
|
364
|
+
files: [kept],
|
|
365
|
+
tags: ["dev-page", "docs"],
|
|
366
|
+
});
|
|
367
|
+
expect(await deps.fs.exists(dropped ?? "")).toBe(false);
|
|
368
|
+
|
|
369
|
+
expect((await write("DELETE", "/api/todos/1")).status).toBe(200);
|
|
370
|
+
expect((await snapshot()).todos).toMatchObject([
|
|
371
|
+
{ subject: "above", position: 1 },
|
|
372
|
+
]);
|
|
373
|
+
expect(await deps.fs.exists(kept ?? "")).toBe(false);
|
|
374
|
+
expect((await write("DELETE", "/api/todos/1")).status).toBe(404);
|
|
375
|
+
expect((await write("DELETE", "/api/todos/nope")).status).toBe(400);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("refuses a write from another site", async () => {
|
|
380
|
+
await serving(async (_snapshot, port) => {
|
|
381
|
+
const response = await post(port, "/api/todos", todoForm("planted"), {
|
|
382
|
+
origin: "https://evil.example",
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(response.status).toBe(403);
|
|
386
|
+
expect(await deps.todos.all()).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("lists the paths a task's tree holds, for pointing an agent at one", async () => {
|
|
392
|
+
await add(deps, "alpha");
|
|
393
|
+
const tree = (await deps.service.find("alpha")).path;
|
|
394
|
+
await deps.fs.mkdir(`${tree}/src/lib`);
|
|
395
|
+
await deps.commit(tree, "src/lib/a.ts", "a\n", "add a");
|
|
396
|
+
await deps.fs.write(`${tree}/src/new.ts`, "new\n");
|
|
397
|
+
await deps.fs.write(`${tree}/.gitignore`, "*.log\n");
|
|
398
|
+
await deps.fs.write(`${tree}/debug.log`, "noise\n");
|
|
399
|
+
|
|
400
|
+
await serving(async (_snapshot, port) => {
|
|
401
|
+
const paths = (task: string) =>
|
|
402
|
+
fetch(
|
|
403
|
+
`http://127.0.0.1:${port}/api/paths?${new URLSearchParams({ task })}`,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const listed = (await (await paths("alpha")).json()) as string[];
|
|
407
|
+
expect(listed).toContain("src/");
|
|
408
|
+
expect(listed).toContain("src/lib/");
|
|
409
|
+
expect(listed).toContain("src/lib/a.ts");
|
|
410
|
+
// New but not ignored, so an agent may well want pointing at it.
|
|
411
|
+
expect(listed).toContain("src/new.ts");
|
|
412
|
+
expect(listed).not.toContain("debug.log");
|
|
413
|
+
// The main tree, where trunk is, has none of the task's work.
|
|
414
|
+
expect((await (await paths("")).json()) as string[]).not.toContain(
|
|
415
|
+
"src/lib/a.ts",
|
|
416
|
+
);
|
|
417
|
+
expect((await paths("nope")).status).toBe(404);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("refuses a host it was not told about, and serves one it was", async () => {
|
|
422
|
+
const server = await dev(deps, {
|
|
423
|
+
ports: OpenPortProvider.any(),
|
|
424
|
+
hosts: ["repo.arbor.example"],
|
|
425
|
+
});
|
|
426
|
+
try {
|
|
427
|
+
const as = (host: string) =>
|
|
428
|
+
fetch(`http://127.0.0.1:${server.port}/api/snapshot`, {
|
|
429
|
+
headers: { host },
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect((await as("rebound.example")).status).toBe(403);
|
|
433
|
+
expect((await as("repo.arbor.example")).status).toBe(200);
|
|
434
|
+
expect((await as(`localhost:${server.port}`)).status).toBe(200);
|
|
435
|
+
} finally {
|
|
436
|
+
await server.disposeAsync();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|