@xiaoming_z/dsh-git 0.0.0-stage → 0.3.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/README.md +147 -2
- package/client.js +3133 -0
- package/cordis.patch.yml +29 -0
- package/docs/composer-branch-chip.png +0 -0
- package/index.js +1320 -0
- package/package.json +44 -4
- package/test/browser/README.md +39 -0
- package/test/browser/drive.mjs +122 -0
- package/test/browser/harness.js +271 -0
- package/test/browser/index.html +41 -0
- package/test/browser/setup.mjs +119 -0
- package/test/client.test.mjs +1258 -0
- package/test/host.test.mjs +817 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-half test.
|
|
3
|
+
*
|
|
4
|
+
* The bundle is loaded exactly the way the shell kernel loads it (a classic
|
|
5
|
+
* script registering a factory with `window.__ModuleLoader__`), then driven
|
|
6
|
+
* through a real mount → fetch → re-render cycle by a minimal hook-capable
|
|
7
|
+
* React stand-in. That cycle is where a panel that "flashes a box then goes
|
|
8
|
+
* blank" fails: the first paint succeeds and the post-load paint throws, so
|
|
9
|
+
* React unmounts the tree.
|
|
10
|
+
*
|
|
11
|
+
* Run: `node test/client.test.mjs`
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { splitHunks as hostSplitHunks } from '../index.js'
|
|
16
|
+
|
|
17
|
+
const SRC = fileURLToPath(new URL('../client.js', import.meta.url))
|
|
18
|
+
|
|
19
|
+
let failures = 0
|
|
20
|
+
const check = (label, cond, extra = '') => {
|
|
21
|
+
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
22
|
+
if (!cond) failures += 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---- load the bundle the way the kernel does --------------------------------
|
|
26
|
+
let registration = null
|
|
27
|
+
const window = {
|
|
28
|
+
__ModuleLoader__: {
|
|
29
|
+
load: (entry) => {
|
|
30
|
+
registration = entry
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
// Enough of a window for the panel's layout measurement to run in the test.
|
|
34
|
+
innerHeight: 943,
|
|
35
|
+
innerWidth: 1280,
|
|
36
|
+
addEventListener: () => {},
|
|
37
|
+
removeEventListener: () => {},
|
|
38
|
+
// `nearestScroller` identifies a scroller by its computed `overflow-y`; a
|
|
39
|
+
// fixture node opts in by carrying the flag this returns.
|
|
40
|
+
getComputedStyle: (node) => ({ overflowY: node?.scrolls === true ? 'auto' : 'visible' }),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A hook-capable React stand-in. Not React, but it exercises the contract the
|
|
45
|
+
* component relies on: stable hook order, state updates that schedule a
|
|
46
|
+
* re-render, and effects that run once after mount.
|
|
47
|
+
*/
|
|
48
|
+
let currentHooks = []
|
|
49
|
+
let cursor = 0
|
|
50
|
+
let effects = []
|
|
51
|
+
let dirty = false
|
|
52
|
+
|
|
53
|
+
const React = {
|
|
54
|
+
createElement(type, props, ...children) {
|
|
55
|
+
const element = { type, props: props ?? {}, children: children.flat(Infinity) }
|
|
56
|
+
// Render nested function components eagerly. Without this the harness only
|
|
57
|
+
// ever executes the top-level GitView and every child (FileRow, LaneGraph,
|
|
58
|
+
// DiffText) stays an inert object — which is exactly how a crash inside
|
|
59
|
+
// FileRow slipped past the first version of this test.
|
|
60
|
+
if (typeof type === 'function') {
|
|
61
|
+
const outerHooks = currentHooks
|
|
62
|
+
const outerCursor = cursor
|
|
63
|
+
// These children hold no state, so their hook slots need not persist
|
|
64
|
+
// between renders; isolating them keeps GitView's own slots aligned.
|
|
65
|
+
currentHooks = []
|
|
66
|
+
cursor = 0
|
|
67
|
+
try {
|
|
68
|
+
return type(element.props)
|
|
69
|
+
} finally {
|
|
70
|
+
currentHooks = outerHooks
|
|
71
|
+
cursor = outerCursor
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return element
|
|
75
|
+
},
|
|
76
|
+
useState(initial) {
|
|
77
|
+
const index = cursor++
|
|
78
|
+
if (!(index in currentHooks)) currentHooks[index] = typeof initial === 'function' ? initial() : initial
|
|
79
|
+
const set = (next) => {
|
|
80
|
+
currentHooks[index] = typeof next === 'function' ? next(currentHooks[index]) : next
|
|
81
|
+
dirty = true
|
|
82
|
+
}
|
|
83
|
+
return [currentHooks[index], set]
|
|
84
|
+
},
|
|
85
|
+
useEffect(fn) {
|
|
86
|
+
const index = cursor++
|
|
87
|
+
if (!(index in currentHooks)) {
|
|
88
|
+
currentHooks[index] = true
|
|
89
|
+
effects.push(fn)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
useLayoutEffect(fn) {
|
|
93
|
+
const index = cursor++
|
|
94
|
+
if (!(index in currentHooks)) {
|
|
95
|
+
currentHooks[index] = true
|
|
96
|
+
effects.push(fn)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
useRef(initial) {
|
|
100
|
+
const index = cursor++
|
|
101
|
+
if (!(index in currentHooks)) currentHooks[index] = { current: initial }
|
|
102
|
+
return currentHooks[index]
|
|
103
|
+
},
|
|
104
|
+
useCallback: (fn) => {
|
|
105
|
+
cursor++
|
|
106
|
+
return fn
|
|
107
|
+
},
|
|
108
|
+
useMemo: (fn) => {
|
|
109
|
+
cursor++
|
|
110
|
+
return fn()
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const code = readFileSync(SRC, 'utf8')
|
|
115
|
+
// A syntax error in the bundle would surface here, exactly as it would in the browser.
|
|
116
|
+
new Function('window', code)(window)
|
|
117
|
+
|
|
118
|
+
check('bundle registers one module factory', registration !== null && typeof registration.factory === 'function')
|
|
119
|
+
check('bundle id matches the package name', registration?.id === '@xiaoming_z/dsh-git', registration?.id)
|
|
120
|
+
|
|
121
|
+
const requireStub = (spec) => {
|
|
122
|
+
if (spec === 'react') return React
|
|
123
|
+
throw new Error(`unexpected external request: ${spec}`)
|
|
124
|
+
}
|
|
125
|
+
const exports = registration.factory(requireStub)
|
|
126
|
+
check('exports apply/inject', typeof exports.apply === 'function' && Array.isArray(exports.inject))
|
|
127
|
+
check(
|
|
128
|
+
'inject names the services the body uses',
|
|
129
|
+
JSON.stringify(exports.inject) === JSON.stringify(['slots', 'locale', 'sessions']),
|
|
130
|
+
JSON.stringify(exports.inject),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// ---- translation seats ------------------------------------------------------
|
|
134
|
+
const { DICTS } = exports
|
|
135
|
+
/**
|
|
136
|
+
* Build a translate function from a dictionary, mirroring `{name}` substitution.
|
|
137
|
+
* @param locale - `zh` or `en`.
|
|
138
|
+
* @returns the translate function the slot would inject.
|
|
139
|
+
*/
|
|
140
|
+
const makeT = (locale) => (key, params) => {
|
|
141
|
+
const template = DICTS[locale][key] ?? key
|
|
142
|
+
if (params === undefined) return template
|
|
143
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
|
|
144
|
+
}
|
|
145
|
+
const tZh = makeT('zh')
|
|
146
|
+
const tEn = makeT('en')
|
|
147
|
+
|
|
148
|
+
check(
|
|
149
|
+
'both built-in locales define exactly the same keys',
|
|
150
|
+
JSON.stringify(Object.keys(DICTS.zh).sort()) === JSON.stringify(Object.keys(DICTS.en).sort()),
|
|
151
|
+
`zh ${Object.keys(DICTS.zh).length} / en ${Object.keys(DICTS.en).length}`,
|
|
152
|
+
)
|
|
153
|
+
check(
|
|
154
|
+
'no English entry is left as its own key',
|
|
155
|
+
Object.entries(DICTS.en).every(([key, value]) => value !== key),
|
|
156
|
+
)
|
|
157
|
+
check(
|
|
158
|
+
'every zh and en value is a string',
|
|
159
|
+
Object.values(DICTS.zh).every((v) => typeof v === 'string') &&
|
|
160
|
+
Object.values(DICTS.en).every((v) => typeof v === 'string'),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// ---- apply() wiring ---------------------------------------------------------
|
|
164
|
+
const effectsSeen = []
|
|
165
|
+
const sources = []
|
|
166
|
+
const injectedDeps = []
|
|
167
|
+
const registered = []
|
|
168
|
+
const injected = []
|
|
169
|
+
const ctx = {
|
|
170
|
+
effect: (fn, label) => {
|
|
171
|
+
effectsSeen.push(label)
|
|
172
|
+
return fn()
|
|
173
|
+
},
|
|
174
|
+
locale: {
|
|
175
|
+
register: (ns, dict) => {
|
|
176
|
+
registered.push(['locale', ns, dict])
|
|
177
|
+
return () => {}
|
|
178
|
+
},
|
|
179
|
+
// `apply` binds once; the fake hands back the zh seat.
|
|
180
|
+
bind: () => tZh,
|
|
181
|
+
},
|
|
182
|
+
slots: {
|
|
183
|
+
inject: (name, fn) => {
|
|
184
|
+
injected.push(name)
|
|
185
|
+
fn()
|
|
186
|
+
},
|
|
187
|
+
register: (descriptor, Component) => {
|
|
188
|
+
registered.push(['slot', descriptor, Component])
|
|
189
|
+
return () => {}
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
// Models the real timing: the service is NOT resolvable through `get` when
|
|
193
|
+
// the plugin applies, and `inject` is how a plugin waits for it. The earlier
|
|
194
|
+
// fake handed `get` the service directly, which is why it never caught the
|
|
195
|
+
// plugin dying on `undefined.registerSource`.
|
|
196
|
+
get: () => undefined,
|
|
197
|
+
inject: (deps, callback) => {
|
|
198
|
+
injectedDeps.push(deps)
|
|
199
|
+
callback({ ...ctx, inputTriggers: { registerSource: (src) => { sources.push(src); return () => {} } } })
|
|
200
|
+
},
|
|
201
|
+
sessions: {
|
|
202
|
+
list: {
|
|
203
|
+
getSnapshot: () => ({
|
|
204
|
+
ids: ['s1'],
|
|
205
|
+
byId: { s1: { id: 's1', cwd: '/repo/from-session' } },
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
// Guarded: a throw here used to abort the whole file, losing every other
|
|
211
|
+
// assertion's result. It is exactly the failure an eager service read causes.
|
|
212
|
+
let applyError = null
|
|
213
|
+
try {
|
|
214
|
+
exports.apply(ctx)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
applyError = error
|
|
217
|
+
}
|
|
218
|
+
check('apply finishes without throwing', applyError === null, applyError?.message ?? '')
|
|
219
|
+
check('registers a locale namespace', registered.some(([kind, ns]) => kind === 'locale' && ns === 'git'))
|
|
220
|
+
check('declares the conversation.view contribution', injected.includes('conversation.view'), injected.join(','))
|
|
221
|
+
check('waits for the trigger service instead of reading it eagerly', JSON.stringify(injectedDeps).includes('inputTriggers'), JSON.stringify(injectedDeps))
|
|
222
|
+
check('registers an @ source for git references', sources.length === 1 && sources[0].trigger === '@' && sources[0].name === 'git', JSON.stringify(sources.map((s) => `${s.trigger}${s.name}`)))
|
|
223
|
+
|
|
224
|
+
// The regression: an optional service being absent must not take the whole
|
|
225
|
+
// plugin down. Reading it eagerly threw and the panel never loaded at all.
|
|
226
|
+
const sourcesBefore = sources.length
|
|
227
|
+
let survivedWithoutService = true
|
|
228
|
+
try {
|
|
229
|
+
exports.apply({
|
|
230
|
+
effect: (fn) => fn(),
|
|
231
|
+
locale: { register: () => () => {}, bind: () => tZh },
|
|
232
|
+
slots: { inject: () => {}, register: () => () => {} },
|
|
233
|
+
sessions: { list: { getSnapshot: () => ({ byId: {} }) } },
|
|
234
|
+
get: () => undefined,
|
|
235
|
+
// The service never arrives in this deployment.
|
|
236
|
+
inject: () => {},
|
|
237
|
+
})
|
|
238
|
+
} catch (error) {
|
|
239
|
+
survivedWithoutService = false
|
|
240
|
+
}
|
|
241
|
+
check('a deployment without the trigger service still mounts the panel', survivedWithoutService)
|
|
242
|
+
check('and registers no source in that case', sources.length === sourcesBefore)
|
|
243
|
+
const slot = registered.find(([kind, descriptor]) => kind === 'slot' && descriptor?.name === 'conversation.view')
|
|
244
|
+
check(
|
|
245
|
+
'slot descriptor is a conversation.view contribution with an id + label',
|
|
246
|
+
slot?.[1]?.name === 'conversation.view' && slot?.[1]?.id === 'git' && typeof slot?.[1]?.label === 'function',
|
|
247
|
+
JSON.stringify({ ...slot?.[1], label: slot?.[1]?.label?.() }),
|
|
248
|
+
)
|
|
249
|
+
check('slot contribution passes a component', typeof slot?.[2] === 'function')
|
|
250
|
+
|
|
251
|
+
// ---- session ↔ repository binding -------------------------------------------
|
|
252
|
+
const { sessionCwd } = exports
|
|
253
|
+
check(
|
|
254
|
+
'the contribution resolves the active session working directory',
|
|
255
|
+
typeof slot?.[1]?.inject === 'function' &&
|
|
256
|
+
slot[1].inject('s1')?.sessionRoot === '/repo/from-session',
|
|
257
|
+
JSON.stringify(slot?.[1]?.inject?.('s1')),
|
|
258
|
+
)
|
|
259
|
+
check(
|
|
260
|
+
'an unknown session resolves to no root rather than throwing',
|
|
261
|
+
sessionCwd(ctx, 'nope') === null && sessionCwd(ctx, '') === null && sessionCwd(ctx, undefined) === null,
|
|
262
|
+
)
|
|
263
|
+
check('a shell without the sessions service degrades to null', sessionCwd({}, 's1') === null)
|
|
264
|
+
check(
|
|
265
|
+
'a malformed summary degrades to null',
|
|
266
|
+
sessionCwd({ sessions: { list: { getSnapshot: () => ({ byId: { s1: { cwd: 42 } } }) } } }, 's1') === null,
|
|
267
|
+
)
|
|
268
|
+
check(
|
|
269
|
+
'a throwing sessions service degrades to null',
|
|
270
|
+
sessionCwd({ sessions: { list: { getSnapshot: () => { throw new Error('nope') } } } }, 's1') === null,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
// ---- lane assignment --------------------------------------------------------
|
|
274
|
+
const c = (hash, ...parents) => ({ hash, parents })
|
|
275
|
+
let rows = exports.computeLanes([c('c', 'b'), c('b', 'a'), c('a')])
|
|
276
|
+
check('linear history stays in one lane', rows.every((row) => row.lane === 0), rows.map((row) => row.lane).join(','))
|
|
277
|
+
|
|
278
|
+
rows = exports.computeLanes([c('m', 'a', 'b'), c('b', 'base'), c('a', 'base'), c('base')])
|
|
279
|
+
check(
|
|
280
|
+
'a merge parent claims its own lane',
|
|
281
|
+
rows[0].lane === 0 && rows[1].lane === 1,
|
|
282
|
+
rows.map((row) => `${row.commit.hash}@${row.lane}`).join(' '),
|
|
283
|
+
)
|
|
284
|
+
check('the merge row records both active lanes', rows[0].lanesAfter.length >= 1, JSON.stringify(rows[0].lanesAfter))
|
|
285
|
+
|
|
286
|
+
rows = exports.computeLanes([c('x'), c('y')])
|
|
287
|
+
check('disjoint roots occupy separate lanes', rows[0].lane === 0 && rows[1].lane === 0)
|
|
288
|
+
|
|
289
|
+
rows = exports.computeLanes([c('a'), c('b')])
|
|
290
|
+
check('lane state is drained at the tail', rows[rows.length - 1].lanesAfter.length === 0)
|
|
291
|
+
|
|
292
|
+
/** Widest gutter any row needs. */
|
|
293
|
+
const widthOf = (list) =>
|
|
294
|
+
Math.max(...list.map((row) => Math.max(row.lanesBefore.length, row.lanesAfter.length, row.lane + 1)))
|
|
295
|
+
|
|
296
|
+
// Regression: the graph used to widen monotonically. A lane freed in the
|
|
297
|
+
// MIDDLE of the table was never reclaimed (only trailing empties were trimmed),
|
|
298
|
+
// so the next branch appended a new column instead of filling the hole.
|
|
299
|
+
rows = exports.computeLanes([c('m', 'x', 'r'), c('x'), c('y', 'r'), c('r')])
|
|
300
|
+
check('a freed interior lane is reused instead of widening the graph', widthOf(rows) === 2, `width=${widthOf(rows)}`)
|
|
301
|
+
|
|
302
|
+
// Regression: a parent outside the loaded window held its lane open forever.
|
|
303
|
+
rows = exports.computeLanes([{ hash: 'only', parents: ['outside-the-window'] }])
|
|
304
|
+
check('a parent outside the window releases its lane', rows[0].lanesAfter.length === 0, JSON.stringify(rows[0].lanesAfter))
|
|
305
|
+
|
|
306
|
+
// Regression: several lanes awaiting one merge target only converged for the
|
|
307
|
+
// first, so the rest stayed open.
|
|
308
|
+
rows = exports.computeLanes([c('t', 'a', 'b'), c('b', 't'), c('a', 't'), c('t')])
|
|
309
|
+
check(
|
|
310
|
+
'every lane converging on one commit collapses',
|
|
311
|
+
rows[1].lanesAfter.filter((lane) => lane === 't').length <= 1,
|
|
312
|
+
JSON.stringify(rows[1].lanesAfter),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// A merge-heavy history must not grow a column per commit — the failure a real
|
|
316
|
+
// repository showed, where the gutter squeezed the subject text to nothing.
|
|
317
|
+
const busy = []
|
|
318
|
+
for (let i = 0; i < 120; i += 1) {
|
|
319
|
+
busy.push({ hash: `c${i}`, parents: i % 3 === 0 && i > 0 ? [`s${i}`, `c${i + 1}`] : [`c${i + 1}`] })
|
|
320
|
+
if (i % 3 === 0 && i > 0) busy.push({ hash: `s${i}`, parents: [`c${i + 1}`] })
|
|
321
|
+
}
|
|
322
|
+
rows = exports.computeLanes(busy)
|
|
323
|
+
check('a 160-commit merge-heavy history stays narrow', widthOf(rows) <= 3, `width=${widthOf(rows)} over ${rows.length} rows`)
|
|
324
|
+
check(
|
|
325
|
+
'the gutter does not grow monotonically with depth',
|
|
326
|
+
widthOf(rows.slice(0, 20)) >= widthOf(rows.slice(-20)),
|
|
327
|
+
`first20=${widthOf(rows.slice(0, 20))} last20=${widthOf(rows.slice(-20))}`,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
// ---- mount → fetch → re-render ----------------------------------------------
|
|
331
|
+
/** Realistic host payloads for the endpoints the panel calls. */
|
|
332
|
+
const HOST = {
|
|
333
|
+
'/status': {
|
|
334
|
+
ok: true,
|
|
335
|
+
data: {
|
|
336
|
+
root: '/repo',
|
|
337
|
+
branch: { head: 'main', oid: 'abc', upstream: null, ahead: 2, behind: 1, detached: false, initial: false },
|
|
338
|
+
staged: [{ path: 'a.txt', index: 'M', worktree: '.', origPath: null }],
|
|
339
|
+
unstaged: [{ path: 'b.txt', index: '.', worktree: 'M', origPath: null }],
|
|
340
|
+
untracked: [{ path: 'c.txt', index: '?', worktree: '?', origPath: null }],
|
|
341
|
+
conflicted: [{ path: 'd.txt', index: 'U', worktree: 'U', origPath: null }],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
'/log': {
|
|
345
|
+
ok: true,
|
|
346
|
+
data: {
|
|
347
|
+
root: '/repo',
|
|
348
|
+
commits: [
|
|
349
|
+
{
|
|
350
|
+
hash: 'a'.repeat(40),
|
|
351
|
+
parents: ['b'.repeat(40)],
|
|
352
|
+
author: 'T',
|
|
353
|
+
email: 't@e.com',
|
|
354
|
+
at: 1_700_000_000_000,
|
|
355
|
+
subject: 'merge things',
|
|
356
|
+
},
|
|
357
|
+
{ hash: 'b'.repeat(40), parents: [], author: 'T', email: 't@e.com', at: 1_699_000_000_000, subject: 'root' },
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
'/diff': { ok: true, data: { root: '/repo', file: 'a.txt', staged: false, text: '@@ -1 +1 @@\n-old\n+new\n' } },
|
|
362
|
+
'/branches': {
|
|
363
|
+
ok: true,
|
|
364
|
+
data: {
|
|
365
|
+
root: '/repo',
|
|
366
|
+
head: 'main',
|
|
367
|
+
local: [
|
|
368
|
+
{ name: 'main', current: true, upstream: 'origin/main', oid: 'abc1234' },
|
|
369
|
+
{ name: 'feature', current: false, upstream: null, oid: 'def5678' },
|
|
370
|
+
],
|
|
371
|
+
remote: [{ name: 'origin/main', remote: 'origin', branch: 'main', oid: 'abc1234' }],
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
'/commit': {
|
|
375
|
+
ok: true,
|
|
376
|
+
data: {
|
|
377
|
+
root: '/repo',
|
|
378
|
+
commit: { hash: 'a'.repeat(40), author: 'T', at: 1_700_000_000_000, subject: 'merge things' },
|
|
379
|
+
files: [{ status: 'M', score: null, path: 'a.txt', origPath: null }],
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
'/show': { ok: true, data: { root: '/repo', text: 'diff --git a/a.txt b/a.txt\n+a\n' } },
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Mount the panel, run its effect, let the fetches settle, and re-render until
|
|
387
|
+
* quiet — the full cycle a real mount performs.
|
|
388
|
+
* @param seed - hook slots to pre-set (e.g. the `mode` hook) before mounting.
|
|
389
|
+
* @param props - the props the component is rendered with.
|
|
390
|
+
* @param component - the component to render; the panel by default.
|
|
391
|
+
* @returns the final tree, any crash, and the endpoints requested.
|
|
392
|
+
*/
|
|
393
|
+
async function mount(
|
|
394
|
+
seed = {},
|
|
395
|
+
props = { sessionRoot: '/repo/from-session', t: tZh },
|
|
396
|
+
component = exports.GitView,
|
|
397
|
+
) {
|
|
398
|
+
const fetched = []
|
|
399
|
+
globalThis.fetch = async (url) => {
|
|
400
|
+
const path = String(url).replace('/git-api', '').split('?')[0]
|
|
401
|
+
fetched.push(path)
|
|
402
|
+
const payload = HOST[path] ?? { ok: false, error: `mock has no ${path}` }
|
|
403
|
+
return { status: payload.ok ? 200 : 400, json: async () => payload }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
currentHooks = []
|
|
407
|
+
for (const [index, value] of Object.entries(seed)) currentHooks[Number(index)] = value
|
|
408
|
+
cursor = 0
|
|
409
|
+
effects = []
|
|
410
|
+
dirty = false
|
|
411
|
+
|
|
412
|
+
let tree = null
|
|
413
|
+
let crash = null
|
|
414
|
+
try {
|
|
415
|
+
tree = component(props)
|
|
416
|
+
} catch (error) {
|
|
417
|
+
crash = error
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const fn of effects) {
|
|
421
|
+
try {
|
|
422
|
+
fn()
|
|
423
|
+
} catch (error) {
|
|
424
|
+
crash = crash ?? error
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for (let i = 0; i < 40; i += 1) {
|
|
429
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
430
|
+
if (!dirty) {
|
|
431
|
+
if (i > 4) break
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
dirty = false
|
|
435
|
+
cursor = 0
|
|
436
|
+
effects = []
|
|
437
|
+
try {
|
|
438
|
+
tree = component(props)
|
|
439
|
+
} catch (error) {
|
|
440
|
+
crash = error
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return { tree, crash, fetched }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The worktree pane is the default view; the commit list lives in the history
|
|
449
|
+
// pane, so each mode gets its own mount.
|
|
450
|
+
const worktree = await mount({ 2: 'worktree' })
|
|
451
|
+
check('first paint and post-load render do not throw', worktree.crash === null, worktree.crash?.stack ?? '')
|
|
452
|
+
check(
|
|
453
|
+
'the panel requested host data',
|
|
454
|
+
worktree.fetched.includes('/status') && worktree.fetched.includes('/log'),
|
|
455
|
+
worktree.fetched.join(','),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
const worktreeFlat = JSON.stringify(worktree.tree ?? {})
|
|
459
|
+
check('worktree pane shows the branch name', worktreeFlat.includes('main'))
|
|
460
|
+
check('worktree pane shows the changed file', worktreeFlat.includes('a.txt'))
|
|
461
|
+
check('no literal undefined leaked into a file row', !worktreeFlat.includes('undefined'))
|
|
462
|
+
check('both view tabs are present', worktreeFlat.includes('工作区') && worktreeFlat.includes('历史'))
|
|
463
|
+
check('the commit box is present', worktreeFlat.includes('提交 '))
|
|
464
|
+
check(
|
|
465
|
+
'an empty path box advertises the session workspace it is following',
|
|
466
|
+
worktreeFlat.includes('跟随会话工作区'),
|
|
467
|
+
'',
|
|
468
|
+
)
|
|
469
|
+
// The action button is the one place a child component reads its own prop, so
|
|
470
|
+
// it is where a prop-name slip shows up.
|
|
471
|
+
check(
|
|
472
|
+
'worktree pane renders the stage action for a changed file',
|
|
473
|
+
worktreeFlat.includes('暂存') && worktreeFlat.includes('git add'),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
// Hook order is rootInput, appliedRoot, mode — seeding slot 2 opens the panel
|
|
477
|
+
// straight into the history pane.
|
|
478
|
+
const history = await mount({ 2: 'history' })
|
|
479
|
+
check('history pane renders without throwing', history.crash === null, history.crash?.stack ?? '')
|
|
480
|
+
const historyFlat = JSON.stringify(history.tree ?? {})
|
|
481
|
+
check('history pane shows commit subjects', historyFlat.includes('merge things') && historyFlat.includes('root'))
|
|
482
|
+
check('history pane asks for nothing until a commit is picked', history.fetched.includes('/status'))
|
|
483
|
+
check('no literal undefined leaked into the history pane', !historyFlat.includes('undefined'))
|
|
484
|
+
// Three columns: branches | commits | changes.
|
|
485
|
+
check('history pane fetched the branch list', history.fetched.includes('/branches'))
|
|
486
|
+
check(
|
|
487
|
+
'branch column lists local and remote branches',
|
|
488
|
+
historyFlat.includes('本地') && historyFlat.includes('远程') && historyFlat.includes('origin') && historyFlat.includes('feature'),
|
|
489
|
+
'',
|
|
490
|
+
)
|
|
491
|
+
check(
|
|
492
|
+
'the details column prompts until a commit is picked',
|
|
493
|
+
historyFlat.includes('选择中间一栏的提交查看改动'),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
// The hunk split exists on both sides, and the indices the browser sends must
|
|
497
|
+
// line up with the ones the host derives from the same text.
|
|
498
|
+
const SAMPLE_DIFF = [
|
|
499
|
+
'diff --git a/a.txt b/a.txt',
|
|
500
|
+
'index 111..222 100644',
|
|
501
|
+
'--- a/a.txt',
|
|
502
|
+
'+++ b/a.txt',
|
|
503
|
+
'@@ -1,3 +1,3 @@',
|
|
504
|
+
' keep',
|
|
505
|
+
'-old one',
|
|
506
|
+
'+new one',
|
|
507
|
+
'@@ -20,3 +20,3 @@',
|
|
508
|
+
' keep two',
|
|
509
|
+
'-old two',
|
|
510
|
+
'+new two',
|
|
511
|
+
'',
|
|
512
|
+
].join('\n')
|
|
513
|
+
const clientSplit = exports.splitHunks(SAMPLE_DIFF)
|
|
514
|
+
const hostSplit = hostSplitHunks(SAMPLE_DIFF)
|
|
515
|
+
check('client and host agree on the hunk split', JSON.stringify(clientSplit) === JSON.stringify(hostSplit), `${clientSplit.hunks.length} vs ${hostSplit.hunks.length}`)
|
|
516
|
+
check('the split separates the file header from the hunks', clientSplit.header.length === 4 && clientSplit.hunks.length === 2, `header=${clientSplit.header.length} hunks=${clientSplit.hunks.length}`)
|
|
517
|
+
|
|
518
|
+
// Rendering a diff seeds the `file` and `fileDiff` state slots (indices 7 and 8)
|
|
519
|
+
// so the worktree pane paints hunks instead of the empty prompt.
|
|
520
|
+
const withDiff = await mount({
|
|
521
|
+
2: 'worktree',
|
|
522
|
+
7: { path: 'a.txt', staged: false, untracked: false },
|
|
523
|
+
8: SAMPLE_DIFF,
|
|
524
|
+
})
|
|
525
|
+
check('a rendered diff does not throw', withDiff.crash === null, withDiff.crash?.stack ?? '')
|
|
526
|
+
const diffFlat = JSON.stringify(withDiff.tree ?? {})
|
|
527
|
+
check('each hunk gets its own action bar', diffFlat.includes('改动块 1/2') && diffFlat.includes('改动块 2/2'))
|
|
528
|
+
check('an unstaged hunk offers to stage itself', diffFlat.includes('暂存此块'))
|
|
529
|
+
// An untracked file has no index entry to patch, so it must not offer hunks.
|
|
530
|
+
const untrackedDiff = await mount({
|
|
531
|
+
2: 'worktree',
|
|
532
|
+
7: { path: 'new.txt', staged: false, untracked: true },
|
|
533
|
+
8: SAMPLE_DIFF,
|
|
534
|
+
})
|
|
535
|
+
check(
|
|
536
|
+
'an untracked file offers no hunk action',
|
|
537
|
+
!JSON.stringify(untrackedDiff.tree ?? {}).includes('暂存此块'),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
// ---- resizable columns ------------------------------------------------------
|
|
541
|
+
const { clampWidth, readWidths, COLUMN_LIMITS } = exports
|
|
542
|
+
const fakeStorage = (value) => ({ getItem: () => value, setItem: () => {} })
|
|
543
|
+
const [BRANCH_MIN, BRANCH_MAX] = COLUMN_LIMITS.branches
|
|
544
|
+
|
|
545
|
+
check(
|
|
546
|
+
'clampWidth holds a width inside its column bounds',
|
|
547
|
+
clampWidth('branches', 10) === BRANCH_MIN && clampWidth('branches', 9999) === BRANCH_MAX,
|
|
548
|
+
`${clampWidth('branches', 10)}..${clampWidth('branches', 9999)}`,
|
|
549
|
+
)
|
|
550
|
+
check(
|
|
551
|
+
'clampWidth rejects a non-numeric width',
|
|
552
|
+
clampWidth('branches', 'wide') === null && clampWidth('branches', Number.NaN) === null,
|
|
553
|
+
)
|
|
554
|
+
check(
|
|
555
|
+
'readWidths keeps a valid stored width',
|
|
556
|
+
readWidths(fakeStorage(JSON.stringify({ branches: 260, commits: 500, files: 300 }))).branches === 260,
|
|
557
|
+
)
|
|
558
|
+
check('readWidths falls back to the default when nothing is stored', readWidths(fakeStorage(null)).branches === 190)
|
|
559
|
+
check('readWidths survives malformed JSON', readWidths(fakeStorage('{not json')).branches === 190)
|
|
560
|
+
check(
|
|
561
|
+
'readWidths clamps a stored value instead of trusting it',
|
|
562
|
+
readWidths(fakeStorage(JSON.stringify({ branches: 99999 }))).branches === BRANCH_MAX,
|
|
563
|
+
)
|
|
564
|
+
check(
|
|
565
|
+
'readWidths ignores an unknown column and keeps the rest',
|
|
566
|
+
readWidths(fakeStorage(JSON.stringify({ branches: 300, bogus: 10 }))).commits === 380,
|
|
567
|
+
)
|
|
568
|
+
check(
|
|
569
|
+
'readWidths tolerates a storage that throws',
|
|
570
|
+
readWidths({ getItem: () => { throw new Error('denied') } }).files === 380,
|
|
571
|
+
)
|
|
572
|
+
check('readWidths tolerates no storage at all', readWidths(null).files === 380)
|
|
573
|
+
|
|
574
|
+
const { nextWidth } = exports
|
|
575
|
+
check('a rightward drag widens the column by the pointer travel', nextWidth('branches', 200, 500, 560) === 260)
|
|
576
|
+
check('a leftward drag narrows it by the same amount', nextWidth('branches', 200, 500, 440) === 140)
|
|
577
|
+
check('a drag past the minimum stops at it', nextWidth('branches', 200, 500, 100) === BRANCH_MIN)
|
|
578
|
+
check('a drag past the maximum stops at it', nextWidth('branches', 300, 500, 900) === BRANCH_MAX)
|
|
579
|
+
|
|
580
|
+
const countSplitters = (mounted) => (JSON.stringify(mounted.tree ?? {}).match(/col-resize/g) ?? []).length
|
|
581
|
+
check('the worktree view renders one column splitter', countSplitters(worktree) === 1, `${countSplitters(worktree)}`)
|
|
582
|
+
check('the history view renders two column splitters', countSplitters(history) === 2, `${countSplitters(history)}`)
|
|
583
|
+
|
|
584
|
+
// ---- layering against the shell's session-column resize strip ----------------
|
|
585
|
+
// The shell puts an invisible 40px `cursor: col-resize` strip at z-index 8 over
|
|
586
|
+
// this view, so the panel has to paint above it; and because the composer
|
|
587
|
+
// floats at z-index 7, the panel may only take the pointer where it actually
|
|
588
|
+
// has content. Both halves of that contract are easy to break silently.
|
|
589
|
+
const rootStyle = worktree.tree?.props?.style ?? {}
|
|
590
|
+
const innerPanel = worktree.tree?.children?.[0] ?? {}
|
|
591
|
+
const panelStyle = innerPanel.props?.style ?? {}
|
|
592
|
+
|
|
593
|
+
check('the panel paints above the shell resize strip', Number(rootStyle.zIndex) > 8, `zIndex=${rootStyle.zIndex}`)
|
|
594
|
+
check('the shell box is positioned so its z-index applies', rootStyle.position === 'relative')
|
|
595
|
+
check('the shell box is not hit-testable itself', rootStyle.pointerEvents === 'none')
|
|
596
|
+
check(
|
|
597
|
+
'the panel does not inset itself for the composer (this view occludes it on purpose)',
|
|
598
|
+
rootStyle.paddingBottom === undefined,
|
|
599
|
+
String(rootStyle.paddingBottom),
|
|
600
|
+
)
|
|
601
|
+
check('the shell box paints no background', rootStyle.background === undefined)
|
|
602
|
+
check(
|
|
603
|
+
'the fallback height leaves the panel room to reach the window bottom',
|
|
604
|
+
/calc\(100vh - (\d+)px\)/.test(String(rootStyle.height)) &&
|
|
605
|
+
Number(/calc\(100vh - (\d+)px\)/.exec(String(rootStyle.height))[1]) <= 110,
|
|
606
|
+
String(rootStyle.height),
|
|
607
|
+
)
|
|
608
|
+
check('the inner panel is the hit-testable one', panelStyle.pointerEvents === 'auto')
|
|
609
|
+
check('the inner panel carries the background', typeof panelStyle.background === 'string')
|
|
610
|
+
check('the inner panel fills the shell box', panelStyle.flex === '1 1 auto' && panelStyle.minHeight === 0)
|
|
611
|
+
|
|
612
|
+
// ---- filling the view so the composer stays occluded ------------------------
|
|
613
|
+
// A `max-height` alone left a short worktree panel content-sized, which let the
|
|
614
|
+
// composer reappear below it. The panel measures from its own top edge to the
|
|
615
|
+
// window bottom instead. Hook slot 20 is the ref (`fillHeight` is 19).
|
|
616
|
+
const measured = await mount({ 20: { current: { getBoundingClientRect: () => ({ top: 82 }) } } })
|
|
617
|
+
check('the measurement does not throw', measured.crash === null, measured.crash?.stack ?? '')
|
|
618
|
+
check(
|
|
619
|
+
'the panel is given an explicit height measured from its own top edge',
|
|
620
|
+
measured.tree?.props?.style?.height === '861px',
|
|
621
|
+
`height=${measured.tree?.props?.style?.height} (window 943 - top 82)`,
|
|
622
|
+
)
|
|
623
|
+
check(
|
|
624
|
+
'the viewport estimate remains the fallback when there is nothing to measure',
|
|
625
|
+
String(worktree.tree?.props?.style?.height).includes('100vh'),
|
|
626
|
+
String(worktree.tree?.props?.style?.height),
|
|
627
|
+
)
|
|
628
|
+
check(
|
|
629
|
+
'a definite shell height still wins over the measurement',
|
|
630
|
+
worktree.tree?.props?.style?.maxHeight === '100%',
|
|
631
|
+
String(worktree.tree?.props?.style?.maxHeight),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
// ---- English rendering ------------------------------------------------------
|
|
635
|
+
const englishMount = await mount({ 2: 'history' }, { sessionRoot: '/repo/from-session', t: tEn })
|
|
636
|
+
check('the history pane renders in English without throwing', englishMount.crash === null, englishMount.crash?.stack ?? '')
|
|
637
|
+
const englishFlat = JSON.stringify(englishMount.tree ?? {})
|
|
638
|
+
check(
|
|
639
|
+
'the English render uses the git vocabulary rather than transliterations',
|
|
640
|
+
englishFlat.includes('History') && englishFlat.includes('Branches') && englishFlat.includes('Checkout'),
|
|
641
|
+
'',
|
|
642
|
+
)
|
|
643
|
+
check(
|
|
644
|
+
'the English render contains no Chinese at all',
|
|
645
|
+
!/[\u4e00-\u9fff]/.test(englishFlat),
|
|
646
|
+
(englishFlat.match(/[\u4e00-\u9fff]+/g) ?? []).slice(0, 5).join(' '),
|
|
647
|
+
)
|
|
648
|
+
const englishWorktree = await mount({ 2: 'worktree' }, { sessionRoot: '/repo/from-session', t: tEn })
|
|
649
|
+
const englishWorktreeFlat = JSON.stringify(englishWorktree.tree ?? {})
|
|
650
|
+
check(
|
|
651
|
+
'the worktree pane is translated too',
|
|
652
|
+
englishWorktreeFlat.includes('Changes') &&
|
|
653
|
+
englishWorktreeFlat.includes('Stage') &&
|
|
654
|
+
englishWorktreeFlat.includes('Refresh'),
|
|
655
|
+
'',
|
|
656
|
+
)
|
|
657
|
+
check(
|
|
658
|
+
'the worktree English render contains no Chinese at all',
|
|
659
|
+
!/[\u4e00-\u9fff]/.test(englishWorktreeFlat),
|
|
660
|
+
(englishWorktreeFlat.match(/[\u4e00-\u9fff]+/g) ?? []).slice(0, 5).join(' '),
|
|
661
|
+
)
|
|
662
|
+
check(
|
|
663
|
+
'the Chinese render still carries Chinese copy',
|
|
664
|
+
/[\u4e00-\u9fff]/.test(JSON.stringify(worktree.tree ?? {})),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
// ---- git references for the model -------------------------------------------
|
|
668
|
+
const { encodeFileRef, encodeCommitRef, decodeRef, refText, gitCandidates } = exports
|
|
669
|
+
|
|
670
|
+
const fileRef = encodeFileRef({ path: 'src/a b.ts', status: 'M', added: 12, deleted: 3, binary: false }, 'worktree')
|
|
671
|
+
check(
|
|
672
|
+
'a file reference round-trips',
|
|
673
|
+
JSON.stringify(decodeRef(fileRef)) ===
|
|
674
|
+
JSON.stringify({ kind: 'file', mode: 'worktree', status: 'M', added: 12, deleted: 3, binary: false, path: 'src/a b.ts' }),
|
|
675
|
+
fileRef,
|
|
676
|
+
)
|
|
677
|
+
// The path is the last field, so colons in it must not confuse the decoder.
|
|
678
|
+
const colonRef = encodeFileRef({ path: 'weird:name.txt', status: 'A', added: 1, deleted: 0, binary: false }, 'staged')
|
|
679
|
+
check(
|
|
680
|
+
'a path containing a colon survives the round trip',
|
|
681
|
+
decodeRef(colonRef).path === 'weird:name.txt' && decodeRef(colonRef).mode === 'staged',
|
|
682
|
+
decodeRef(colonRef).path,
|
|
683
|
+
)
|
|
684
|
+
check(
|
|
685
|
+
'a path with non-ASCII characters survives',
|
|
686
|
+
decodeRef(encodeFileRef({ path: '中文 文件.txt', status: '?', added: null, deleted: null, binary: false }, 'untracked')).path ===
|
|
687
|
+
'中文 文件.txt',
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
// Metadata only: no diff body, but the command that produces one.
|
|
691
|
+
const fileText = refText(fileRef)
|
|
692
|
+
check(
|
|
693
|
+
'the model gets the change metadata, not the diff',
|
|
694
|
+
fileText.includes('path="src/a b.ts"') && fileText.includes('added="12"') && fileText.includes('deleted="3"'),
|
|
695
|
+
fileText,
|
|
696
|
+
)
|
|
697
|
+
check('the model is told how to see it', fileText.includes('git diff -- src/a b.ts'), fileText)
|
|
698
|
+
check('a staged reference names its own command', refText(colonRef).includes('git diff --cached -- weird:name.txt'))
|
|
699
|
+
check(
|
|
700
|
+
'an untracked reference names the --no-index command',
|
|
701
|
+
refText(encodeFileRef({ path: 'new.txt', added: null, deleted: null, binary: false }, 'untracked')).includes('--no-index'),
|
|
702
|
+
)
|
|
703
|
+
check(
|
|
704
|
+
'a binary reference says so instead of inventing line counts',
|
|
705
|
+
refText(encodeFileRef({ path: 'logo.png', added: null, deleted: null, binary: true }, 'worktree')).includes('binary="true"'),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
const commitRef = encodeCommitRef({ hash: 'a'.repeat(40), subject: 'fix: "quoted" thing', files: 3 })
|
|
709
|
+
const commitText = refText(commitRef)
|
|
710
|
+
check(
|
|
711
|
+
'a commit reference carries its subject and file count',
|
|
712
|
+
commitText.includes('subject="fix: \'quoted\' thing"') && commitText.includes('files="3"'),
|
|
713
|
+
commitText,
|
|
714
|
+
)
|
|
715
|
+
check('a commit reference names its command', commitText.includes('git show aaaaaaa'))
|
|
716
|
+
// The worktree view feeds porcelain entries, which carry index/worktree rather
|
|
717
|
+
// than a single status; reading only `status` produced a reference with no
|
|
718
|
+
// metadata at all.
|
|
719
|
+
check(
|
|
720
|
+
'a porcelain entry reports the side that actually changed',
|
|
721
|
+
refText(encodeFileRef({ path: 'a.md', index: 'A', worktree: '.' }, 'worktree')).includes('status="A"') &&
|
|
722
|
+
refText(encodeFileRef({ path: 'b.md', index: '.', worktree: 'M' }, 'worktree')).includes('status="M"') &&
|
|
723
|
+
refText(encodeFileRef({ path: 'c.md', index: '?', worktree: '?' }, 'worktree')).includes('status="?"'),
|
|
724
|
+
)
|
|
725
|
+
check(
|
|
726
|
+
'a malformed reference is passed through rather than throwing',
|
|
727
|
+
refText('dsh-git:file:bogus') === 'dsh-git:file:bogus' && refText('not-ours') === 'not-ours',
|
|
728
|
+
)
|
|
729
|
+
check('an unknown prefix decodes to null', decodeRef('other:thing') === null && decodeRef(null) === null)
|
|
730
|
+
|
|
731
|
+
// Candidates: the session's repository, narrowed by the query.
|
|
732
|
+
const candidateCtx = {
|
|
733
|
+
sessions: { list: { getSnapshot: () => ({ byId: { s1: { cwd: '/repo' } } }) } },
|
|
734
|
+
}
|
|
735
|
+
HOST['/status'] = {
|
|
736
|
+
ok: true,
|
|
737
|
+
data: {
|
|
738
|
+
root: '/repo',
|
|
739
|
+
branch: { head: 'main' },
|
|
740
|
+
staged: [{ path: 'staged.ts', index: 'M', worktree: '.', origPath: null }],
|
|
741
|
+
unstaged: [{ path: 'loose.ts', index: '.', worktree: 'M', origPath: null }],
|
|
742
|
+
untracked: [],
|
|
743
|
+
conflicted: [],
|
|
744
|
+
},
|
|
745
|
+
}
|
|
746
|
+
const all = await gitCandidates(candidateCtx, { sessionId: 's1' }, { query: '' }, tEn)
|
|
747
|
+
// Two changed files from /status plus the two commits the /log mock returns.
|
|
748
|
+
check('candidates cover changed files and recent commits', all.length === 4, all.map((c) => c.name).join(' | '))
|
|
749
|
+
check(
|
|
750
|
+
'files are grouped ahead of commits',
|
|
751
|
+
all[0].section === 'Changed files' && all[all.length - 1].section === 'Recent commits',
|
|
752
|
+
all.map((c) => c.section).join(','),
|
|
753
|
+
)
|
|
754
|
+
check(
|
|
755
|
+
'a file candidate carries a usable reference',
|
|
756
|
+
decodeRef(all.find((c) => c.name === 'staged.ts')?.value).mode === 'staged',
|
|
757
|
+
)
|
|
758
|
+
check('a commit candidate carries a commit reference', decodeRef(all.find((c) => c.name.startsWith('a')).value).kind === 'commit')
|
|
759
|
+
const filtered = await gitCandidates(candidateCtx, { sessionId: 's1' }, { query: 'loose' }, tEn)
|
|
760
|
+
check('the query narrows the candidates', filtered.length === 1 && filtered[0].name === 'loose.ts', filtered.map((c) => c.name).join(','))
|
|
761
|
+
check(
|
|
762
|
+
'an unresolvable session yields no candidates',
|
|
763
|
+
(await gitCandidates(candidateCtx, { sessionId: 'nope' }, { query: '' }, tEn)).length === 0,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
// ---- colours come from theme tokens -----------------------------------------
|
|
767
|
+
// A palette picked to read on a dark background is close to invisible on a light
|
|
768
|
+
// one. Every colour the panel renders must therefore be a theme token, which the
|
|
769
|
+
// shell switches with the theme — never a literal that only suits one of them.
|
|
770
|
+
const HEX_COLOUR = /#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\b/
|
|
771
|
+
const hardcodedColours = (mounted) =>
|
|
772
|
+
(JSON.stringify(mounted.tree ?? {}).match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).join(' ')
|
|
773
|
+
|
|
774
|
+
check('the worktree pane hardcodes no colours', !HEX_COLOUR.test(JSON.stringify(worktree.tree ?? {})), hardcodedColours(worktree))
|
|
775
|
+
check('the history pane hardcodes no colours', !HEX_COLOUR.test(JSON.stringify(history.tree ?? {})), hardcodedColours(history))
|
|
776
|
+
check('a rendered diff hardcodes no colours', !HEX_COLOUR.test(JSON.stringify(withDiff.tree ?? {})), hardcodedColours(withDiff))
|
|
777
|
+
check(
|
|
778
|
+
'diff lines are coloured by the theme success/error tokens',
|
|
779
|
+
JSON.stringify(withDiff.tree ?? {}).includes('--dsw-alias-state-success-primary') &&
|
|
780
|
+
JSON.stringify(withDiff.tree ?? {}).includes('--dsw-alias-state-error-primary'),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
// ---- review view -------------------------------------------------------------
|
|
784
|
+
const REVIEW_SAMPLE = {
|
|
785
|
+
root: '/repo',
|
|
786
|
+
marker: 'a'.repeat(40),
|
|
787
|
+
head: 'abc1234',
|
|
788
|
+
commits: [{ hash: 'a'.repeat(40), parents: [], author: 'T', email: 't@e.com', at: Date.now() - 60_000, subject: 'agent work' }],
|
|
789
|
+
files: [
|
|
790
|
+
{ path: 'small.js', origPath: null, added: 2, deleted: 1, binary: false, status: 'M' },
|
|
791
|
+
{ path: 'big.js', origPath: null, added: 300, deleted: 4, binary: false, status: 'M' },
|
|
792
|
+
{ path: 'logo.png', origPath: null, added: null, deleted: null, binary: true, status: 'M' },
|
|
793
|
+
{ path: 'gone.txt', origPath: null, added: 0, deleted: 40, binary: false, status: 'D' },
|
|
794
|
+
{ path: 'clash.txt', origPath: null, added: 3, deleted: 3, binary: false, status: 'U' },
|
|
795
|
+
],
|
|
796
|
+
totals: { files: 5, added: 305, deleted: 48, binary: 1 },
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const { rankReviewFiles, readMarkers, MARKER_KEY } = exports
|
|
800
|
+
check(
|
|
801
|
+
'ranking puts conflicts, deletions, binaries and large changes first',
|
|
802
|
+
rankReviewFiles(REVIEW_SAMPLE.files)
|
|
803
|
+
.map((entry) => entry.file.path)
|
|
804
|
+
.join(',') === 'clash.txt,gone.txt,logo.png,big.js,small.js',
|
|
805
|
+
rankReviewFiles(REVIEW_SAMPLE.files).map((entry) => entry.file.path).join(','),
|
|
806
|
+
)
|
|
807
|
+
check(
|
|
808
|
+
'within a tier the bigger change comes first',
|
|
809
|
+
rankReviewFiles([
|
|
810
|
+
{ path: 'a.js', added: 1, deleted: 0, binary: false, status: 'M' },
|
|
811
|
+
{ path: 'b.js', added: 50, deleted: 0, binary: false, status: 'M' },
|
|
812
|
+
])[0].file.path === 'b.js',
|
|
813
|
+
)
|
|
814
|
+
check(
|
|
815
|
+
'a file with no risk marker carries no reason',
|
|
816
|
+
rankReviewFiles([{ path: 'plain.js', added: 1, deleted: 1, binary: false, status: 'M' }])[0].reason === null,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
const markerStorage = (value) => ({ getItem: () => value, setItem: () => {} })
|
|
820
|
+
check(
|
|
821
|
+
'markers round-trip',
|
|
822
|
+
readMarkers(markerStorage(JSON.stringify({ '/repo': { hash: 'abc', at: 123 } })))['/repo'].hash === 'abc',
|
|
823
|
+
)
|
|
824
|
+
check('markers survive malformed JSON', Object.keys(readMarkers(markerStorage('{oops'))).length === 0)
|
|
825
|
+
check(
|
|
826
|
+
'a marker without a hash is dropped',
|
|
827
|
+
Object.keys(readMarkers(markerStorage(JSON.stringify({ '/repo': { at: 1 }, '/ok': { hash: 'h' } })))).join(',') === '/ok',
|
|
828
|
+
)
|
|
829
|
+
check('markers tolerate no storage', Object.keys(readMarkers(null)).length === 0)
|
|
830
|
+
|
|
831
|
+
// Rendering: seed the marker (slot 24) and a status payload (slot 5) so the
|
|
832
|
+
// review effect's condition holds on the first render — the shim runs effects
|
|
833
|
+
// once, so a null status there would blank the pane before the fetch could
|
|
834
|
+
// happen. `/review` is served from the mock, so this exercises the real path:
|
|
835
|
+
// marker -> effect -> request -> render.
|
|
836
|
+
HOST['/review'] = { ok: true, data: REVIEW_SAMPLE }
|
|
837
|
+
const reviewMount = await mount({
|
|
838
|
+
5: {
|
|
839
|
+
root: '/repo',
|
|
840
|
+
branch: { head: 'main', oid: 'a'.repeat(40) },
|
|
841
|
+
staged: [],
|
|
842
|
+
unstaged: [],
|
|
843
|
+
untracked: [],
|
|
844
|
+
conflicted: [],
|
|
845
|
+
},
|
|
846
|
+
24: { '/repo': { hash: 'a'.repeat(40), at: Date.now() - 3_600_000 } },
|
|
847
|
+
})
|
|
848
|
+
check('the review pane renders without throwing', reviewMount.crash === null, reviewMount.crash?.stack ?? '')
|
|
849
|
+
check('the pane asked the host for the review', reviewMount.fetched.includes('/review'), reviewMount.fetched.join(','))
|
|
850
|
+
const reviewFlat = JSON.stringify(reviewMount.tree ?? {})
|
|
851
|
+
check(
|
|
852
|
+
'the review pane leads with the net effect',
|
|
853
|
+
reviewFlat.includes('5 个文件') && reviewFlat.includes('+305') && reviewFlat.includes('−48'),
|
|
854
|
+
'',
|
|
855
|
+
)
|
|
856
|
+
check(
|
|
857
|
+
'every ranked file is listed',
|
|
858
|
+
['clash.txt', 'gone.txt', 'logo.png', 'big.js', 'small.js'].every((path) => reviewFlat.includes(path)),
|
|
859
|
+
)
|
|
860
|
+
check(
|
|
861
|
+
'the ranking is explained rather than implied',
|
|
862
|
+
reviewFlat.includes('冲突') && reviewFlat.includes('删除') && reviewFlat.includes('二进制') && reviewFlat.includes('大改动'),
|
|
863
|
+
)
|
|
864
|
+
check(
|
|
865
|
+
'the list is rendered in ranked order',
|
|
866
|
+
(() => {
|
|
867
|
+
const order = ['clash.txt', 'gone.txt', 'logo.png', 'big.js', 'small.js'].map((path) => reviewFlat.indexOf(path))
|
|
868
|
+
return order.every((at, index) => at > 0 && (index === 0 || at > order[index - 1]))
|
|
869
|
+
})(),
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
// No marker yet: the pane asks for one instead of showing an empty list.
|
|
873
|
+
const noMarker = await mount({})
|
|
874
|
+
const noMarkerFlat = JSON.stringify(noMarker.tree ?? {})
|
|
875
|
+
check('the review tab is the default view', noMarkerFlat.includes('审查') && noMarkerFlat.includes('还没有审查点'))
|
|
876
|
+
check('the empty review pane offers to set a marker', noMarkerFlat.includes('标记为已审查'))
|
|
877
|
+
|
|
878
|
+
// ---- branch actions ---------------------------------------------------------
|
|
879
|
+
/** Depth-first search for the first element whose children include `text`. */
|
|
880
|
+
function findByText(node, text) {
|
|
881
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
882
|
+
if (Array.isArray(node.children) && node.children.includes(text)) return node
|
|
883
|
+
for (const child of node.children ?? []) {
|
|
884
|
+
const hit = findByText(child, text)
|
|
885
|
+
if (hit !== undefined) return hit
|
|
886
|
+
}
|
|
887
|
+
return undefined
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
check('the branch column offers a new-branch action', findByText(history.tree, '新建') !== undefined)
|
|
891
|
+
check('the branch column offers a pull button', findByText(history.tree, '拉取') !== undefined)
|
|
892
|
+
check('the branch column offers a push button', findByText(history.tree, '推送') !== undefined)
|
|
893
|
+
|
|
894
|
+
// ---- the composer's branch chip ---------------------------------------------
|
|
895
|
+
// The chip says which branch the session's workspace is on. It is display only,
|
|
896
|
+
// and a directory that is not a repository has no chip at all.
|
|
897
|
+
const { BranchChip } = exports
|
|
898
|
+
|
|
899
|
+
const chipSlot = registered.find(
|
|
900
|
+
([kind, descriptor]) => kind === 'slot' && descriptor?.name === 'conversation.input.left',
|
|
901
|
+
)
|
|
902
|
+
check('the chip is registered in the composer accessory row', chipSlot !== undefined)
|
|
903
|
+
check('the chip is registered under its own id', chipSlot?.[1]?.id === 'git-branch', String(chipSlot?.[1]?.id))
|
|
904
|
+
check(
|
|
905
|
+
'the chip follows the session workspace',
|
|
906
|
+
chipSlot?.[1]?.inject?.('s1')?.root === '/repo/from-session',
|
|
907
|
+
JSON.stringify(chipSlot?.[1]?.inject?.('s1')),
|
|
908
|
+
)
|
|
909
|
+
check(
|
|
910
|
+
'a session with no workspace gives the chip nothing to report',
|
|
911
|
+
chipSlot?.[1]?.inject?.('nope')?.root === '',
|
|
912
|
+
JSON.stringify(chipSlot?.[1]?.inject?.('nope')),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
const chipMount = await mount({}, { root: '/repo/from-session' }, BranchChip)
|
|
916
|
+
check('the chip renders without throwing', chipMount.crash === null, chipMount.crash?.stack ?? '')
|
|
917
|
+
const chipFlat = JSON.stringify(chipMount.tree ?? {})
|
|
918
|
+
check('the chip shows the current branch', chipFlat.includes('main'), chipFlat)
|
|
919
|
+
check('the chip names the branch it shows', chipMount.tree?.props?.['data-dsh-git-branch'] === 'main')
|
|
920
|
+
check('the chip carries a branch icon', chipFlat.includes('svg'), '')
|
|
921
|
+
check('the chip is not a button', chipMount.tree?.type === 'span', String(chipMount.tree?.type))
|
|
922
|
+
check(
|
|
923
|
+
'the chip asks the host for the branch, not for the whole status',
|
|
924
|
+
chipMount.fetched.includes('/branches') && !chipMount.fetched.includes('/status'),
|
|
925
|
+
chipMount.fetched.join(','),
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
// A directory that is not a repository: the host refuses, and the chip is
|
|
929
|
+
// silent. A composer is not the place to complain about a repository.
|
|
930
|
+
const savedBranches = HOST['/branches']
|
|
931
|
+
HOST['/branches'] = { ok: false, error: 'not a git repository' }
|
|
932
|
+
const noRepoChip = await mount({}, { root: '/tmp/not-a-repo' }, BranchChip)
|
|
933
|
+
check('a directory that is not a repository shows no chip', noRepoChip.tree === null, JSON.stringify(noRepoChip.tree))
|
|
934
|
+
check('a failed read does not crash the chip', noRepoChip.crash === null, noRepoChip.crash?.stack ?? '')
|
|
935
|
+
|
|
936
|
+
// No workspace at all: nothing to report.
|
|
937
|
+
const noRootChip = await mount({}, { root: '' }, BranchChip)
|
|
938
|
+
check('no workspace shows no chip', noRootChip.tree === null, JSON.stringify(noRootChip.tree))
|
|
939
|
+
HOST['/branches'] = savedBranches
|
|
940
|
+
|
|
941
|
+
// All three strategies must be reachable, not just the two the first cut
|
|
942
|
+
// exposed: the host enumerated three and the client has to offer three.
|
|
943
|
+
const strategySelect = (() => {
|
|
944
|
+
const walk = (node) => {
|
|
945
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
946
|
+
if (node.type === 'select') return node
|
|
947
|
+
for (const child of node.children ?? []) {
|
|
948
|
+
const hit = walk(child)
|
|
949
|
+
if (hit !== undefined) return hit
|
|
950
|
+
}
|
|
951
|
+
return undefined
|
|
952
|
+
}
|
|
953
|
+
return walk(history.tree)
|
|
954
|
+
})()
|
|
955
|
+
const optionValues = (strategySelect?.children ?? []).map((option) => option?.props?.value)
|
|
956
|
+
check(
|
|
957
|
+
'the pull strategy selector lists every strategy the host accepts',
|
|
958
|
+
JSON.stringify(optionValues) === JSON.stringify(['ff-only', 'merge', 'rebase']),
|
|
959
|
+
JSON.stringify(optionValues),
|
|
960
|
+
)
|
|
961
|
+
check('the pull strategy defaults to the safe one', strategySelect?.props?.value === 'ff-only', strategySelect?.props?.value)
|
|
962
|
+
check(
|
|
963
|
+
'the selected strategy is explained rather than just named',
|
|
964
|
+
typeof strategySelect?.props?.title === 'string' && strategySelect.props.title.includes('不改历史'),
|
|
965
|
+
strategySelect?.props?.title,
|
|
966
|
+
)
|
|
967
|
+
check('the current branch is marked instead of offering a checkout', historyFlat.includes('当前'))
|
|
968
|
+
check(
|
|
969
|
+
'other local and remote branches offer a checkout',
|
|
970
|
+
(historyFlat.match(/检出/g) ?? []).length >= 2,
|
|
971
|
+
`${(historyFlat.match(/检出/g) ?? []).length} occurrences`,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
// Clicking "新建" must reveal the inline name input. This is the one interaction
|
|
975
|
+
// the harness can drive end to end: call the handler, re-render, look again.
|
|
976
|
+
//
|
|
977
|
+
// The click has to happen against the hook state of the mount that produced the
|
|
978
|
+
// tree, so this re-mounts rather than reusing `history` — an intervening mount
|
|
979
|
+
// would have replaced the state slots the button's handler writes into.
|
|
980
|
+
const branchMount = await mount({ 2: 'history' })
|
|
981
|
+
const newBranchButton = findByText(branchMount.tree, '新建')
|
|
982
|
+
check('the new-branch button carries a handler', typeof newBranchButton?.props?.onClick === 'function')
|
|
983
|
+
newBranchButton.props.onClick()
|
|
984
|
+
// The shim reads hooks positionally, so cursor must be reset for a manual render.
|
|
985
|
+
cursor = 0
|
|
986
|
+
effects = []
|
|
987
|
+
const opened = exports.GitView({ sessionRoot: '/repo/from-session', t: tZh })
|
|
988
|
+
check('clicking it opens the inline name input', JSON.stringify(opened).includes('新分支名'))
|
|
989
|
+
check('the input names the start point it will branch from', JSON.stringify(opened).includes('基于 HEAD'))
|
|
990
|
+
|
|
991
|
+
// ---- sizing to the scroll container, not to the window ----------------------
|
|
992
|
+
// The composer seat is an in-flow sibling, so a window-sized panel made the
|
|
993
|
+
// scroller exactly one composer taller than its scrollport — scrollable, which
|
|
994
|
+
// is how the composer could be scrolled back into view.
|
|
995
|
+
const { fillHeightFor } = exports
|
|
996
|
+
// A physically consistent fixture: a real scroller's `scrollHeight` tracks the
|
|
997
|
+
// panel's height, because the panel is part of the flow. Fixing `scrollHeight`
|
|
998
|
+
// at a constant would test arithmetic on an impossible container.
|
|
999
|
+
const FLOW_BESIDES_PANEL = 160 // headers above + the composer seat below
|
|
1000
|
+
const scrollerNode = { clientHeight: 860, parentElement: null, panelHeight: 0, scrolls: true }
|
|
1001
|
+
Object.defineProperty(scrollerNode, 'scrollHeight', {
|
|
1002
|
+
get: () => scrollerNode.panelHeight + FLOW_BESIDES_PANEL,
|
|
1003
|
+
})
|
|
1004
|
+
const panelOf = (height) => {
|
|
1005
|
+
scrollerNode.panelHeight = height
|
|
1006
|
+
return { getBoundingClientRect: () => ({ top: 82, height }), parentElement: scrollerNode }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
check(
|
|
1010
|
+
'the panel is sized to leave no slack in the scroll container',
|
|
1011
|
+
fillHeightFor(panelOf(860), 861) === 700,
|
|
1012
|
+
`scrollport 860 - ${FLOW_BESIDES_PANEL} of other flow = ${fillHeightFor(panelOf(860), 861)}`,
|
|
1013
|
+
)
|
|
1014
|
+
// The target must not depend on the height the panel happened to have when it
|
|
1015
|
+
// was measured — that is what makes a single pass exact and a re-measure safe.
|
|
1016
|
+
check(
|
|
1017
|
+
'an oversized panel converges on the same target',
|
|
1018
|
+
fillHeightFor(panelOf(1013), 861) === 700,
|
|
1019
|
+
String(fillHeightFor(panelOf(1013), 861)),
|
|
1020
|
+
)
|
|
1021
|
+
check(
|
|
1022
|
+
'an undersized panel grows to the same target',
|
|
1023
|
+
fillHeightFor(panelOf(300), 861) === 700,
|
|
1024
|
+
String(fillHeightFor(panelOf(300), 861)),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
// No scroller (a definite-height shell, or a bare test frame): the window
|
|
1028
|
+
// estimate is the only thing available.
|
|
1029
|
+
const noScroller = { getBoundingClientRect: () => ({ top: 82, height: 400 }), parentElement: null }
|
|
1030
|
+
check('without a scroller the window estimate is used', fillHeightFor(noScroller, 861) === 861)
|
|
1031
|
+
|
|
1032
|
+
check(
|
|
1033
|
+
'a collapsed measurement is rejected instead of collapsing the panel',
|
|
1034
|
+
fillHeightFor({ getBoundingClientRect: () => ({ top: 0, height: 0 }), parentElement: null }, 12) === null,
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
// A malformed payload must produce a visible message, not a blank panel: the
|
|
1038
|
+
// throw has to happen before render, or React unmounts the tree with no trace.
|
|
1039
|
+
const savedStatus = HOST['/status']
|
|
1040
|
+
HOST['/status'] = { ok: true, data: { root: '/repo', branch: { head: 'main' } } }
|
|
1041
|
+
const malformed = await mount()
|
|
1042
|
+
check('a status payload missing its arrays does not crash the render', malformed.crash === null, malformed.crash?.stack ?? '')
|
|
1043
|
+
const malformedFlat = JSON.stringify(malformed.tree ?? {})
|
|
1044
|
+
check(
|
|
1045
|
+
'the missing field is reported to the user instead of blanking the panel',
|
|
1046
|
+
malformedFlat.includes('缺少字段') && malformedFlat.includes('conflicted'),
|
|
1047
|
+
malformedFlat.slice(0, 160),
|
|
1048
|
+
)
|
|
1049
|
+
HOST['/status'] = savedStatus
|
|
1050
|
+
|
|
1051
|
+
// ---- the hover bubble --------------------------------------------------------
|
|
1052
|
+
// A name that does not fit is cut to an ellipsis, and the reader has no way to
|
|
1053
|
+
// see the rest of it. The bubble is the answer, and it has three rules worth
|
|
1054
|
+
// asserting: it appears only for a label that is actually cut off, it lands
|
|
1055
|
+
// where the reader can see it, and it is styled from the shell's own tooltip
|
|
1056
|
+
// tokens rather than from a palette of this plugin's own.
|
|
1057
|
+
|
|
1058
|
+
const { bubblePosition, isTruncated, BUBBLE_STYLE, BUBBLE_DELAY_MS } = exports
|
|
1059
|
+
|
|
1060
|
+
// Placement: below the label when there is room, above it when there is not,
|
|
1061
|
+
// and never past the edge of the window.
|
|
1062
|
+
const anchorBox = { left: 100, top: 200, width: 120, height: 18, bottom: 218, right: 220 }
|
|
1063
|
+
const bubbleSize = { width: 200, height: 40 }
|
|
1064
|
+
const screen = { width: 1280, height: 943 }
|
|
1065
|
+
|
|
1066
|
+
const below = bubblePosition(anchorBox, bubbleSize, screen)
|
|
1067
|
+
check('the bubble sits below the label when there is room', below.top === 226, JSON.stringify(below))
|
|
1068
|
+
check(
|
|
1069
|
+
'a bubble wider than the label lines up with its start edge',
|
|
1070
|
+
below.left === 100,
|
|
1071
|
+
JSON.stringify(below),
|
|
1072
|
+
)
|
|
1073
|
+
check(
|
|
1074
|
+
'a bubble narrower than the label is centred on it',
|
|
1075
|
+
bubblePosition(anchorBox, { width: 80, height: 40 }, screen).left === 120,
|
|
1076
|
+
JSON.stringify(bubblePosition(anchorBox, { width: 80, height: 40 }, screen)),
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
const above = bubblePosition({ left: 100, top: 900, width: 120, height: 18, bottom: 918 }, bubbleSize, screen)
|
|
1080
|
+
check('the bubble flips above the label when the bottom is full', above.top === 852, JSON.stringify(above))
|
|
1081
|
+
|
|
1082
|
+
const leftEdge = bubblePosition({ left: 0, top: 200, width: 20, height: 18, bottom: 218 }, bubbleSize, screen)
|
|
1083
|
+
check('the bubble never runs off the left edge', leftEdge.left === 8, JSON.stringify(leftEdge))
|
|
1084
|
+
|
|
1085
|
+
const rightEdge = bubblePosition({ left: 1200, top: 200, width: 70, height: 18, bottom: 218 }, bubbleSize, screen)
|
|
1086
|
+
check('the bubble never runs off the right edge', rightEdge.left === 1072, JSON.stringify(rightEdge))
|
|
1087
|
+
|
|
1088
|
+
// The gate: a bubble that repeats text the reader can already see is noise.
|
|
1089
|
+
check('a label that fits is not truncated', isTruncated({ scrollWidth: 100, clientWidth: 100 }) === false)
|
|
1090
|
+
check('a label that overflows is truncated', isTruncated({ scrollWidth: 140, clientWidth: 100 }) === true)
|
|
1091
|
+
check('an element that was never measured is not truncated', isTruncated(null) === false)
|
|
1092
|
+
check(
|
|
1093
|
+
'the bubble is styled from the shell tooltip tokens, not from literals',
|
|
1094
|
+
BUBBLE_STYLE.background === 'var(--dsw-alias-tooltip-bg)' &&
|
|
1095
|
+
BUBBLE_STYLE.color === 'var(--dsw-static-neutral-bluish-00)' &&
|
|
1096
|
+
BUBBLE_STYLE.borderRadius === 'var(--dsw-radius-sm)' &&
|
|
1097
|
+
BUBBLE_STYLE.pointerEvents === 'none',
|
|
1098
|
+
JSON.stringify(BUBBLE_STYLE),
|
|
1099
|
+
)
|
|
1100
|
+
check(
|
|
1101
|
+
'the bubble hardcodes no colour',
|
|
1102
|
+
!HEX_COLOUR.test(JSON.stringify(BUBBLE_STYLE)),
|
|
1103
|
+
(JSON.stringify(BUBBLE_STYLE).match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).join(' '),
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
// The bubble lives in the document body, so the panel's `overflow: hidden`
|
|
1107
|
+
// columns cannot clip it. This is the fake document it lives in.
|
|
1108
|
+
const fakeDocument = {
|
|
1109
|
+
body: {
|
|
1110
|
+
children: [],
|
|
1111
|
+
appendChild(element) {
|
|
1112
|
+
this.children.push(element)
|
|
1113
|
+
return element
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
createElement(tag) {
|
|
1117
|
+
return {
|
|
1118
|
+
tagName: tag,
|
|
1119
|
+
nodeType: 1,
|
|
1120
|
+
style: {},
|
|
1121
|
+
textContent: '',
|
|
1122
|
+
children: [],
|
|
1123
|
+
offsetWidth: 200,
|
|
1124
|
+
offsetHeight: 40,
|
|
1125
|
+
setAttribute(name, value) {
|
|
1126
|
+
this.attributes = { ...(this.attributes ?? {}), [name]: value }
|
|
1127
|
+
},
|
|
1128
|
+
// The bubble is filled with text nodes and `<wbr>` break hints, the way
|
|
1129
|
+
// a path should break at its slashes. A text node adds to the text; a
|
|
1130
|
+
// `<wbr>` does not, which is the whole point of it.
|
|
1131
|
+
appendChild(child) {
|
|
1132
|
+
this.children.push(child)
|
|
1133
|
+
if (child.nodeType === 3) this.textContent += child.data
|
|
1134
|
+
return child
|
|
1135
|
+
},
|
|
1136
|
+
getBoundingClientRect: () => anchorBox,
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
createTextNode(data) {
|
|
1140
|
+
return { nodeType: 3, data }
|
|
1141
|
+
},
|
|
1142
|
+
}
|
|
1143
|
+
globalThis.document = fakeDocument
|
|
1144
|
+
|
|
1145
|
+
/** A label that is (or is not) cut off. */
|
|
1146
|
+
const fakeLabel = (scrollWidth, clientWidth) => ({
|
|
1147
|
+
scrollWidth,
|
|
1148
|
+
clientWidth,
|
|
1149
|
+
getBoundingClientRect: () => anchorBox,
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
/** Wait for the bubble's delay to elapse. */
|
|
1153
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, BUBBLE_DELAY_MS + 60))
|
|
1154
|
+
|
|
1155
|
+
// A label that fits must not produce a bubble at all.
|
|
1156
|
+
exports.reveal('src/deep/nested/file.ts').onPointerEnter({
|
|
1157
|
+
currentTarget: fakeLabel(120, 120),
|
|
1158
|
+
})
|
|
1159
|
+
await settle()
|
|
1160
|
+
check('a label that fits produces no bubble', fakeDocument.body.children.length === 0)
|
|
1161
|
+
|
|
1162
|
+
// A label that is cut off must produce one, carrying the whole name.
|
|
1163
|
+
exports.reveal('src/deep/nested/file.ts').onPointerEnter({
|
|
1164
|
+
currentTarget: fakeLabel(400, 120),
|
|
1165
|
+
})
|
|
1166
|
+
await settle()
|
|
1167
|
+
const bubble = fakeDocument.body.children[0]
|
|
1168
|
+
check('a cut-off label produces a bubble', bubble !== undefined)
|
|
1169
|
+
check('the bubble is on screen', bubble?.style?.display === 'block', String(bubble?.style?.display))
|
|
1170
|
+
check('the bubble carries the full name', bubble?.textContent === 'src/deep/nested/file.ts', String(bubble?.textContent))
|
|
1171
|
+
check('the bubble is placed next to the label', bubble?.style?.left === '100px' && bubble?.style?.top === '226px', `${bubble?.style?.left} / ${bubble?.style?.top}`)
|
|
1172
|
+
|
|
1173
|
+
// Leaving the label takes it away again.
|
|
1174
|
+
exports.reveal('src/deep/nested/file.ts').onPointerLeave()
|
|
1175
|
+
check('the bubble goes away when the pointer leaves', bubble?.style?.display === 'none', String(bubble?.style?.display))
|
|
1176
|
+
|
|
1177
|
+
// The rows themselves must carry the reveal handlers, and must not keep the
|
|
1178
|
+
// native tooltip: both answer the same hover, and the native one paints over
|
|
1179
|
+
// the bubble a second later.
|
|
1180
|
+
|
|
1181
|
+
/** Every node in a rendered tree, depth first. */
|
|
1182
|
+
function walk(node, visit) {
|
|
1183
|
+
if (node === null || typeof node !== 'object') return
|
|
1184
|
+
visit(node)
|
|
1185
|
+
for (const child of node.children ?? []) walk(child, visit)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** The node that renders `text` and reacts to the pointer. */
|
|
1189
|
+
function labelledNode(tree, text) {
|
|
1190
|
+
let hit
|
|
1191
|
+
walk(tree, (node) => {
|
|
1192
|
+
if (hit !== undefined) return
|
|
1193
|
+
if (!Array.isArray(node.children) || !node.children.includes(text)) return
|
|
1194
|
+
if (typeof node.props?.onPointerEnter !== 'function') return
|
|
1195
|
+
hit = node
|
|
1196
|
+
})
|
|
1197
|
+
return hit
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** The row that holds a node. */
|
|
1201
|
+
function rowOf(tree, node) {
|
|
1202
|
+
let hit
|
|
1203
|
+
walk(tree, (candidate) => {
|
|
1204
|
+
if (hit !== undefined) return
|
|
1205
|
+
if ((candidate.children ?? []).includes(node)) hit = candidate
|
|
1206
|
+
})
|
|
1207
|
+
return hit
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// A file with a directory: the row shows the name and the directory as two
|
|
1211
|
+
// pieces, and the bubble shows the whole path.
|
|
1212
|
+
const savedStatusForBubble = HOST['/status']
|
|
1213
|
+
HOST['/status'] = {
|
|
1214
|
+
ok: true,
|
|
1215
|
+
data: {
|
|
1216
|
+
root: '/repo',
|
|
1217
|
+
branch: { head: 'main', oid: 'abc' },
|
|
1218
|
+
staged: [],
|
|
1219
|
+
unstaged: [{ path: 'src/deep/nested/file.ts', index: '.', worktree: 'M', origPath: null }],
|
|
1220
|
+
untracked: [],
|
|
1221
|
+
conflicted: [],
|
|
1222
|
+
},
|
|
1223
|
+
}
|
|
1224
|
+
const bubbleMount = await mount({ 2: 'worktree' })
|
|
1225
|
+
const fileNameNode = labelledNode(bubbleMount.tree, 'file.ts')
|
|
1226
|
+
check('a file name reveals its full path on hover', fileNameNode !== undefined)
|
|
1227
|
+
check(
|
|
1228
|
+
'the file row no longer leans on a native tooltip',
|
|
1229
|
+
rowOf(bubbleMount.tree, fileNameNode)?.props?.title === undefined,
|
|
1230
|
+
String(rowOf(bubbleMount.tree, fileNameNode)?.props?.title),
|
|
1231
|
+
)
|
|
1232
|
+
check(
|
|
1233
|
+
'the file name can dismiss the bubble again',
|
|
1234
|
+
typeof fileNameNode?.props?.onPointerLeave === 'function',
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
// The branch tree: a long branch name is the case the panel is worst at.
|
|
1238
|
+
const branchBubbleMount = await mount({ 2: 'history' })
|
|
1239
|
+
const branchNode = labelledNode(branchBubbleMount.tree, 'feature')
|
|
1240
|
+
check('a branch name reveals itself on hover', branchNode !== undefined)
|
|
1241
|
+
check(
|
|
1242
|
+
'the branch row no longer leans on a native tooltip',
|
|
1243
|
+
rowOf(branchBubbleMount.tree, branchNode)?.props?.title === undefined,
|
|
1244
|
+
String(rowOf(branchBubbleMount.tree, branchNode)?.props?.title),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
// The commit subject is the other name the panel cuts short — and it never had
|
|
1248
|
+
// a tooltip at all, so this is the case the bubble actually adds.
|
|
1249
|
+
const subjectNode = labelledNode(branchBubbleMount.tree, 'merge things')
|
|
1250
|
+
check('a commit subject reveals itself on hover', subjectNode !== undefined)
|
|
1251
|
+
const hashNode = labelledNode(branchBubbleMount.tree, 'aaaaaaa')
|
|
1252
|
+
check('a commit hash reveals the whole hash and its author', hashNode !== undefined)
|
|
1253
|
+
|
|
1254
|
+
HOST['/status'] = savedStatusForBubble
|
|
1255
|
+
delete globalThis.document
|
|
1256
|
+
|
|
1257
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`)
|
|
1258
|
+
process.exit(failures === 0 ? 0 : 1)
|