@fungi.computer/bonsai 0.0.0-stage → 0.0.1
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 +17 -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 +835 -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/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 +5 -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 +715 -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,916 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import {
|
|
6
|
+
QueryClient,
|
|
7
|
+
QueryClientProvider,
|
|
8
|
+
notifyManager,
|
|
9
|
+
} from "@tanstack/react-query";
|
|
10
|
+
import { createCapabilityRegistry } from "@fungi.computer/agar";
|
|
11
|
+
import { appMethods } from "@fungi.computer/agar/backend";
|
|
12
|
+
import { ChangePage } from "../web/change-page.js";
|
|
13
|
+
import { RouterProvider } from "../web/router.js";
|
|
14
|
+
import { connect } from "../web/transport.js";
|
|
15
|
+
import type { Change, ChangeDetail, JsonValue } from "../shared/contract.js";
|
|
16
|
+
|
|
17
|
+
const callMethod =
|
|
18
|
+
vi.fn<(method: string, input: string) => Promise<JsonValue>>();
|
|
19
|
+
|
|
20
|
+
let container: HTMLDivElement;
|
|
21
|
+
let root: ReturnType<typeof createRoot>;
|
|
22
|
+
let client: QueryClient;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.stubGlobal("scrollTo", vi.fn());
|
|
26
|
+
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
|
27
|
+
configurable: true,
|
|
28
|
+
value: vi.fn(),
|
|
29
|
+
});
|
|
30
|
+
vi.stubGlobal(
|
|
31
|
+
"matchMedia",
|
|
32
|
+
vi.fn(() => ({
|
|
33
|
+
matches: false,
|
|
34
|
+
addEventListener: vi.fn(),
|
|
35
|
+
removeEventListener: vi.fn(),
|
|
36
|
+
})),
|
|
37
|
+
);
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
"ResizeObserver",
|
|
40
|
+
class {
|
|
41
|
+
observe = vi.fn();
|
|
42
|
+
disconnect = vi.fn();
|
|
43
|
+
unobserve = vi.fn();
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const capabilities = createCapabilityRegistry();
|
|
47
|
+
capabilities.provide(appMethods, { call: callMethod });
|
|
48
|
+
connect({ capabilities });
|
|
49
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
50
|
+
notifyManager.setScheduler(queueMicrotask);
|
|
51
|
+
client = new QueryClient({
|
|
52
|
+
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
|
53
|
+
});
|
|
54
|
+
container = document.createElement("div");
|
|
55
|
+
document.body.appendChild(container);
|
|
56
|
+
root = createRoot(container);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await act(async () => {
|
|
61
|
+
root.unmount();
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
});
|
|
64
|
+
connect(null);
|
|
65
|
+
client.clear();
|
|
66
|
+
container.remove();
|
|
67
|
+
vi.unstubAllGlobals();
|
|
68
|
+
vi.resetAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const render = async (child: ReturnType<typeof createElement>) => {
|
|
72
|
+
await act(async () => {
|
|
73
|
+
root.render(createElement(QueryClientProvider, { client }, child));
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const HEAD = "a".repeat(40);
|
|
80
|
+
const OPEN_TARGET = "b".repeat(40);
|
|
81
|
+
const CONFLICT_TARGET = "c".repeat(40);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A minimal, schema-valid Change for repo "pond" #1, varied only by the
|
|
85
|
+
* fields each phase of the test actually cares about. Every field is
|
|
86
|
+
* required here (never spread from a Partial<Change>) so the inferred
|
|
87
|
+
* object type never widens `mergeFailure` to include `undefined`.
|
|
88
|
+
*/
|
|
89
|
+
// `satisfies`, not `: Change`/`: ChangeDetail`, so the inferred type stays
|
|
90
|
+
// the plain literal shape: Change's `mergeFailure` is optional, and a
|
|
91
|
+
// declared-`Change` return would widen it to `... | undefined`, which the
|
|
92
|
+
// mocked transport's `Promise<JsonValue>` return type then rejects.
|
|
93
|
+
function buildChange(fields: {
|
|
94
|
+
state: Change["state"];
|
|
95
|
+
status: Change["status"];
|
|
96
|
+
lease: Change["lease"];
|
|
97
|
+
conflict: Change["conflict"];
|
|
98
|
+
mergeable: Change["mergeable"];
|
|
99
|
+
}) {
|
|
100
|
+
return {
|
|
101
|
+
repo: "pond",
|
|
102
|
+
number: 1,
|
|
103
|
+
sourceRepo: "pond--morel",
|
|
104
|
+
sourceRef: "feature",
|
|
105
|
+
targetRef: "main",
|
|
106
|
+
title: "Add waves",
|
|
107
|
+
body: "",
|
|
108
|
+
openedBy: "Morel",
|
|
109
|
+
openedKind: "human",
|
|
110
|
+
createdAt: 1,
|
|
111
|
+
updatedAt: 1,
|
|
112
|
+
head: HEAD,
|
|
113
|
+
target: OPEN_TARGET,
|
|
114
|
+
revision: 0,
|
|
115
|
+
mergedCommit: null,
|
|
116
|
+
mergedBy: null,
|
|
117
|
+
closedBy: null,
|
|
118
|
+
base: OPEN_TARGET,
|
|
119
|
+
ahead: 1,
|
|
120
|
+
behind: 0,
|
|
121
|
+
...fields,
|
|
122
|
+
} satisfies Change;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildDetail(
|
|
126
|
+
change: Omit<ReturnType<typeof buildChange>, "mergedCommit" | "mergedBy"> &
|
|
127
|
+
Pick<Change, "mergedCommit" | "mergedBy">,
|
|
128
|
+
) {
|
|
129
|
+
return {
|
|
130
|
+
change,
|
|
131
|
+
actions: {
|
|
132
|
+
approve: { allowed: false, reason: "self-approval" },
|
|
133
|
+
close: { allowed: true },
|
|
134
|
+
},
|
|
135
|
+
commits: [],
|
|
136
|
+
diff: { files: [], additions: 0, deletions: 0, omittedFiles: 0 },
|
|
137
|
+
review: { approvals: [], checks: [] },
|
|
138
|
+
} satisfies ChangeDetail;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A separate helper, not a `buildChange` option: adding `mergeFailure` as an
|
|
143
|
+
* optional field on `buildChange`'s own parameter would spread it back into
|
|
144
|
+
* an optional (`... | undefined`) property, hitting the same widening issue
|
|
145
|
+
* the comment above `buildChange` explains.
|
|
146
|
+
*/
|
|
147
|
+
function withMergeFailure(
|
|
148
|
+
change: ReturnType<typeof buildChange>,
|
|
149
|
+
reason: "access_changed" | "sync_failed",
|
|
150
|
+
) {
|
|
151
|
+
return { ...change, mergeFailure: reason } satisfies Change;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Same reasoning as `withMergeFailure`: a dedicated helper, not an optional
|
|
156
|
+
* `withMergeFailure` parameter, so a "head_moved" settlement that also
|
|
157
|
+
* named what landed never widens `mergedCommit`/`mergedBy` to include
|
|
158
|
+
* `undefined`.
|
|
159
|
+
*/
|
|
160
|
+
function withHeadMoved(
|
|
161
|
+
change: Omit<ReturnType<typeof buildChange>, "mergedCommit" | "mergedBy">,
|
|
162
|
+
landed: { commit: string; by: string } | null,
|
|
163
|
+
) {
|
|
164
|
+
return landed === null
|
|
165
|
+
? ({
|
|
166
|
+
...change,
|
|
167
|
+
mergeFailure: "head_moved",
|
|
168
|
+
mergedCommit: null,
|
|
169
|
+
mergedBy: null,
|
|
170
|
+
} satisfies Change)
|
|
171
|
+
: ({
|
|
172
|
+
...change,
|
|
173
|
+
mergeFailure: "head_moved",
|
|
174
|
+
mergedCommit: landed.commit,
|
|
175
|
+
mergedBy: landed.by,
|
|
176
|
+
} satisfies Change);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("Bonsai change page", () => {
|
|
180
|
+
it("shows the real settled outcome instead of a stuck Merging… when a background merge lands", async () => {
|
|
181
|
+
let phase: "open" | "merging" | "conflict" = "open";
|
|
182
|
+
callMethod.mockImplementation((method) => {
|
|
183
|
+
switch (method) {
|
|
184
|
+
case "changes.get":
|
|
185
|
+
return Promise.resolve(
|
|
186
|
+
buildDetail(
|
|
187
|
+
phase === "open"
|
|
188
|
+
? buildChange({
|
|
189
|
+
state: "open",
|
|
190
|
+
status: "ready",
|
|
191
|
+
lease: null,
|
|
192
|
+
conflict: null,
|
|
193
|
+
mergeable: { allowed: true },
|
|
194
|
+
})
|
|
195
|
+
: phase === "merging"
|
|
196
|
+
? buildChange({
|
|
197
|
+
state: "merging",
|
|
198
|
+
status: "merging",
|
|
199
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
200
|
+
conflict: null,
|
|
201
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
202
|
+
})
|
|
203
|
+
: buildChange({
|
|
204
|
+
state: "open",
|
|
205
|
+
status: "conflict",
|
|
206
|
+
lease: null,
|
|
207
|
+
conflict: {
|
|
208
|
+
head: HEAD,
|
|
209
|
+
target: CONFLICT_TARGET,
|
|
210
|
+
paths: ["README.md"],
|
|
211
|
+
},
|
|
212
|
+
mergeable: { allowed: false, reason: "conflict" },
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
case "changes.list":
|
|
217
|
+
return Promise.resolve({ changes: [] });
|
|
218
|
+
case "graph.get":
|
|
219
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
220
|
+
case "actors.list":
|
|
221
|
+
return Promise.resolve({ agents: [] });
|
|
222
|
+
case "repos.get":
|
|
223
|
+
return Promise.resolve({
|
|
224
|
+
repo: {
|
|
225
|
+
name: "pond",
|
|
226
|
+
description: null,
|
|
227
|
+
parent: null,
|
|
228
|
+
owner: null,
|
|
229
|
+
createdAt: 1,
|
|
230
|
+
lastPushAt: null,
|
|
231
|
+
forks: [],
|
|
232
|
+
openChanges: 0,
|
|
233
|
+
activity: [],
|
|
234
|
+
},
|
|
235
|
+
defaultBranch: "main",
|
|
236
|
+
refs: [],
|
|
237
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
238
|
+
empty: false,
|
|
239
|
+
});
|
|
240
|
+
case "activity.list":
|
|
241
|
+
return Promise.resolve({ events: [] });
|
|
242
|
+
case "changes.merge":
|
|
243
|
+
return Promise.resolve({ kind: "merging" });
|
|
244
|
+
default:
|
|
245
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await render(
|
|
250
|
+
createElement(
|
|
251
|
+
RouterProvider,
|
|
252
|
+
null,
|
|
253
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
258
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
259
|
+
);
|
|
260
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
261
|
+
await act(async () => {
|
|
262
|
+
mergeButton.click();
|
|
263
|
+
await Promise.resolve();
|
|
264
|
+
await Promise.resolve();
|
|
265
|
+
await Promise.resolve();
|
|
266
|
+
});
|
|
267
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
268
|
+
"Merging…",
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Polling observes the Change as "merging" at least once before settlement.
|
|
272
|
+
phase = "merging";
|
|
273
|
+
await act(async () => {
|
|
274
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
275
|
+
await Promise.resolve();
|
|
276
|
+
});
|
|
277
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
278
|
+
"Merging…",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// The background merge settles as a real conflict on README.md.
|
|
282
|
+
phase = "conflict";
|
|
283
|
+
await act(async () => {
|
|
284
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
285
|
+
await Promise.resolve();
|
|
286
|
+
});
|
|
287
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
288
|
+
"Conflicts in README.md. Update the source branch, resolve and push again.",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("shows Merge failed · Retry once a background merge exhausts its retries, instead of hanging on Merging…", async () => {
|
|
293
|
+
let phase: "open" | "merging" | "failed" = "open";
|
|
294
|
+
callMethod.mockImplementation((method) => {
|
|
295
|
+
switch (method) {
|
|
296
|
+
case "changes.get":
|
|
297
|
+
return Promise.resolve(
|
|
298
|
+
buildDetail(
|
|
299
|
+
phase === "open"
|
|
300
|
+
? buildChange({
|
|
301
|
+
state: "open",
|
|
302
|
+
status: "ready",
|
|
303
|
+
lease: null,
|
|
304
|
+
conflict: null,
|
|
305
|
+
mergeable: { allowed: true },
|
|
306
|
+
})
|
|
307
|
+
: phase === "merging"
|
|
308
|
+
? buildChange({
|
|
309
|
+
state: "merging",
|
|
310
|
+
status: "merging",
|
|
311
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
312
|
+
conflict: null,
|
|
313
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
314
|
+
})
|
|
315
|
+
: withMergeFailure(
|
|
316
|
+
buildChange({
|
|
317
|
+
state: "open",
|
|
318
|
+
status: "ready",
|
|
319
|
+
lease: null,
|
|
320
|
+
conflict: null,
|
|
321
|
+
mergeable: { allowed: true },
|
|
322
|
+
}),
|
|
323
|
+
"sync_failed",
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
case "changes.list":
|
|
328
|
+
return Promise.resolve({ changes: [] });
|
|
329
|
+
case "graph.get":
|
|
330
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
331
|
+
case "actors.list":
|
|
332
|
+
return Promise.resolve({ agents: [] });
|
|
333
|
+
case "repos.get":
|
|
334
|
+
return Promise.resolve({
|
|
335
|
+
repo: {
|
|
336
|
+
name: "pond",
|
|
337
|
+
description: null,
|
|
338
|
+
parent: null,
|
|
339
|
+
owner: null,
|
|
340
|
+
createdAt: 1,
|
|
341
|
+
lastPushAt: null,
|
|
342
|
+
forks: [],
|
|
343
|
+
openChanges: 0,
|
|
344
|
+
activity: [],
|
|
345
|
+
},
|
|
346
|
+
defaultBranch: "main",
|
|
347
|
+
refs: [],
|
|
348
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
349
|
+
empty: false,
|
|
350
|
+
});
|
|
351
|
+
case "activity.list":
|
|
352
|
+
return Promise.resolve({ events: [] });
|
|
353
|
+
case "changes.merge":
|
|
354
|
+
return Promise.resolve({ kind: "merging" });
|
|
355
|
+
default:
|
|
356
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await render(
|
|
361
|
+
createElement(
|
|
362
|
+
RouterProvider,
|
|
363
|
+
null,
|
|
364
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
369
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
370
|
+
);
|
|
371
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
372
|
+
await act(async () => {
|
|
373
|
+
mergeButton.click();
|
|
374
|
+
await Promise.resolve();
|
|
375
|
+
await Promise.resolve();
|
|
376
|
+
await Promise.resolve();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
phase = "merging";
|
|
380
|
+
await act(async () => {
|
|
381
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
382
|
+
await Promise.resolve();
|
|
383
|
+
});
|
|
384
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
385
|
+
"Merging…",
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// The background lane exhausted its stale-CAS retries and recorded its
|
|
389
|
+
// own mergeFailure; the page must say so rather than hang forever.
|
|
390
|
+
phase = "failed";
|
|
391
|
+
await act(async () => {
|
|
392
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
393
|
+
await Promise.resolve();
|
|
394
|
+
});
|
|
395
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
396
|
+
"Merge failed · Retry",
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("names a moved branch, not an unchanged head, when a foreground merge exhausts its retries", async () => {
|
|
401
|
+
callMethod.mockImplementation((method) => {
|
|
402
|
+
switch (method) {
|
|
403
|
+
case "changes.get":
|
|
404
|
+
return Promise.resolve(
|
|
405
|
+
buildDetail(
|
|
406
|
+
buildChange({
|
|
407
|
+
state: "open",
|
|
408
|
+
status: "ready",
|
|
409
|
+
lease: null,
|
|
410
|
+
conflict: null,
|
|
411
|
+
mergeable: { allowed: true },
|
|
412
|
+
}),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
case "changes.list":
|
|
416
|
+
return Promise.resolve({ changes: [] });
|
|
417
|
+
case "graph.get":
|
|
418
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
419
|
+
case "actors.list":
|
|
420
|
+
return Promise.resolve({ agents: [] });
|
|
421
|
+
case "repos.get":
|
|
422
|
+
return Promise.resolve({
|
|
423
|
+
repo: {
|
|
424
|
+
name: "pond",
|
|
425
|
+
description: null,
|
|
426
|
+
parent: null,
|
|
427
|
+
owner: null,
|
|
428
|
+
createdAt: 1,
|
|
429
|
+
lastPushAt: null,
|
|
430
|
+
forks: [],
|
|
431
|
+
openChanges: 0,
|
|
432
|
+
activity: [],
|
|
433
|
+
},
|
|
434
|
+
defaultBranch: "main",
|
|
435
|
+
refs: [],
|
|
436
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
437
|
+
empty: false,
|
|
438
|
+
});
|
|
439
|
+
case "activity.list":
|
|
440
|
+
return Promise.resolve({ events: [] });
|
|
441
|
+
case "changes.merge":
|
|
442
|
+
// The foreground path's own 3x inline retry exhausted and
|
|
443
|
+
// settled synchronously: the target moved, not the reviewed
|
|
444
|
+
// (unchanged) head, so `head` here is just `change.head` again.
|
|
445
|
+
return Promise.resolve({ kind: "moved", head: HEAD });
|
|
446
|
+
default:
|
|
447
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await render(
|
|
452
|
+
createElement(
|
|
453
|
+
RouterProvider,
|
|
454
|
+
null,
|
|
455
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
460
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
461
|
+
);
|
|
462
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
463
|
+
await act(async () => {
|
|
464
|
+
mergeButton.click();
|
|
465
|
+
await Promise.resolve();
|
|
466
|
+
await Promise.resolve();
|
|
467
|
+
await Promise.resolve();
|
|
468
|
+
});
|
|
469
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
470
|
+
"The merge did not complete because the branch moved. Review the refreshed Change and merge again.",
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("shows a defensive message, never a stuck Merging…, if a settled merge leaves neither a failure nor a matching conflict", async () => {
|
|
475
|
+
let phase: "open" | "merging" | "unexplained" = "open";
|
|
476
|
+
callMethod.mockImplementation((method) => {
|
|
477
|
+
switch (method) {
|
|
478
|
+
case "changes.get":
|
|
479
|
+
return Promise.resolve(
|
|
480
|
+
buildDetail(
|
|
481
|
+
phase === "open"
|
|
482
|
+
? buildChange({
|
|
483
|
+
state: "open",
|
|
484
|
+
status: "ready",
|
|
485
|
+
lease: null,
|
|
486
|
+
conflict: null,
|
|
487
|
+
mergeable: { allowed: true },
|
|
488
|
+
})
|
|
489
|
+
: phase === "merging"
|
|
490
|
+
? buildChange({
|
|
491
|
+
state: "merging",
|
|
492
|
+
status: "merging",
|
|
493
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
494
|
+
conflict: null,
|
|
495
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
496
|
+
})
|
|
497
|
+
: buildChange({
|
|
498
|
+
// No mergeFailure, no conflict, and not merged: the
|
|
499
|
+
// defensive fallback, not another "Merging…" render.
|
|
500
|
+
state: "open",
|
|
501
|
+
status: "ready",
|
|
502
|
+
lease: null,
|
|
503
|
+
conflict: null,
|
|
504
|
+
mergeable: { allowed: true },
|
|
505
|
+
}),
|
|
506
|
+
),
|
|
507
|
+
);
|
|
508
|
+
case "changes.list":
|
|
509
|
+
return Promise.resolve({ changes: [] });
|
|
510
|
+
case "graph.get":
|
|
511
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
512
|
+
case "actors.list":
|
|
513
|
+
return Promise.resolve({ agents: [] });
|
|
514
|
+
case "repos.get":
|
|
515
|
+
return Promise.resolve({
|
|
516
|
+
repo: {
|
|
517
|
+
name: "pond",
|
|
518
|
+
description: null,
|
|
519
|
+
parent: null,
|
|
520
|
+
owner: null,
|
|
521
|
+
createdAt: 1,
|
|
522
|
+
lastPushAt: null,
|
|
523
|
+
forks: [],
|
|
524
|
+
openChanges: 0,
|
|
525
|
+
activity: [],
|
|
526
|
+
},
|
|
527
|
+
defaultBranch: "main",
|
|
528
|
+
refs: [],
|
|
529
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
530
|
+
empty: false,
|
|
531
|
+
});
|
|
532
|
+
case "activity.list":
|
|
533
|
+
return Promise.resolve({ events: [] });
|
|
534
|
+
case "changes.merge":
|
|
535
|
+
return Promise.resolve({ kind: "merging" });
|
|
536
|
+
default:
|
|
537
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await render(
|
|
542
|
+
createElement(
|
|
543
|
+
RouterProvider,
|
|
544
|
+
null,
|
|
545
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
550
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
551
|
+
);
|
|
552
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
553
|
+
await act(async () => {
|
|
554
|
+
mergeButton.click();
|
|
555
|
+
await Promise.resolve();
|
|
556
|
+
await Promise.resolve();
|
|
557
|
+
await Promise.resolve();
|
|
558
|
+
});
|
|
559
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
560
|
+
"Merging…",
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
phase = "merging";
|
|
564
|
+
await act(async () => {
|
|
565
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
566
|
+
await Promise.resolve();
|
|
567
|
+
});
|
|
568
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
569
|
+
"Merging…",
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
phase = "unexplained";
|
|
573
|
+
await act(async () => {
|
|
574
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
575
|
+
await Promise.resolve();
|
|
576
|
+
});
|
|
577
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
578
|
+
"The merge did not complete. Review the Change and try again.",
|
|
579
|
+
);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("says the source moved when a background merge settles head_moved without landing anything", async () => {
|
|
583
|
+
let phase: "open" | "merging" | "head_moved" = "open";
|
|
584
|
+
callMethod.mockImplementation((method) => {
|
|
585
|
+
switch (method) {
|
|
586
|
+
case "changes.get":
|
|
587
|
+
return Promise.resolve(
|
|
588
|
+
buildDetail(
|
|
589
|
+
phase === "open"
|
|
590
|
+
? buildChange({
|
|
591
|
+
state: "open",
|
|
592
|
+
status: "ready",
|
|
593
|
+
lease: null,
|
|
594
|
+
conflict: null,
|
|
595
|
+
mergeable: { allowed: true },
|
|
596
|
+
})
|
|
597
|
+
: phase === "merging"
|
|
598
|
+
? buildChange({
|
|
599
|
+
state: "merging",
|
|
600
|
+
status: "merging",
|
|
601
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
602
|
+
conflict: null,
|
|
603
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
604
|
+
})
|
|
605
|
+
: withHeadMoved(
|
|
606
|
+
buildChange({
|
|
607
|
+
state: "open",
|
|
608
|
+
status: "ready",
|
|
609
|
+
lease: null,
|
|
610
|
+
conflict: null,
|
|
611
|
+
mergeable: { allowed: true },
|
|
612
|
+
}),
|
|
613
|
+
null,
|
|
614
|
+
),
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
case "changes.list":
|
|
618
|
+
return Promise.resolve({ changes: [] });
|
|
619
|
+
case "graph.get":
|
|
620
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
621
|
+
case "actors.list":
|
|
622
|
+
return Promise.resolve({ agents: [] });
|
|
623
|
+
case "repos.get":
|
|
624
|
+
return Promise.resolve({
|
|
625
|
+
repo: {
|
|
626
|
+
name: "pond",
|
|
627
|
+
description: null,
|
|
628
|
+
parent: null,
|
|
629
|
+
owner: null,
|
|
630
|
+
createdAt: 1,
|
|
631
|
+
lastPushAt: null,
|
|
632
|
+
forks: [],
|
|
633
|
+
openChanges: 0,
|
|
634
|
+
activity: [],
|
|
635
|
+
},
|
|
636
|
+
defaultBranch: "main",
|
|
637
|
+
refs: [],
|
|
638
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
639
|
+
empty: false,
|
|
640
|
+
});
|
|
641
|
+
case "activity.list":
|
|
642
|
+
return Promise.resolve({ events: [] });
|
|
643
|
+
case "changes.merge":
|
|
644
|
+
return Promise.resolve({ kind: "merging" });
|
|
645
|
+
default:
|
|
646
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
await render(
|
|
651
|
+
createElement(
|
|
652
|
+
RouterProvider,
|
|
653
|
+
null,
|
|
654
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
655
|
+
),
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
659
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
660
|
+
);
|
|
661
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
662
|
+
await act(async () => {
|
|
663
|
+
mergeButton.click();
|
|
664
|
+
await Promise.resolve();
|
|
665
|
+
await Promise.resolve();
|
|
666
|
+
await Promise.resolve();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
phase = "merging";
|
|
670
|
+
await act(async () => {
|
|
671
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
672
|
+
await Promise.resolve();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
phase = "head_moved";
|
|
676
|
+
await act(async () => {
|
|
677
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
678
|
+
await Promise.resolve();
|
|
679
|
+
});
|
|
680
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
681
|
+
"The source moved. Review the refreshed Change and merge again.",
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("names the commit and asks for a review of the new head when a background merge lands for a superseded head", async () => {
|
|
686
|
+
let phase: "open" | "merging" | "head_moved" = "open";
|
|
687
|
+
const landedCommit = "d".repeat(40);
|
|
688
|
+
callMethod.mockImplementation((method) => {
|
|
689
|
+
switch (method) {
|
|
690
|
+
case "changes.get":
|
|
691
|
+
return Promise.resolve(
|
|
692
|
+
buildDetail(
|
|
693
|
+
phase === "open"
|
|
694
|
+
? buildChange({
|
|
695
|
+
state: "open",
|
|
696
|
+
status: "ready",
|
|
697
|
+
lease: null,
|
|
698
|
+
conflict: null,
|
|
699
|
+
mergeable: { allowed: true },
|
|
700
|
+
})
|
|
701
|
+
: phase === "merging"
|
|
702
|
+
? buildChange({
|
|
703
|
+
state: "merging",
|
|
704
|
+
status: "merging",
|
|
705
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
706
|
+
conflict: null,
|
|
707
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
708
|
+
})
|
|
709
|
+
: withHeadMoved(
|
|
710
|
+
buildChange({
|
|
711
|
+
state: "open",
|
|
712
|
+
status: "ready",
|
|
713
|
+
lease: null,
|
|
714
|
+
conflict: null,
|
|
715
|
+
mergeable: { allowed: true },
|
|
716
|
+
}),
|
|
717
|
+
{ commit: landedCommit, by: "Levi" },
|
|
718
|
+
),
|
|
719
|
+
),
|
|
720
|
+
);
|
|
721
|
+
case "changes.list":
|
|
722
|
+
return Promise.resolve({ changes: [] });
|
|
723
|
+
case "graph.get":
|
|
724
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
725
|
+
case "actors.list":
|
|
726
|
+
return Promise.resolve({ agents: [] });
|
|
727
|
+
case "repos.get":
|
|
728
|
+
return Promise.resolve({
|
|
729
|
+
repo: {
|
|
730
|
+
name: "pond",
|
|
731
|
+
description: null,
|
|
732
|
+
parent: null,
|
|
733
|
+
owner: null,
|
|
734
|
+
createdAt: 1,
|
|
735
|
+
lastPushAt: null,
|
|
736
|
+
forks: [],
|
|
737
|
+
openChanges: 0,
|
|
738
|
+
activity: [],
|
|
739
|
+
},
|
|
740
|
+
defaultBranch: "main",
|
|
741
|
+
refs: [],
|
|
742
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
743
|
+
empty: false,
|
|
744
|
+
});
|
|
745
|
+
case "activity.list":
|
|
746
|
+
return Promise.resolve({ events: [] });
|
|
747
|
+
case "changes.merge":
|
|
748
|
+
return Promise.resolve({ kind: "merging" });
|
|
749
|
+
default:
|
|
750
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
await render(
|
|
755
|
+
createElement(
|
|
756
|
+
RouterProvider,
|
|
757
|
+
null,
|
|
758
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
759
|
+
),
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
763
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
764
|
+
);
|
|
765
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
766
|
+
await act(async () => {
|
|
767
|
+
mergeButton.click();
|
|
768
|
+
await Promise.resolve();
|
|
769
|
+
await Promise.resolve();
|
|
770
|
+
await Promise.resolve();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
phase = "merging";
|
|
774
|
+
await act(async () => {
|
|
775
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
776
|
+
await Promise.resolve();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
phase = "head_moved";
|
|
780
|
+
await act(async () => {
|
|
781
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
782
|
+
await Promise.resolve();
|
|
783
|
+
});
|
|
784
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
785
|
+
`Merged as ${landedCommit.slice(0, 7)}. Review the new head before merging again.`,
|
|
786
|
+
);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("does not flash the defensive fallback on a second merge click after the first settled unexplained", async () => {
|
|
790
|
+
let phase: "open" | "merging" | "unexplained" = "open";
|
|
791
|
+
callMethod.mockImplementation((method) => {
|
|
792
|
+
switch (method) {
|
|
793
|
+
case "changes.get":
|
|
794
|
+
return Promise.resolve(
|
|
795
|
+
buildDetail(
|
|
796
|
+
phase === "open" || phase === "unexplained"
|
|
797
|
+
? buildChange({
|
|
798
|
+
state: "open",
|
|
799
|
+
status: "ready",
|
|
800
|
+
lease: null,
|
|
801
|
+
conflict: null,
|
|
802
|
+
mergeable: { allowed: true },
|
|
803
|
+
})
|
|
804
|
+
: buildChange({
|
|
805
|
+
state: "merging",
|
|
806
|
+
status: "merging",
|
|
807
|
+
lease: { id: "lease-1", by: "Levi", head: HEAD, at: 1 },
|
|
808
|
+
conflict: null,
|
|
809
|
+
mergeable: { allowed: false, reason: "merging" },
|
|
810
|
+
}),
|
|
811
|
+
),
|
|
812
|
+
);
|
|
813
|
+
case "changes.list":
|
|
814
|
+
return Promise.resolve({ changes: [] });
|
|
815
|
+
case "graph.get":
|
|
816
|
+
return Promise.resolve({ lanes: [], nodes: [] });
|
|
817
|
+
case "actors.list":
|
|
818
|
+
return Promise.resolve({ agents: [] });
|
|
819
|
+
case "repos.get":
|
|
820
|
+
return Promise.resolve({
|
|
821
|
+
repo: {
|
|
822
|
+
name: "pond",
|
|
823
|
+
description: null,
|
|
824
|
+
parent: null,
|
|
825
|
+
owner: null,
|
|
826
|
+
createdAt: 1,
|
|
827
|
+
lastPushAt: null,
|
|
828
|
+
forks: [],
|
|
829
|
+
openChanges: 0,
|
|
830
|
+
activity: [],
|
|
831
|
+
},
|
|
832
|
+
defaultBranch: "main",
|
|
833
|
+
refs: [],
|
|
834
|
+
cloneUrl: "https://bonsai.local/git/pond",
|
|
835
|
+
empty: false,
|
|
836
|
+
});
|
|
837
|
+
case "activity.list":
|
|
838
|
+
return Promise.resolve({ events: [] });
|
|
839
|
+
case "changes.merge":
|
|
840
|
+
return Promise.resolve({ kind: "merging" });
|
|
841
|
+
default:
|
|
842
|
+
return Promise.reject(new Error(`unexpected method ${method}`));
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await render(
|
|
847
|
+
createElement(
|
|
848
|
+
RouterProvider,
|
|
849
|
+
null,
|
|
850
|
+
createElement(ChangePage, { repo: "pond", number: 1 }),
|
|
851
|
+
),
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
const mergeButton = [...container.querySelectorAll("button")].find(
|
|
855
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
856
|
+
);
|
|
857
|
+
if (!mergeButton) throw new Error("Merge button not found");
|
|
858
|
+
|
|
859
|
+
// First attempt: observe "merging", then settle unexplained (neither
|
|
860
|
+
// a failure nor a matching conflict) -- the defensive fallback.
|
|
861
|
+
await act(async () => {
|
|
862
|
+
mergeButton.click();
|
|
863
|
+
await Promise.resolve();
|
|
864
|
+
await Promise.resolve();
|
|
865
|
+
await Promise.resolve();
|
|
866
|
+
});
|
|
867
|
+
phase = "merging";
|
|
868
|
+
await act(async () => {
|
|
869
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
870
|
+
await Promise.resolve();
|
|
871
|
+
});
|
|
872
|
+
phase = "unexplained";
|
|
873
|
+
await act(async () => {
|
|
874
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
875
|
+
await Promise.resolve();
|
|
876
|
+
});
|
|
877
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
878
|
+
"The merge did not complete. Review the Change and try again.",
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
// Second click: sawMerging must reset, or this click's own optimistic
|
|
882
|
+
// "Merging…" would immediately flash the stale fallback again, before
|
|
883
|
+
// this attempt's own first poll has confirmed "merging". Re-find the
|
|
884
|
+
// button: the board tile re-rendered under the first settlement, and
|
|
885
|
+
// a cached node from before that render may no longer be attached.
|
|
886
|
+
await act(async () => {
|
|
887
|
+
const secondClick = [...container.querySelectorAll("button")].find(
|
|
888
|
+
(button) => button.textContent.startsWith("Merge into"),
|
|
889
|
+
);
|
|
890
|
+
if (!secondClick) throw new Error("Merge button not found");
|
|
891
|
+
secondClick.click();
|
|
892
|
+
await Promise.resolve();
|
|
893
|
+
await Promise.resolve();
|
|
894
|
+
await Promise.resolve();
|
|
895
|
+
});
|
|
896
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
897
|
+
"Merging…",
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// And the fallback still works on this second attempt once polling
|
|
901
|
+
// has observed "merging" again before it settles unexplained again.
|
|
902
|
+
phase = "merging";
|
|
903
|
+
await act(async () => {
|
|
904
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
905
|
+
await Promise.resolve();
|
|
906
|
+
});
|
|
907
|
+
phase = "unexplained";
|
|
908
|
+
await act(async () => {
|
|
909
|
+
await client.invalidateQueries({ queryKey: ["change", "pond", 1] });
|
|
910
|
+
await Promise.resolve();
|
|
911
|
+
});
|
|
912
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe(
|
|
913
|
+
"The merge did not complete. Review the Change and try again.",
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
});
|