@fungi.computer/bonsai 0.0.0-stage → 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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +848 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/present.test.ts +27 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +47 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +719 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { act, createElement, Fragment } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
import {
|
|
7
|
+
QueryClient,
|
|
8
|
+
QueryClientProvider,
|
|
9
|
+
notifyManager,
|
|
10
|
+
useQuery,
|
|
11
|
+
} from "@tanstack/react-query";
|
|
12
|
+
import { useLive } from "../web/api.js";
|
|
13
|
+
import { RepoPage } from "../web/pages.js";
|
|
14
|
+
import { ChangePage } from "../web/change-page.js";
|
|
15
|
+
import { InsightTile } from "../web/insight-tiles.js";
|
|
16
|
+
import { OverviewBoard } from "../web/insights.js";
|
|
17
|
+
import { CodeTab } from "../web/code-tab.js";
|
|
18
|
+
import { ActivityFeed } from "../web/parts.js";
|
|
19
|
+
import { NetworkGraph } from "../web/network-graph.js";
|
|
20
|
+
import { ChangeGroups } from "../web/change-rows.js";
|
|
21
|
+
import { RouterProvider, Link, href, useRouter } from "../web/router.js";
|
|
22
|
+
import { connect } from "../web/transport.js";
|
|
23
|
+
import { createCapabilityRegistry } from "@fungi.computer/agar";
|
|
24
|
+
import { appMethods } from "@fungi.computer/agar/backend";
|
|
25
|
+
import type { Activity, ChangeDetail, JsonValue } from "../shared/contract.js";
|
|
26
|
+
|
|
27
|
+
const callMethod =
|
|
28
|
+
vi.fn<(method: string, input: string) => Promise<JsonValue>>();
|
|
29
|
+
|
|
30
|
+
let container: HTMLDivElement;
|
|
31
|
+
let root: ReturnType<typeof createRoot>;
|
|
32
|
+
let client: QueryClient;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
vi.stubGlobal("scrollTo", vi.fn());
|
|
36
|
+
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: vi.fn(),
|
|
39
|
+
});
|
|
40
|
+
vi.stubGlobal(
|
|
41
|
+
"matchMedia",
|
|
42
|
+
vi.fn(() => ({
|
|
43
|
+
matches: false,
|
|
44
|
+
addEventListener: vi.fn(),
|
|
45
|
+
removeEventListener: vi.fn(),
|
|
46
|
+
})),
|
|
47
|
+
);
|
|
48
|
+
vi.stubGlobal(
|
|
49
|
+
"ResizeObserver",
|
|
50
|
+
class {
|
|
51
|
+
observe = vi.fn();
|
|
52
|
+
disconnect = vi.fn();
|
|
53
|
+
unobserve = vi.fn();
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
const capabilities = createCapabilityRegistry();
|
|
57
|
+
capabilities.provide(appMethods, { call: callMethod });
|
|
58
|
+
connect({ capabilities });
|
|
59
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
60
|
+
notifyManager.setScheduler(queueMicrotask);
|
|
61
|
+
client = new QueryClient({
|
|
62
|
+
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
|
63
|
+
});
|
|
64
|
+
container = document.createElement("div");
|
|
65
|
+
document.body.appendChild(container);
|
|
66
|
+
root = createRoot(container);
|
|
67
|
+
});
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await act(async () => {
|
|
70
|
+
root.unmount();
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
});
|
|
73
|
+
connect(null);
|
|
74
|
+
client.clear();
|
|
75
|
+
container.remove();
|
|
76
|
+
vi.useRealTimers();
|
|
77
|
+
vi.unstubAllGlobals();
|
|
78
|
+
vi.resetAllMocks();
|
|
79
|
+
});
|
|
80
|
+
const render = async (child: ReturnType<typeof createElement>) => {
|
|
81
|
+
await act(async () => {
|
|
82
|
+
root.render(createElement(QueryClientProvider, { client }, child));
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function LiveProbe() {
|
|
88
|
+
const live = useLive();
|
|
89
|
+
return createElement("div", { "data-connected": live.connected }, live.error);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function detailFixture(): ChangeDetail {
|
|
93
|
+
return {
|
|
94
|
+
change: {
|
|
95
|
+
repo: "pond",
|
|
96
|
+
number: 1,
|
|
97
|
+
sourceRepo: "pond--ada",
|
|
98
|
+
sourceRef: "main",
|
|
99
|
+
targetRef: "main",
|
|
100
|
+
title: "Add ripples",
|
|
101
|
+
body: "",
|
|
102
|
+
openedBy: "ada",
|
|
103
|
+
openedKind: "agent",
|
|
104
|
+
createdAt: 1,
|
|
105
|
+
updatedAt: 1,
|
|
106
|
+
head: "a".repeat(40),
|
|
107
|
+
target: "b".repeat(40),
|
|
108
|
+
base: null,
|
|
109
|
+
ahead: 1,
|
|
110
|
+
behind: 0,
|
|
111
|
+
state: "open",
|
|
112
|
+
status: "ready",
|
|
113
|
+
mergeable: { allowed: true },
|
|
114
|
+
revision: 0,
|
|
115
|
+
conflict: null,
|
|
116
|
+
lease: null,
|
|
117
|
+
mergedCommit: null,
|
|
118
|
+
mergedBy: null,
|
|
119
|
+
closedBy: null,
|
|
120
|
+
},
|
|
121
|
+
actions: { approve: { allowed: true }, close: { allowed: true } },
|
|
122
|
+
review: { approvals: [], checks: [] },
|
|
123
|
+
commits: [],
|
|
124
|
+
diff: { files: [], additions: 0, deletions: 0, omittedFiles: 0 },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("Bonsai failed actions", () => {
|
|
129
|
+
it("surfaces a failed live poll and clears it after recovery", async () => {
|
|
130
|
+
vi.mocked(callMethod).mockRejectedValueOnce(new Error("transport failed"));
|
|
131
|
+
await render(createElement(LiveProbe));
|
|
132
|
+
expect(container.textContent).toContain("Live updates are unavailable");
|
|
133
|
+
expect(container.firstElementChild?.getAttribute("data-connected")).toBe(
|
|
134
|
+
"false",
|
|
135
|
+
);
|
|
136
|
+
vi.mocked(callMethod).mockResolvedValue({ events: [] });
|
|
137
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
138
|
+
expect(container.textContent).toBe("");
|
|
139
|
+
expect(container.firstElementChild?.getAttribute("data-connected")).toBe(
|
|
140
|
+
"true",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("announces a refused merge as an error, then clears it on a successful retry", async () => {
|
|
145
|
+
client.setQueryData(["agents"], { agents: [] });
|
|
146
|
+
const detail = detailFixture();
|
|
147
|
+
client.setQueryData(["change", "pond", 1], detail);
|
|
148
|
+
client.setQueryData(["changes", "pond"], { changes: [] });
|
|
149
|
+
client.setQueryData(["graph", "pond"], { nodes: [], lanes: [] });
|
|
150
|
+
let attempts = 0;
|
|
151
|
+
vi.mocked(callMethod).mockImplementation((method) => {
|
|
152
|
+
if (method === "changes.merge") {
|
|
153
|
+
attempts++;
|
|
154
|
+
if (attempts === 1) throw new Error("Merge permission refused.");
|
|
155
|
+
return Promise.resolve({
|
|
156
|
+
kind: "merged",
|
|
157
|
+
before: "b".repeat(40),
|
|
158
|
+
after: "c".repeat(40),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (method === "changes.get")
|
|
162
|
+
return Promise.resolve(z.json().parse(detail));
|
|
163
|
+
if (method === "changes.list") return Promise.resolve({ changes: [] });
|
|
164
|
+
if (method === "graph.get")
|
|
165
|
+
return Promise.resolve({ nodes: [], lanes: [] });
|
|
166
|
+
throw new Error(`Unexpected fixture method ${method}`);
|
|
167
|
+
});
|
|
168
|
+
await render(
|
|
169
|
+
createElement(
|
|
170
|
+
RouterProvider,
|
|
171
|
+
null,
|
|
172
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const button = Array.from(container.querySelectorAll("button")).find(
|
|
176
|
+
(value) => value.textContent.includes("Merge into"),
|
|
177
|
+
);
|
|
178
|
+
if (!button) throw new Error("Merge action missing");
|
|
179
|
+
await act(async () => {
|
|
180
|
+
button.click();
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
});
|
|
183
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
|
|
184
|
+
"Merge permission refused.",
|
|
185
|
+
);
|
|
186
|
+
expect(button.disabled).toBe(false);
|
|
187
|
+
expect(callMethod).toHaveBeenCalledWith(
|
|
188
|
+
"changes.merge",
|
|
189
|
+
JSON.stringify({ repo: "pond", number: 1, head: "a".repeat(40) }),
|
|
190
|
+
);
|
|
191
|
+
await act(async () => {
|
|
192
|
+
button.click();
|
|
193
|
+
await Promise.resolve();
|
|
194
|
+
});
|
|
195
|
+
expect(container.querySelector('[role="alert"]')).toBeNull();
|
|
196
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
197
|
+
"Merged as ccccccc.",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
function RouteProbe() {
|
|
203
|
+
const { route } = useRouter();
|
|
204
|
+
return createElement(
|
|
205
|
+
"output",
|
|
206
|
+
null,
|
|
207
|
+
route.page === "change"
|
|
208
|
+
? `${route.repo}#${String(route.number)}`
|
|
209
|
+
: route.page,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cacheChange(detail: ChangeDetail) {
|
|
214
|
+
client.setQueryData(["agents"], { agents: [] });
|
|
215
|
+
client.setQueryData(
|
|
216
|
+
["change", detail.change.repo, detail.change.number],
|
|
217
|
+
detail,
|
|
218
|
+
);
|
|
219
|
+
client.setQueryData(["changes", detail.change.repo], { changes: [] });
|
|
220
|
+
client.setQueryData(["graph", detail.change.repo], { nodes: [], lanes: [] });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
describe("numbered Change review", () => {
|
|
224
|
+
it("opens a numbered route with an encoded repository", async () => {
|
|
225
|
+
const to = href({ page: "change", repo: "pond/with space", number: 12 });
|
|
226
|
+
expect(to).toBe("#change/pond%2Fwith%20space/12");
|
|
227
|
+
await render(
|
|
228
|
+
createElement(
|
|
229
|
+
RouterProvider,
|
|
230
|
+
null,
|
|
231
|
+
createElement(
|
|
232
|
+
Fragment,
|
|
233
|
+
null,
|
|
234
|
+
createElement(Link, { to }, "Open Change"),
|
|
235
|
+
createElement(RouteProbe),
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
await act(async () => {
|
|
240
|
+
container.querySelector("a")?.click();
|
|
241
|
+
await Promise.resolve();
|
|
242
|
+
});
|
|
243
|
+
expect(container.querySelector("output")?.textContent).toBe(
|
|
244
|
+
"pond/with space#12",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("refreshes a pending merge until its access-change settlement is visible", async () => {
|
|
249
|
+
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
|
250
|
+
const detail = detailFixture();
|
|
251
|
+
detail.change.state = "merging";
|
|
252
|
+
detail.change.status = "merging";
|
|
253
|
+
detail.change.mergeable = { allowed: false, reason: "merging" };
|
|
254
|
+
detail.change.lease = {
|
|
255
|
+
id: "pending-merge",
|
|
256
|
+
by: "Levi",
|
|
257
|
+
head: detail.change.head,
|
|
258
|
+
at: 1,
|
|
259
|
+
};
|
|
260
|
+
cacheChange(detail);
|
|
261
|
+
const failed = {
|
|
262
|
+
...detail,
|
|
263
|
+
change: {
|
|
264
|
+
...detail.change,
|
|
265
|
+
state: "open",
|
|
266
|
+
status: "ready",
|
|
267
|
+
lease: null,
|
|
268
|
+
mergeFailure: "access_changed",
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
callMethod.mockImplementation((method) => {
|
|
272
|
+
if (method === "changes.get")
|
|
273
|
+
return Promise.resolve(z.json().parse(failed));
|
|
274
|
+
throw new Error(`Unexpected method ${method}`);
|
|
275
|
+
});
|
|
276
|
+
await render(
|
|
277
|
+
createElement(
|
|
278
|
+
RouterProvider,
|
|
279
|
+
null,
|
|
280
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
await act(async () => {
|
|
284
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
285
|
+
});
|
|
286
|
+
expect(container.textContent).toContain("Merge failed: access changed");
|
|
287
|
+
const reads = callMethod.mock.calls.filter(
|
|
288
|
+
([method]) => method === "changes.get",
|
|
289
|
+
).length;
|
|
290
|
+
expect(reads).toBe(1);
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
293
|
+
});
|
|
294
|
+
expect(
|
|
295
|
+
callMethod.mock.calls.filter(([method]) => method === "changes.get"),
|
|
296
|
+
).toHaveLength(reads);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("hides actions refused by server policy and an empty Review", async () => {
|
|
300
|
+
const detail = detailFixture();
|
|
301
|
+
detail.actions = {
|
|
302
|
+
approve: { allowed: false, reason: "self-approval" },
|
|
303
|
+
close: { allowed: false, reason: "not-authorized" },
|
|
304
|
+
};
|
|
305
|
+
cacheChange(detail);
|
|
306
|
+
await render(
|
|
307
|
+
createElement(
|
|
308
|
+
RouterProvider,
|
|
309
|
+
null,
|
|
310
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
expect(container.querySelector("h1")?.textContent).toBe("#1 Add ripples");
|
|
314
|
+
expect(
|
|
315
|
+
[...container.querySelectorAll("button")].some(
|
|
316
|
+
(button) =>
|
|
317
|
+
button.textContent === "Approve" || button.textContent === "Close",
|
|
318
|
+
),
|
|
319
|
+
).toBe(false);
|
|
320
|
+
expect(container.querySelector('[data-tile="review"]')).toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("counts only current-head checks and explains earlier approvals", async () => {
|
|
324
|
+
const detail = detailFixture();
|
|
325
|
+
detail.review = {
|
|
326
|
+
approvals: [
|
|
327
|
+
{ actor: "Levi", kind: "human", head: "d".repeat(40), at: 1 },
|
|
328
|
+
],
|
|
329
|
+
checks: [
|
|
330
|
+
{
|
|
331
|
+
head: detail.change.head,
|
|
332
|
+
name: "tests",
|
|
333
|
+
status: "passed",
|
|
334
|
+
reporter: "Luna",
|
|
335
|
+
attemptId: "now",
|
|
336
|
+
runUrl: "https://example.test/run/1",
|
|
337
|
+
summary: null,
|
|
338
|
+
updatedAt: 1,
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
head: "d".repeat(40),
|
|
342
|
+
name: "tests",
|
|
343
|
+
status: "failed",
|
|
344
|
+
reporter: "Luna",
|
|
345
|
+
attemptId: "old",
|
|
346
|
+
runUrl: null,
|
|
347
|
+
summary: null,
|
|
348
|
+
updatedAt: 0,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
cacheChange(detail);
|
|
353
|
+
await render(
|
|
354
|
+
createElement(
|
|
355
|
+
RouterProvider,
|
|
356
|
+
null,
|
|
357
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
const review = container.querySelector('[data-tile="review"]');
|
|
361
|
+
expect(review?.textContent).toContain("1 of 1 passed");
|
|
362
|
+
expect(review?.textContent).toContain(
|
|
363
|
+
"Levi approved ddddddd · before the latest push",
|
|
364
|
+
);
|
|
365
|
+
expect(
|
|
366
|
+
review?.querySelector('a[href="https://example.test/run/1"]')
|
|
367
|
+
?.textContent,
|
|
368
|
+
).toBe("Run ›");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it.each(["approved", "moved", "refused"] as const)(
|
|
372
|
+
"pins approval to the displayed head and handles %s",
|
|
373
|
+
async (kind) => {
|
|
374
|
+
const detail = detailFixture();
|
|
375
|
+
cacheChange(detail);
|
|
376
|
+
callMethod.mockImplementation((method) => {
|
|
377
|
+
if (method === "changes.approve")
|
|
378
|
+
return Promise.resolve(
|
|
379
|
+
kind === "approved"
|
|
380
|
+
? { kind }
|
|
381
|
+
: kind === "moved"
|
|
382
|
+
? { kind, head: "c".repeat(40) }
|
|
383
|
+
: { kind, reason: "self-approval" },
|
|
384
|
+
);
|
|
385
|
+
if (method === "changes.get")
|
|
386
|
+
return Promise.resolve(z.json().parse(detail));
|
|
387
|
+
if (method === "changes.list") return Promise.resolve({ changes: [] });
|
|
388
|
+
if (method === "graph.get")
|
|
389
|
+
return Promise.resolve({ nodes: [], lanes: [] });
|
|
390
|
+
throw new Error(`Unexpected method ${method}`);
|
|
391
|
+
});
|
|
392
|
+
await render(
|
|
393
|
+
createElement(
|
|
394
|
+
RouterProvider,
|
|
395
|
+
null,
|
|
396
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
const button = [...container.querySelectorAll("button")].find(
|
|
400
|
+
(button) => button.textContent === "Approve",
|
|
401
|
+
);
|
|
402
|
+
expect(button).toBeDefined();
|
|
403
|
+
await act(async () => {
|
|
404
|
+
button?.click();
|
|
405
|
+
await Promise.resolve();
|
|
406
|
+
});
|
|
407
|
+
expect(callMethod).toHaveBeenCalledWith(
|
|
408
|
+
"changes.approve",
|
|
409
|
+
JSON.stringify({ repo: "pond", number: 1, head: "a".repeat(40) }),
|
|
410
|
+
);
|
|
411
|
+
expect(container.querySelector('[role="status"]')?.textContent).toContain(
|
|
412
|
+
kind === "approved"
|
|
413
|
+
? "Approved aaaaaaa"
|
|
414
|
+
: kind === "moved"
|
|
415
|
+
? "head moved to ccccccc"
|
|
416
|
+
: "Approval refused:",
|
|
417
|
+
);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
it("closes the canonical numbered Change", async () => {
|
|
422
|
+
const detail = detailFixture();
|
|
423
|
+
cacheChange(detail);
|
|
424
|
+
callMethod.mockImplementation((method) => {
|
|
425
|
+
if (method === "changes.close") return Promise.resolve({ allowed: true });
|
|
426
|
+
if (method === "changes.get")
|
|
427
|
+
return Promise.resolve(z.json().parse(detail));
|
|
428
|
+
if (method === "changes.list") return Promise.resolve({ changes: [] });
|
|
429
|
+
if (method === "graph.get")
|
|
430
|
+
return Promise.resolve({ nodes: [], lanes: [] });
|
|
431
|
+
throw new Error(`Unexpected method ${method}`);
|
|
432
|
+
});
|
|
433
|
+
await render(
|
|
434
|
+
createElement(
|
|
435
|
+
RouterProvider,
|
|
436
|
+
null,
|
|
437
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
await act(async () => {
|
|
441
|
+
[...container.querySelectorAll("button")]
|
|
442
|
+
.find((button) => button.textContent === "Close")
|
|
443
|
+
?.click();
|
|
444
|
+
await Promise.resolve();
|
|
445
|
+
});
|
|
446
|
+
expect(callMethod).toHaveBeenCalledWith(
|
|
447
|
+
"changes.close",
|
|
448
|
+
JSON.stringify({ repo: "pond", number: 1 }),
|
|
449
|
+
);
|
|
450
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
451
|
+
"Change closed.",
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("groups attention, ready and done Changes without losing in-flight work", async () => {
|
|
456
|
+
const base = detailFixture().change;
|
|
457
|
+
await render(
|
|
458
|
+
createElement(
|
|
459
|
+
RouterProvider,
|
|
460
|
+
null,
|
|
461
|
+
createElement(ChangeGroups, {
|
|
462
|
+
colors: new Map(),
|
|
463
|
+
changes: [
|
|
464
|
+
{ ...base, number: 1, status: "needs-approval" },
|
|
465
|
+
{ ...base, number: 2 },
|
|
466
|
+
{ ...base, number: 3, state: "closed", status: "closed" },
|
|
467
|
+
{ ...base, number: 4, status: "checks-running" },
|
|
468
|
+
],
|
|
469
|
+
}),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
expect(
|
|
473
|
+
[...container.querySelectorAll("h3")].map(
|
|
474
|
+
(heading) => heading.textContent,
|
|
475
|
+
),
|
|
476
|
+
).toEqual(["Needs you", "Ready", "In progress", "Done"]);
|
|
477
|
+
expect(
|
|
478
|
+
[...container.querySelectorAll("a")].map((link) => link.textContent),
|
|
479
|
+
).toEqual([
|
|
480
|
+
"#1 · Add ripples",
|
|
481
|
+
"#2 · Add ripples",
|
|
482
|
+
"#4 · Add ripples",
|
|
483
|
+
"#3 · Add ripples",
|
|
484
|
+
]);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("shows a single time label for a burst of commits", async () => {
|
|
489
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(700);
|
|
490
|
+
const head = "a".repeat(40);
|
|
491
|
+
await act(async () => {
|
|
492
|
+
await Promise.resolve();
|
|
493
|
+
root.render(
|
|
494
|
+
createElement(NetworkGraph, {
|
|
495
|
+
graph: {
|
|
496
|
+
lanes: [
|
|
497
|
+
{
|
|
498
|
+
id: "pond:main",
|
|
499
|
+
repo: "pond",
|
|
500
|
+
ref: "main",
|
|
501
|
+
owner: null,
|
|
502
|
+
head,
|
|
503
|
+
canonical: true,
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
nodes: Array.from({ length: 11 }, (_, index) => ({
|
|
507
|
+
hash: index.toString(16).padStart(40, "0"),
|
|
508
|
+
parents: [],
|
|
509
|
+
message: "Real push",
|
|
510
|
+
author: "morel",
|
|
511
|
+
agent: "morel",
|
|
512
|
+
origin: "pond",
|
|
513
|
+
committedAt: Math.floor(Date.now() / 1000),
|
|
514
|
+
lanes: ["pond:main"],
|
|
515
|
+
})),
|
|
516
|
+
},
|
|
517
|
+
colors: new Map(),
|
|
518
|
+
fresh: new Set<string>(),
|
|
519
|
+
onSelect: vi.fn(),
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
expect(
|
|
524
|
+
[...container.querySelectorAll(".bonsai-network-axis text")].map(
|
|
525
|
+
(node) => node.textContent,
|
|
526
|
+
),
|
|
527
|
+
).toEqual(["just now"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it.each(["merge", "merge.conflict"] as const)(
|
|
531
|
+
"shows the source lane in %s activity",
|
|
532
|
+
async (type) => {
|
|
533
|
+
const identity = {
|
|
534
|
+
repo: "koi-pond",
|
|
535
|
+
number: 1,
|
|
536
|
+
sourceRepo: "koi-pond--morel",
|
|
537
|
+
sourceRef: "ripples",
|
|
538
|
+
targetRef: "main",
|
|
539
|
+
head: "a".repeat(40),
|
|
540
|
+
};
|
|
541
|
+
const event: Activity =
|
|
542
|
+
type === "merge"
|
|
543
|
+
? {
|
|
544
|
+
id: 1,
|
|
545
|
+
at: Date.now(),
|
|
546
|
+
actor: "owner",
|
|
547
|
+
repo: "koi-pond",
|
|
548
|
+
type,
|
|
549
|
+
data: {
|
|
550
|
+
change: identity,
|
|
551
|
+
from: identity.sourceRepo,
|
|
552
|
+
ref: "main",
|
|
553
|
+
before: "b".repeat(40),
|
|
554
|
+
after: "c".repeat(40),
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
: {
|
|
558
|
+
id: 1,
|
|
559
|
+
at: Date.now(),
|
|
560
|
+
actor: "owner",
|
|
561
|
+
repo: "koi-pond",
|
|
562
|
+
type,
|
|
563
|
+
data: {
|
|
564
|
+
change: identity,
|
|
565
|
+
from: identity.sourceRepo,
|
|
566
|
+
ref: "main",
|
|
567
|
+
paths: ["src/pond.ts"],
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
await act(async () => {
|
|
571
|
+
await Promise.resolve();
|
|
572
|
+
root.render(
|
|
573
|
+
createElement(ActivityFeed, {
|
|
574
|
+
events: [event],
|
|
575
|
+
colors: new Map(),
|
|
576
|
+
fresh: new Set<number>(),
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
expect(container.textContent).toContain("morel/ripples");
|
|
581
|
+
expect(container.textContent).not.toContain("koi-pond--morel");
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const emptyCodeInsights = {
|
|
586
|
+
head: null,
|
|
587
|
+
commits: 0,
|
|
588
|
+
files: [],
|
|
589
|
+
ageHistory: [],
|
|
590
|
+
omittedFiles: 0,
|
|
591
|
+
partial: false,
|
|
592
|
+
};
|
|
593
|
+
const codeInsightCalls = () =>
|
|
594
|
+
callMethod.mock.calls.filter(([method]) => method === "insights.code").length;
|
|
595
|
+
const heavyTile = (id: "ages" | "churn") =>
|
|
596
|
+
createElement(InsightTile, {
|
|
597
|
+
id,
|
|
598
|
+
repo: "pond",
|
|
599
|
+
refName: "main",
|
|
600
|
+
graph: { nodes: [], lanes: [] },
|
|
601
|
+
actors: [],
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("opening the default Overview makes zero code-insight calls", async () => {
|
|
605
|
+
callMethod.mockImplementation((method) => {
|
|
606
|
+
if (method === "insights.layout.get")
|
|
607
|
+
return Promise.resolve({ tiles: null });
|
|
608
|
+
if (method === "insights.code") return Promise.resolve(emptyCodeInsights);
|
|
609
|
+
throw new Error(`Unexpected method ${method}`);
|
|
610
|
+
});
|
|
611
|
+
await render(
|
|
612
|
+
createElement(
|
|
613
|
+
RouterProvider,
|
|
614
|
+
null,
|
|
615
|
+
createElement(OverviewBoard, {
|
|
616
|
+
repo: "pond",
|
|
617
|
+
controlsTarget: null,
|
|
618
|
+
definitions: ["ages", "churn"].map((id) => ({
|
|
619
|
+
id,
|
|
620
|
+
title: id,
|
|
621
|
+
body: heavyTile(id === "ages" ? "ages" : "churn"),
|
|
622
|
+
})),
|
|
623
|
+
}),
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
expect(
|
|
627
|
+
callMethod.mock.calls.filter(
|
|
628
|
+
([method]) => method === "insights.layout.get",
|
|
629
|
+
),
|
|
630
|
+
).toHaveLength(1);
|
|
631
|
+
expect(codeInsightCalls()).toBe(0);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("opening Code makes zero code-insight calls", async () => {
|
|
635
|
+
callMethod.mockImplementation((method) => {
|
|
636
|
+
if (method === "insights.code") return Promise.resolve(emptyCodeInsights);
|
|
637
|
+
if (method === "files.list")
|
|
638
|
+
return Promise.resolve({ error: "Fixture file read unavailable" });
|
|
639
|
+
throw new Error(`Unexpected method ${method}`);
|
|
640
|
+
});
|
|
641
|
+
await render(
|
|
642
|
+
createElement(
|
|
643
|
+
RouterProvider,
|
|
644
|
+
null,
|
|
645
|
+
createElement(CodeTab, {
|
|
646
|
+
route: {
|
|
647
|
+
page: "repo",
|
|
648
|
+
repo: "pond",
|
|
649
|
+
tab: "code",
|
|
650
|
+
ref: null,
|
|
651
|
+
file: null,
|
|
652
|
+
path: "",
|
|
653
|
+
},
|
|
654
|
+
detail: {
|
|
655
|
+
repo: {
|
|
656
|
+
name: "pond",
|
|
657
|
+
description: null,
|
|
658
|
+
parent: null,
|
|
659
|
+
owner: null,
|
|
660
|
+
createdAt: 0,
|
|
661
|
+
lastPushAt: null,
|
|
662
|
+
forks: [],
|
|
663
|
+
openChanges: 0,
|
|
664
|
+
activity: [],
|
|
665
|
+
},
|
|
666
|
+
defaultBranch: "main",
|
|
667
|
+
refs: [],
|
|
668
|
+
cloneUrl: "https://example.test/pond.git",
|
|
669
|
+
empty: false,
|
|
670
|
+
},
|
|
671
|
+
}),
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
expect(
|
|
675
|
+
callMethod.mock.calls.filter(([method]) => method === "files.list"),
|
|
676
|
+
).toHaveLength(1);
|
|
677
|
+
expect(codeInsightCalls()).toBe(0);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("live events do not reload explicitly selected code-insight tiles", async () => {
|
|
681
|
+
let eventId = 0;
|
|
682
|
+
callMethod.mockImplementation((method) => {
|
|
683
|
+
if (method === "insights.code") return Promise.resolve(emptyCodeInsights);
|
|
684
|
+
if (method === "activity.list")
|
|
685
|
+
return Promise.resolve({
|
|
686
|
+
events: [
|
|
687
|
+
{
|
|
688
|
+
id: ++eventId,
|
|
689
|
+
at: Date.now(),
|
|
690
|
+
type: "change.closed",
|
|
691
|
+
repo: "pond",
|
|
692
|
+
actor: "owner",
|
|
693
|
+
data: {
|
|
694
|
+
change: {
|
|
695
|
+
repo: "pond",
|
|
696
|
+
number: 1,
|
|
697
|
+
sourceRepo: "pond--ada",
|
|
698
|
+
sourceRef: "main",
|
|
699
|
+
targetRef: "main",
|
|
700
|
+
head: "a".repeat(40),
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
});
|
|
706
|
+
throw new Error(`Unexpected method ${method}`);
|
|
707
|
+
});
|
|
708
|
+
await render(
|
|
709
|
+
createElement(Fragment, null, createElement(LiveProbe), heavyTile("ages")),
|
|
710
|
+
);
|
|
711
|
+
expect(codeInsightCalls()).toBe(1);
|
|
712
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
713
|
+
expect(eventId).toBe(2);
|
|
714
|
+
expect(codeInsightCalls()).toBe(1);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("a Change event reloads only its Change and lists, once per poll", async () => {
|
|
718
|
+
const reads = new Map<string, number>();
|
|
719
|
+
function WatchedQuery({
|
|
720
|
+
queryKey,
|
|
721
|
+
}: {
|
|
722
|
+
queryKey: (string | number | null)[];
|
|
723
|
+
}) {
|
|
724
|
+
useQuery({
|
|
725
|
+
queryKey,
|
|
726
|
+
queryFn: () => {
|
|
727
|
+
const key = JSON.stringify(queryKey);
|
|
728
|
+
reads.set(key, (reads.get(key) ?? 0) + 1);
|
|
729
|
+
return Promise.resolve(null);
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const keys = [
|
|
735
|
+
["changes", "pond"],
|
|
736
|
+
["change", "pond", 1],
|
|
737
|
+
["events", "pond"],
|
|
738
|
+
["events", null],
|
|
739
|
+
["repos"],
|
|
740
|
+
["change", "pond", 2],
|
|
741
|
+
["changes", "garden"],
|
|
742
|
+
["events", "garden"],
|
|
743
|
+
["graph", "pond"],
|
|
744
|
+
["files", "pond", "main"],
|
|
745
|
+
["blob", "garden", "main", "README.md"],
|
|
746
|
+
["repo", "garden"],
|
|
747
|
+
["agents"],
|
|
748
|
+
];
|
|
749
|
+
callMethod.mockResolvedValueOnce({ events: [] }).mockResolvedValue({
|
|
750
|
+
events: [1, 2]
|
|
751
|
+
.map((id) => ({
|
|
752
|
+
id,
|
|
753
|
+
at: 1,
|
|
754
|
+
type: "change.closed",
|
|
755
|
+
repo: "pond",
|
|
756
|
+
actor: "owner",
|
|
757
|
+
data: {
|
|
758
|
+
change: {
|
|
759
|
+
repo: "pond",
|
|
760
|
+
number: 1,
|
|
761
|
+
sourceRepo: "pond--ada",
|
|
762
|
+
sourceRef: "main",
|
|
763
|
+
targetRef: "main",
|
|
764
|
+
head: "a".repeat(40),
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
}))
|
|
768
|
+
.reverse(),
|
|
769
|
+
});
|
|
770
|
+
await render(
|
|
771
|
+
createElement(
|
|
772
|
+
Fragment,
|
|
773
|
+
null,
|
|
774
|
+
createElement(LiveProbe),
|
|
775
|
+
...keys.map((queryKey) =>
|
|
776
|
+
createElement(WatchedQuery, {
|
|
777
|
+
key: JSON.stringify(queryKey),
|
|
778
|
+
queryKey,
|
|
779
|
+
}),
|
|
780
|
+
),
|
|
781
|
+
),
|
|
782
|
+
);
|
|
783
|
+
await act(async () => vi.advanceTimersByTimeAsync(5000));
|
|
784
|
+
expect([...reads.values()]).toEqual([2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("idle live polls back off and hidden pages make no calls", async () => {
|
|
788
|
+
const visibility = vi.spyOn(document, "hidden", "get").mockReturnValue(false);
|
|
789
|
+
callMethod.mockResolvedValue({ events: [] });
|
|
790
|
+
await render(createElement(LiveProbe));
|
|
791
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
792
|
+
expect(callMethod).toHaveBeenCalledTimes(1);
|
|
793
|
+
await act(async () => vi.advanceTimersByTimeAsync(2500));
|
|
794
|
+
expect(callMethod).toHaveBeenCalledTimes(2);
|
|
795
|
+
visibility.mockReturnValue(true);
|
|
796
|
+
await act(async () => {
|
|
797
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
798
|
+
await vi.advanceTimersByTimeAsync(15000);
|
|
799
|
+
});
|
|
800
|
+
expect(callMethod).toHaveBeenCalledTimes(2);
|
|
801
|
+
visibility.mockReturnValue(false);
|
|
802
|
+
await act(async () => {
|
|
803
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
804
|
+
await Promise.resolve();
|
|
805
|
+
});
|
|
806
|
+
expect(callMethod).toHaveBeenCalledTimes(3);
|
|
807
|
+
visibility.mockRestore();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("Overview renders its board while graph and actors are still loading", async () => {
|
|
811
|
+
client.setQueryData(["repo", "pond"], {
|
|
812
|
+
repo: {
|
|
813
|
+
name: "pond",
|
|
814
|
+
description: null,
|
|
815
|
+
parent: null,
|
|
816
|
+
owner: null,
|
|
817
|
+
createdAt: 0,
|
|
818
|
+
lastPushAt: null,
|
|
819
|
+
forks: [],
|
|
820
|
+
activity: [],
|
|
821
|
+
},
|
|
822
|
+
defaultBranch: "main",
|
|
823
|
+
refs: [],
|
|
824
|
+
cloneUrl: "https://example.test/pond.git",
|
|
825
|
+
empty: false,
|
|
826
|
+
});
|
|
827
|
+
callMethod.mockImplementation((method) =>
|
|
828
|
+
method === "insights.layout.get"
|
|
829
|
+
? Promise.resolve({ tiles: null })
|
|
830
|
+
: new Promise(() => undefined),
|
|
831
|
+
);
|
|
832
|
+
await render(
|
|
833
|
+
createElement(
|
|
834
|
+
RouterProvider,
|
|
835
|
+
null,
|
|
836
|
+
createElement(RepoPage, {
|
|
837
|
+
route: {
|
|
838
|
+
page: "repo",
|
|
839
|
+
repo: "pond",
|
|
840
|
+
tab: "overview",
|
|
841
|
+
ref: null,
|
|
842
|
+
file: null,
|
|
843
|
+
path: "",
|
|
844
|
+
},
|
|
845
|
+
live: { connected: true, error: null, fresh: new Set<number>() },
|
|
846
|
+
}),
|
|
847
|
+
),
|
|
848
|
+
);
|
|
849
|
+
expect(
|
|
850
|
+
container.querySelector('[data-tile="readme"]')?.textContent,
|
|
851
|
+
).toContain("Loading README");
|
|
852
|
+
expect(
|
|
853
|
+
container.querySelector('[data-tile="network"]')?.textContent,
|
|
854
|
+
).toContain("Loading network");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("a failed Network tile can retry without reloading the repository", async () => {
|
|
858
|
+
client.setQueryData(["repo", "pond"], {
|
|
859
|
+
repo: {
|
|
860
|
+
name: "pond",
|
|
861
|
+
description: null,
|
|
862
|
+
parent: null,
|
|
863
|
+
owner: null,
|
|
864
|
+
createdAt: 0,
|
|
865
|
+
lastPushAt: null,
|
|
866
|
+
forks: [],
|
|
867
|
+
activity: [],
|
|
868
|
+
},
|
|
869
|
+
defaultBranch: "main",
|
|
870
|
+
refs: [],
|
|
871
|
+
cloneUrl: "https://example.test/pond.git",
|
|
872
|
+
empty: false,
|
|
873
|
+
});
|
|
874
|
+
let graphs = 0;
|
|
875
|
+
callMethod.mockImplementation((method) => {
|
|
876
|
+
if (method === "graph.get") {
|
|
877
|
+
graphs++;
|
|
878
|
+
return graphs === 1
|
|
879
|
+
? Promise.reject(new Error("Bonsai could not finish that request"))
|
|
880
|
+
: Promise.resolve({ nodes: [], lanes: [] });
|
|
881
|
+
}
|
|
882
|
+
if (method === "insights.layout.get")
|
|
883
|
+
return Promise.resolve({ tiles: null });
|
|
884
|
+
return new Promise(() => undefined);
|
|
885
|
+
});
|
|
886
|
+
await render(
|
|
887
|
+
createElement(
|
|
888
|
+
RouterProvider,
|
|
889
|
+
null,
|
|
890
|
+
createElement(RepoPage, {
|
|
891
|
+
route: {
|
|
892
|
+
page: "repo",
|
|
893
|
+
repo: "pond",
|
|
894
|
+
tab: "overview",
|
|
895
|
+
ref: null,
|
|
896
|
+
file: null,
|
|
897
|
+
path: "",
|
|
898
|
+
},
|
|
899
|
+
live: { connected: true, error: null, fresh: new Set<number>() },
|
|
900
|
+
}),
|
|
901
|
+
),
|
|
902
|
+
);
|
|
903
|
+
const tile = container.querySelector('[data-tile="network"]');
|
|
904
|
+
const retry = Array.from(tile?.querySelectorAll("button") ?? []).find(
|
|
905
|
+
(button) => button.textContent === "Retry",
|
|
906
|
+
);
|
|
907
|
+
expect(retry).toBeDefined();
|
|
908
|
+
await act(async () => {
|
|
909
|
+
retry?.click();
|
|
910
|
+
await Promise.resolve();
|
|
911
|
+
});
|
|
912
|
+
expect(graphs).toBe(2);
|
|
913
|
+
expect(tile?.textContent).not.toContain("Taking longer than usual");
|
|
914
|
+
expect(
|
|
915
|
+
callMethod.mock.calls.filter(([method]) => method === "repos.get"),
|
|
916
|
+
).toHaveLength(0);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("a target push refreshes its displayed Changes and new actors, not other repositories", async () => {
|
|
920
|
+
const reads = new Map<string, number>();
|
|
921
|
+
function WatchedQuery({ queryKey }: { queryKey: (string | number)[] }) {
|
|
922
|
+
useQuery({
|
|
923
|
+
queryKey,
|
|
924
|
+
queryFn: () => {
|
|
925
|
+
const key = JSON.stringify(queryKey);
|
|
926
|
+
reads.set(key, (reads.get(key) ?? 0) + 1);
|
|
927
|
+
return Promise.resolve(null);
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
const keys = [
|
|
933
|
+
["change", "pond", 1],
|
|
934
|
+
["change", "garden", 1],
|
|
935
|
+
["graph", "pond"],
|
|
936
|
+
["graph", "garden"],
|
|
937
|
+
["agents"],
|
|
938
|
+
];
|
|
939
|
+
callMethod.mockResolvedValueOnce({ events: [] }).mockResolvedValue({
|
|
940
|
+
events: [
|
|
941
|
+
{
|
|
942
|
+
id: 1,
|
|
943
|
+
at: 1,
|
|
944
|
+
type: "push",
|
|
945
|
+
repo: "pond",
|
|
946
|
+
actor: "new-contributor",
|
|
947
|
+
data: {
|
|
948
|
+
updates: [
|
|
949
|
+
{
|
|
950
|
+
ref: "refs/heads/main",
|
|
951
|
+
before: "a".repeat(40),
|
|
952
|
+
after: "b".repeat(40),
|
|
953
|
+
},
|
|
954
|
+
],
|
|
955
|
+
commits: [],
|
|
956
|
+
paths: [],
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
],
|
|
960
|
+
});
|
|
961
|
+
await render(
|
|
962
|
+
createElement(
|
|
963
|
+
Fragment,
|
|
964
|
+
null,
|
|
965
|
+
createElement(LiveProbe),
|
|
966
|
+
...keys.map((queryKey) =>
|
|
967
|
+
createElement(WatchedQuery, {
|
|
968
|
+
key: JSON.stringify(queryKey),
|
|
969
|
+
queryKey,
|
|
970
|
+
}),
|
|
971
|
+
),
|
|
972
|
+
),
|
|
973
|
+
);
|
|
974
|
+
await act(async () => vi.advanceTimersByTimeAsync(5000));
|
|
975
|
+
expect([...reads.values()]).toEqual([2, 1, 2, 1, 2]);
|
|
976
|
+
});
|