silvery 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Text-BRf-59j2.mjs → Text-Ci6UCSTL.mjs} +46 -46
- package/dist/Text-Ci6UCSTL.mjs.map +1 -0
- package/dist/{ag-BeFC4S2N.mjs → ag-q9lRlKlt.mjs} +42 -19
- package/dist/ag-q9lRlKlt.mjs.map +1 -0
- package/dist/{animation-N8MFybTk.mjs → animation-CZ4x2R7C.mjs} +1 -1
- package/dist/{animation-N8MFybTk.mjs.map → animation-CZ4x2R7C.mjs.map} +1 -1
- package/dist/{ansi-C6Qs1Wn2.mjs → ansi-CLmIMnop.mjs} +1 -1
- package/dist/{ansi-C6Qs1Wn2.mjs.map → ansi-CLmIMnop.mjs.map} +1 -1
- package/dist/{ansi-CBkam1ty.d.mts → ansi-zmNzgkPB.d.mts} +1 -1
- package/dist/{ansi-CBkam1ty.d.mts.map → ansi-zmNzgkPB.d.mts.map} +1 -1
- package/dist/backend-DSiSIc-h.mjs +13396 -0
- package/dist/backend-DSiSIc-h.mjs.map +1 -0
- package/dist/chain-bridge-CkYcMxlq.mjs +4149 -0
- package/dist/chain-bridge-CkYcMxlq.mjs.map +1 -0
- package/dist/{chunk-hT5z_Zn9.mjs → chunk-BEJ448es.mjs} +1 -1
- package/dist/cli-VNqUKlXw.mjs +4 -0
- package/dist/{context-BU5LkkIy.mjs → context-CEhuD1R_.mjs} +1 -1
- package/dist/{context-BU5LkkIy.mjs.map → context-CEhuD1R_.mjs.map} +1 -1
- package/dist/devtools-BF4IMumr.mjs +2 -0
- package/dist/{devtools-9zfhpFyG.mjs → devtools-td2AjFwc.mjs} +5 -4
- package/dist/{devtools-9zfhpFyG.mjs.map → devtools-td2AjFwc.mjs.map} +1 -1
- package/dist/{easing-B0oZKDki.d.mts → easing-C5MrdO7V.d.mts} +1 -1
- package/dist/{easing-B0oZKDki.d.mts.map → easing-C5MrdO7V.d.mts.map} +1 -1
- package/dist/{eta-DGOuC8yU.mjs → eta-BwYWEjfS.mjs} +1 -5
- package/dist/{eta-DGOuC8yU.mjs.map → eta-BwYWEjfS.mjs.map} +1 -1
- package/dist/{flexily-zero-adapter-D6hcFgrH.mjs → flexily-zero-adapter-DktX3HsX.mjs} +1 -1
- package/dist/{flexily-zero-adapter-CEJOcbNp.mjs → flexily-zero-adapter-VoBGHG14.mjs} +1 -1
- package/dist/{flexily-zero-adapter-CEJOcbNp.mjs.map → flexily-zero-adapter-VoBGHG14.mjs.map} +1 -1
- package/dist/hit-registry-D_nxOivS.d.mts +71 -0
- package/dist/hit-registry-D_nxOivS.d.mts.map +1 -0
- package/dist/{image-1nRyKa60.mjs → image-CIF75xMr.mjs} +24 -24
- package/dist/image-CIF75xMr.mjs.map +1 -0
- package/dist/{index-2E1jYgak.d.mts → index-377zdPLP.d.mts} +35 -12
- package/dist/index-377zdPLP.d.mts.map +1 -0
- package/dist/{index-DnuadDNL.d.mts → index-BcSr0RAQ.d.mts} +8590 -11173
- package/dist/index-BcSr0RAQ.d.mts.map +1 -0
- package/dist/{index-Dg1YaeJb.d.mts → index-DLnpxDAm.d.mts} +2 -2
- package/dist/{index-Dg1YaeJb.d.mts.map → index-DLnpxDAm.d.mts.map} +1 -1
- package/dist/{index-Bi4Jdz5g.d.mts → index-DlNmckTq.d.mts} +2 -2
- package/dist/{index-Bi4Jdz5g.d.mts.map → index-DlNmckTq.d.mts.map} +1 -1
- package/dist/index.d.mts +12 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +18 -17
- package/dist/index.mjs.map +1 -1
- package/dist/{layout-engine-Ca_nbtfL.mjs → layout-engine-BgR58vL0.mjs} +3 -3
- package/dist/{layout-engine-Ca_nbtfL.mjs.map → layout-engine-BgR58vL0.mjs.map} +1 -1
- package/dist/{layout-engine-CgsoBRIn.mjs → layout-engine-DNjTha8f.mjs} +1 -1
- package/dist/{layout-signals-Dch2EiCy.mjs → layout-signals-CGYbHmxy.mjs} +2 -2
- package/dist/layout-signals-CGYbHmxy.mjs.map +1 -0
- package/dist/{bound-term-BumfuXXW.d.mts → mouse-events-B2z5tbsL.d.mts} +540 -401
- package/dist/mouse-events-B2z5tbsL.d.mts.map +1 -0
- package/dist/{mouse-events-hnbJZRwK.mjs → mouse-events-WQ2h-uqG.mjs} +14 -6
- package/dist/mouse-events-WQ2h-uqG.mjs.map +1 -0
- package/dist/{multi-progress-Bg4ngK80.d.mts → multi-progress-BaEFfHKP.d.mts} +2 -2
- package/dist/{multi-progress-Bg4ngK80.d.mts.map → multi-progress-BaEFfHKP.d.mts.map} +1 -1
- package/dist/{multi-progress-CaXTuL9G.mjs → multi-progress-zCWHEm9P.mjs} +3 -3
- package/dist/{multi-progress-CaXTuL9G.mjs.map → multi-progress-zCWHEm9P.mjs.map} +1 -1
- package/dist/{node-BiFu8I9Y.mjs → node-CJ3J_rW-.mjs} +4 -4
- package/dist/node-CJ3J_rW-.mjs.map +1 -0
- package/dist/{progress-B_UPy6zk.mjs → progress-6vJ7nxUg.mjs} +4 -4
- package/dist/{progress-B_UPy6zk.mjs.map → progress-6vJ7nxUg.mjs.map} +1 -1
- package/dist/{progress-bar-DmIMPdL0.mjs → progress-bar-BlFNsZL8.mjs} +4 -4
- package/dist/{progress-bar-DmIMPdL0.mjs.map → progress-bar-BlFNsZL8.mjs.map} +1 -1
- package/dist/{reconciler-NBDSEm8k.mjs → reconciler--_-biN23.mjs} +111 -150
- package/dist/reconciler--_-biN23.mjs.map +1 -0
- package/dist/{render-string-BntLj7Xq.mjs → render-string-DaghHpLN.mjs} +1 -1
- package/dist/{render-string-B4h4SmK7.mjs → render-string-DtHZP0cU.mjs} +15 -11
- package/dist/render-string-DtHZP0cU.mjs.map +1 -0
- package/dist/runtime.d.mts +3 -2
- package/dist/runtime.mjs +3 -2
- package/dist/{schemes-DYt2ushj.mjs → schemes-DWQPxXAi.mjs} +2 -2
- package/dist/{schemes-DYt2ushj.mjs.map → schemes-DWQPxXAi.mjs.map} +1 -1
- package/dist/{spinner-Py8_-hn9.mjs → spinner-B50hILqx.mjs} +3 -3
- package/dist/{spinner-Py8_-hn9.mjs.map → spinner-B50hILqx.mjs.map} +1 -1
- package/dist/{spinner-CLgzJ_QF.d.mts → spinner-DN10UfF0.d.mts} +2 -2
- package/dist/{spinner-CLgzJ_QF.d.mts.map → spinner-DN10UfF0.d.mts.map} +1 -1
- package/dist/{src-Oe6x5PrS.mjs → src-B8lf1kiR.mjs} +190 -83
- package/dist/src-B8lf1kiR.mjs.map +1 -0
- package/dist/{src-Dvq-s8iD.mjs → src-BOLdi3WG.mjs} +3 -3
- package/dist/{src-Dvq-s8iD.mjs.map → src-BOLdi3WG.mjs.map} +1 -1
- package/dist/{src-B9S_woYc.mjs → src-BXeTHHlL.mjs} +20289 -23262
- package/dist/src-BXeTHHlL.mjs.map +1 -0
- package/dist/{src-WeA_J4BV.mjs → src-BqeBFB3X.mjs} +90 -47
- package/dist/src-BqeBFB3X.mjs.map +1 -0
- package/dist/{src-CbhWmUnF.mjs → src-DbqcSrhy.mjs} +1400 -1427
- package/dist/src-DbqcSrhy.mjs.map +1 -0
- package/dist/{steps-DYrzCUCK.d.mts → steps-B2kBmFpb.d.mts} +1 -1
- package/dist/{steps-DYrzCUCK.d.mts.map → steps-B2kBmFpb.d.mts.map} +1 -1
- package/dist/{svg-DhxQkz-O.mjs → svg-NmV9dgB9.mjs} +1 -1
- package/dist/{svg-DhxQkz-O.mjs.map → svg-NmV9dgB9.mjs.map} +1 -1
- package/dist/{svg-Hk7lIl4F.d.mts → svg-VUr78l5d.d.mts} +1 -1
- package/dist/{svg-Hk7lIl4F.d.mts.map → svg-VUr78l5d.d.mts.map} +1 -1
- package/dist/term.d.mts +2 -2
- package/dist/term.mjs +3 -3
- package/dist/test.d.mts +673 -0
- package/dist/test.d.mts.map +1 -0
- package/dist/test.mjs +2829 -0
- package/dist/test.mjs.map +1 -0
- package/dist/theme.d.mts +2 -2
- package/dist/theme.d.mts.map +1 -1
- package/dist/theme.mjs +3 -3
- package/dist/{types-Bx-XZNbE.d.mts → types-D_E0FbqA.d.mts} +1 -1
- package/dist/{types-Bx-XZNbE.d.mts.map → types-D_E0FbqA.d.mts.map} +1 -1
- package/dist/ui/animation.d.mts +2 -2
- package/dist/ui/animation.mjs +1 -1
- package/dist/ui/ansi.d.mts +1 -1
- package/dist/ui/ansi.mjs +1 -1
- package/dist/ui/cli.d.mts +3 -3
- package/dist/ui/cli.mjs +5 -5
- package/dist/ui/image.d.mts +1 -1
- package/dist/ui/image.mjs +1 -1
- package/dist/ui/progress.d.mts +4 -4
- package/dist/ui/progress.mjs +4 -4
- package/dist/ui/react.d.mts +1 -1
- package/dist/ui/react.mjs +2 -2
- package/dist/ui/react.mjs.map +1 -1
- package/dist/ui/recording-chrome-react.d.mts +1 -1
- package/dist/ui/recording-chrome-react.mjs +2 -2
- package/dist/ui/recording-chrome.d.mts +1 -1
- package/dist/ui/recording-chrome.mjs +1 -1
- package/dist/ui/utils.mjs +1 -1
- package/dist/ui/wrappers.d.mts +2 -2
- package/dist/ui/wrappers.mjs +1 -1
- package/dist/ui.d.mts +6 -6
- package/dist/ui.mjs +7 -7
- package/dist/{ansi-OYRLxxZQ.mjs → unicode-CxyxVGmB.mjs} +7317 -6909
- package/dist/unicode-CxyxVGmB.mjs.map +1 -0
- package/dist/{useLatest-DC8i7guK.d.mts → useLatest-UOvWy0ZW.d.mts} +2 -2
- package/dist/{useLatest-DC8i7guK.d.mts.map → useLatest-UOvWy0ZW.d.mts.map} +1 -1
- package/dist/{useLayout-BKsQl2Or.mjs → useLayout-D5cE8OCI.mjs} +4 -4
- package/dist/{useLayout-BKsQl2Or.mjs.map → useLayout-D5cE8OCI.mjs.map} +1 -1
- package/dist/{with-text-input-DG4f7JII.d.mts → with-text-input-DLoSCBrm.d.mts} +3 -3
- package/dist/{with-text-input-DG4f7JII.d.mts.map → with-text-input-DLoSCBrm.d.mts.map} +1 -1
- package/dist/wrap-measurer-registration-IV2HtcCd.d.mts +3106 -0
- package/dist/wrap-measurer-registration-IV2HtcCd.d.mts.map +1 -0
- package/dist/{wrapper-D7gNSsgf.mjs → wrapper-CD29afOs.mjs} +17 -37
- package/dist/wrapper-CD29afOs.mjs.map +1 -0
- package/dist/{wrappers-CypAzrMO.mjs → wrappers-CInBJm44.mjs} +6 -6
- package/dist/{wrappers-CypAzrMO.mjs.map → wrappers-CInBJm44.mjs.map} +1 -1
- package/dist/{yoga-adapter-SsEIqMc1.mjs → yoga-adapter-9uab5Qn-.mjs} +2 -2
- package/dist/{yoga-adapter-SsEIqMc1.mjs.map → yoga-adapter-9uab5Qn-.mjs.map} +1 -1
- package/dist/yoga-adapter-yqTl1aXK.mjs +2 -0
- package/package.json +20 -15
- package/dist/Text-BRf-59j2.mjs.map +0 -1
- package/dist/ag-BeFC4S2N.mjs.map +0 -1
- package/dist/ansi-OYRLxxZQ.mjs.map +0 -1
- package/dist/bound-term-BumfuXXW.d.mts.map +0 -1
- package/dist/cli-DG6zsfsS.mjs +0 -4
- package/dist/devtools-nmPUmYU_.mjs +0 -2
- package/dist/image-1nRyKa60.mjs.map +0 -1
- package/dist/index-2E1jYgak.d.mts.map +0 -1
- package/dist/index-DnuadDNL.d.mts.map +0 -1
- package/dist/layout-signals-Dch2EiCy.mjs.map +0 -1
- package/dist/mouse-events-hnbJZRwK.mjs.map +0 -1
- package/dist/node-BiFu8I9Y.mjs.map +0 -1
- package/dist/reconciler-NBDSEm8k.mjs.map +0 -1
- package/dist/render-string-B4h4SmK7.mjs.map +0 -1
- package/dist/src-B9S_woYc.mjs.map +0 -1
- package/dist/src-CbhWmUnF.mjs.map +0 -1
- package/dist/src-Oe6x5PrS.mjs.map +0 -1
- package/dist/src-WeA_J4BV.mjs.map +0 -1
- package/dist/ui/display.d.mts +0 -35
- package/dist/ui/display.d.mts.map +0 -1
- package/dist/ui/display.mjs +0 -123
- package/dist/ui/display.mjs.map +0 -1
- package/dist/ui/input.d.mts +0 -184
- package/dist/ui/input.d.mts.map +0 -1
- package/dist/ui/input.mjs +0 -283
- package/dist/ui/input.mjs.map +0 -1
- package/dist/wrapper-D7gNSsgf.mjs.map +0 -1
- package/dist/yoga-adapter-1ex8r0ws.mjs +0 -2
package/dist/test.mjs
ADDED
|
@@ -0,0 +1,2829 @@
|
|
|
1
|
+
import { f as processMouseEvent, g as updateKeyboardModifiers, s as createMouseEventProcessor, v as pointInRect } from "./mouse-events-WQ2h-uqG.mjs";
|
|
2
|
+
import { r as findActiveCursorRect, t as commitLayoutSnapshot } from "./layout-signals-CGYbHmxy.mjs";
|
|
3
|
+
import { Ct as CursorProvider, Nt as createFocusManager, wt as createCursorStore } from "./src-DbqcSrhy.mjs";
|
|
4
|
+
import { D as buildMismatchContext, F as compareBuffers, I as formatMismatch, O as formatMismatchContext, a as CLS_STRICT_SLUG, c as ScopeProvider, i as CLS_STRICT_MIN_TIER, n as toChainAppContextValue, o as UnexpectedLayoutShiftError, r as createClsMonitor, s as isClsStrictEnabled, t as createChildApp } from "./chain-bridge-CkYcMxlq.mjs";
|
|
5
|
+
import { $t as bufferToText, Ct as isStrictEnabled, H as createTerm, Jt as EMPTY_FRAME_CELL, N as stripAnsi, Qt as bufferToStyledText, Zt as bufferToHTML, _n as splitRawInput, en as cellEquals, gt as outputPhase, hn as parseKey, ln as keyToAnsi, mn as parseHotkey, on as CODE_TO_KEY, tn as cellToFrameCell, un as keyToKittyAnsi, ut as parseBracketedPaste, vt as IncrementalRenderMismatchError, xt as preloadStrictTerminalBackends } from "./unicode-CxyxVGmB.mjs";
|
|
6
|
+
import { O as warnOnce, _ as kittyDeleteAllScrimPlacements, ct as deriveTheme, tt as isProtocolError } from "./src-B8lf1kiR.mjs";
|
|
7
|
+
import { a as releaseContainer, b as getRenderEpoch, d as reportDisposeError, i as reconciler, l as setContainerNodeLifecycle, n as createFiberRoot, r as getContainerRoot, t as createContainer, u as createScope, x as isAnyDirty } from "./reconciler--_-biN23.mjs";
|
|
8
|
+
import { i as isLayoutEngineInitialized, o as setLayoutEngine, t as ensureDefaultLayoutEngine } from "./layout-engine-BgR58vL0.mjs";
|
|
9
|
+
import { c as INSTRUMENT, d as assertBoundedConvergence, f as beginConvergenceLoop, g as notePassCommit, h as logPass, l as MAX_CONVERGENCE_PASSES, n as createRenderPostState, o as FRESH_LAYOUT_STRICT_SLUG, p as beginPass, s as markLayoutTreeDirty, t as createAg, v as recordPassRing } from "./ag-q9lRlKlt.mjs";
|
|
10
|
+
import { c as StdoutContext, i as FocusManagerContext, l as TermContext, o as RuntimeContext, r as ChainAppContext } from "./context-CEhuD1R_.mjs";
|
|
11
|
+
import { createLogger } from "loggily";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { writeFile } from "node:fs/promises";
|
|
14
|
+
import { is } from "css-select";
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import { createGhosttyBackend } from "@termless/ghostty";
|
|
17
|
+
import { createGhosttyNativeBackend } from "@termless/ghostty-native";
|
|
18
|
+
import { createXtermBackend } from "@termless/xtermjs";
|
|
19
|
+
//#region packages/test/src/auto-locator.ts
|
|
20
|
+
/**
|
|
21
|
+
* Create an AutoLocator from a container getter function.
|
|
22
|
+
* The getter is called fresh on each resolution.
|
|
23
|
+
*/
|
|
24
|
+
function createAutoLocator(getContainer, opts) {
|
|
25
|
+
return new AutoLocatorImpl(getContainer, [], void 0, opts?.strict ?? false);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* AutoLocator implementation
|
|
29
|
+
*/
|
|
30
|
+
var AutoLocatorImpl = class AutoLocatorImpl {
|
|
31
|
+
constructor(getContainer, predicates, indexSelector, strict = false) {
|
|
32
|
+
this.getContainer = getContainer;
|
|
33
|
+
this.predicates = predicates;
|
|
34
|
+
this.indexSelector = indexSelector;
|
|
35
|
+
this.strict = strict;
|
|
36
|
+
}
|
|
37
|
+
getByText(text) {
|
|
38
|
+
const predicate = (node) => {
|
|
39
|
+
const content = getNodeTextContent(node);
|
|
40
|
+
if (!content) return false;
|
|
41
|
+
if (node.type !== "silvery-text") return false;
|
|
42
|
+
if (node.isRawText && node.parent?.type === "silvery-text") return false;
|
|
43
|
+
if (typeof text === "string") return content.includes(text);
|
|
44
|
+
return text.test(content);
|
|
45
|
+
};
|
|
46
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
|
|
47
|
+
}
|
|
48
|
+
getByTestId(id) {
|
|
49
|
+
const predicate = (node) => {
|
|
50
|
+
return getNodeProp(node, "testID") === id;
|
|
51
|
+
};
|
|
52
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
|
|
53
|
+
}
|
|
54
|
+
locator(selector) {
|
|
55
|
+
const predicate = (node) => {
|
|
56
|
+
try {
|
|
57
|
+
return is(node, selector, cssSelectOptions);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
|
|
63
|
+
}
|
|
64
|
+
filter(optionsOrPredicate) {
|
|
65
|
+
let predicate;
|
|
66
|
+
if (typeof optionsOrPredicate === "function") predicate = optionsOrPredicate;
|
|
67
|
+
else {
|
|
68
|
+
const opts = optionsOrPredicate;
|
|
69
|
+
predicate = (node) => {
|
|
70
|
+
if (opts.hasText !== void 0) {
|
|
71
|
+
const content = getNodeTextContent(node);
|
|
72
|
+
if (typeof opts.hasText === "string") {
|
|
73
|
+
if (!content.includes(opts.hasText)) return false;
|
|
74
|
+
} else if (!opts.hasText.test(content)) return false;
|
|
75
|
+
}
|
|
76
|
+
if (opts.hasTestId !== void 0) {
|
|
77
|
+
if (getNodeProp(node, "testID") !== opts.hasTestId) return false;
|
|
78
|
+
}
|
|
79
|
+
if (opts.has !== void 0) {
|
|
80
|
+
const value = getNodeProp(node, opts.has.attr);
|
|
81
|
+
if (opts.has.value !== void 0) {
|
|
82
|
+
if (value !== opts.has.value) return false;
|
|
83
|
+
} else if (value === void 0) return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return new AutoLocatorImpl(this.getContainer, [...this.predicates, predicate], void 0, this.strict);
|
|
89
|
+
}
|
|
90
|
+
first() {
|
|
91
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, { type: "first" }, this.strict);
|
|
92
|
+
}
|
|
93
|
+
last() {
|
|
94
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, { type: "last" }, this.strict);
|
|
95
|
+
}
|
|
96
|
+
nth(index) {
|
|
97
|
+
return new AutoLocatorImpl(this.getContainer, this.predicates, {
|
|
98
|
+
type: "nth",
|
|
99
|
+
index
|
|
100
|
+
}, this.strict);
|
|
101
|
+
}
|
|
102
|
+
resolve() {
|
|
103
|
+
const nodes = this.resolveAll();
|
|
104
|
+
if (this.indexSelector) switch (this.indexSelector.type) {
|
|
105
|
+
case "first": return nodes[0] ?? null;
|
|
106
|
+
case "last": return nodes[nodes.length - 1] ?? null;
|
|
107
|
+
case "nth": return nodes[this.indexSelector.index ?? 0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
if (this.strict && nodes.length > 1) throw new Error(`Locator resolved to ${nodes.length} elements. Use .first(), .nth(n), or a more specific selector. Matched types: ${nodes.slice(0, 3).map((n) => n.type).join(", ")}${nodes.length > 3 ? "..." : ""}`);
|
|
110
|
+
return nodes[0] ?? null;
|
|
111
|
+
}
|
|
112
|
+
resolveAll() {
|
|
113
|
+
const container = this.getContainer();
|
|
114
|
+
if (this.predicates.length === 0) return [container];
|
|
115
|
+
const matches = [];
|
|
116
|
+
walkTree(container, (node) => {
|
|
117
|
+
if (this.predicates.every((p) => p(node))) matches.push(node);
|
|
118
|
+
});
|
|
119
|
+
return matches;
|
|
120
|
+
}
|
|
121
|
+
count() {
|
|
122
|
+
return this.resolveAll().length;
|
|
123
|
+
}
|
|
124
|
+
textContent() {
|
|
125
|
+
const node = this.resolve();
|
|
126
|
+
if (!node) return "";
|
|
127
|
+
return getNodeTextContent(node);
|
|
128
|
+
}
|
|
129
|
+
getAttribute(name) {
|
|
130
|
+
const node = this.resolve();
|
|
131
|
+
if (!node) return void 0;
|
|
132
|
+
return getNodeProp(node, name);
|
|
133
|
+
}
|
|
134
|
+
boundingBox() {
|
|
135
|
+
const node = this.resolve();
|
|
136
|
+
if (!node) return null;
|
|
137
|
+
return node.scrollRect ?? null;
|
|
138
|
+
}
|
|
139
|
+
isVisible() {
|
|
140
|
+
const box = this.boundingBox();
|
|
141
|
+
if (!box) return false;
|
|
142
|
+
return box.width > 0 && box.height > 0;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Walk tree depth-first, calling visitor for each node
|
|
147
|
+
*/
|
|
148
|
+
function walkTree(node, visitor) {
|
|
149
|
+
visitor(node);
|
|
150
|
+
for (const child of node.children) walkTree(child, visitor);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get text content of a node (concatenated from all text descendants)
|
|
154
|
+
*/
|
|
155
|
+
function getNodeTextContent(node) {
|
|
156
|
+
if (node.textContent !== void 0) return node.textContent;
|
|
157
|
+
return node.children.map(getNodeTextContent).join("");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get a prop value from node
|
|
161
|
+
*/
|
|
162
|
+
function getNodeProp(node, name) {
|
|
163
|
+
const value = node.props[name];
|
|
164
|
+
if (value === void 0 || value === null) return void 0;
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
/** Shared css-select options — case-sensitive tags, no caching (tree changes between queries) */
|
|
168
|
+
const cssSelectOptions = {
|
|
169
|
+
adapter: {
|
|
170
|
+
isTag: (node) => !node.isRawText,
|
|
171
|
+
getAttributeValue: (element, name) => getNodeProp(element, name),
|
|
172
|
+
getChildren: (node) => [...node.children],
|
|
173
|
+
getName: (element) => element.type ?? "unknown",
|
|
174
|
+
getParent: (node) => node.parent,
|
|
175
|
+
getSiblings: (node) => node.parent ? [...node.parent.children] : [node],
|
|
176
|
+
getText: (node) => getNodeTextContent(node),
|
|
177
|
+
hasAttrib: (element, name) => getNodeProp(element, name) !== void 0,
|
|
178
|
+
removeSubsets: (nodes) => {
|
|
179
|
+
return nodes.filter((node, i) => {
|
|
180
|
+
for (let j = 0; j < nodes.length; j++) if (i !== j) {
|
|
181
|
+
let ancestor = node.parent;
|
|
182
|
+
while (ancestor) {
|
|
183
|
+
if (ancestor === nodes[j]) return false;
|
|
184
|
+
ancestor = ancestor.parent;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
prevElementSibling: (node) => {
|
|
191
|
+
if (!node.parent) return null;
|
|
192
|
+
const siblings = node.parent.children;
|
|
193
|
+
const index = siblings.indexOf(node);
|
|
194
|
+
for (let i = index - 1; i >= 0; i--) if (!siblings[i].isRawText) return siblings[i];
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
xmlMode: true,
|
|
199
|
+
cacheResults: false
|
|
200
|
+
};
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region packages/test/src/debug.ts
|
|
203
|
+
/**
|
|
204
|
+
* Pretty-print SilveryNode tree for debugging.
|
|
205
|
+
*
|
|
206
|
+
* @param node - Root node to inspect
|
|
207
|
+
* @param options - Display options
|
|
208
|
+
* @returns Formatted tree string
|
|
209
|
+
*/
|
|
210
|
+
function debugTree(node, options = {}) {
|
|
211
|
+
const { depth = Number.POSITIVE_INFINITY, showRects = true, showText = true } = options;
|
|
212
|
+
const lines = [];
|
|
213
|
+
function safeStringify(value) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.stringify(value);
|
|
216
|
+
} catch {
|
|
217
|
+
if (typeof value === "object" && value !== null) return "[object]";
|
|
218
|
+
return String(value);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function walk(n, indent, currentDepth) {
|
|
222
|
+
if (currentDepth > depth) return;
|
|
223
|
+
const props = Object.entries(n.props ?? {}).filter(([k]) => !["children"].includes(k)).filter(([, v]) => v !== void 0 && v !== null && v !== false).map(([k, v]) => {
|
|
224
|
+
if (typeof v === "string") return `${k}="${v}"`;
|
|
225
|
+
if (typeof v === "boolean") return k;
|
|
226
|
+
return `${k}=${safeStringify(v)}`;
|
|
227
|
+
}).join(" ");
|
|
228
|
+
let rect = "";
|
|
229
|
+
if (showRects && n.scrollRect) {
|
|
230
|
+
const { x, y, width, height } = n.scrollRect;
|
|
231
|
+
rect = ` [${x},${y} ${width}×${height}]`;
|
|
232
|
+
}
|
|
233
|
+
let text = "";
|
|
234
|
+
if (showText && n.textContent) text = ` "${n.textContent.length > 40 ? n.textContent.slice(0, 37) + "..." : n.textContent}"`;
|
|
235
|
+
const propsStr = props ? " " + props : "";
|
|
236
|
+
lines.push(" ".repeat(indent) + `<${n.type}${propsStr}${text}${rect}>`);
|
|
237
|
+
for (const child of n.children) walk(child, indent + 1, currentDepth + 1);
|
|
238
|
+
}
|
|
239
|
+
walk(node, 0, 0);
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region packages/test/src/diagnostic-theme.ts
|
|
244
|
+
/**
|
|
245
|
+
* Diagnostic theme for pipeline regression tests.
|
|
246
|
+
*
|
|
247
|
+
* Built so that **every distinct flat token resolves to a distinct, visible
|
|
248
|
+
* color** — exposing pipeline bugs that would be invisible under themes where
|
|
249
|
+
* tokens collapse to the canvas background.
|
|
250
|
+
*
|
|
251
|
+
* ## Why this exists
|
|
252
|
+
*
|
|
253
|
+
* The default `ansi16DarkTheme` uses Nord-derived hexes. In Nord:
|
|
254
|
+
* - canvas `bg` = `#2e3440` = rgb(46, 52, 64)
|
|
255
|
+
* - legacy `mutedbg` = blend(bg, fg, 0.04) ≈ rgb(52, 58, 70)
|
|
256
|
+
*
|
|
257
|
+
* When tests render at ansi16, the underlying RGBs from `mutedbg` and `bg`
|
|
258
|
+
* differ by only a few units — and crucially, they look "the same" to
|
|
259
|
+
* naive bg-vs-canvas detectors that compare against a fixed canvas color.
|
|
260
|
+
*
|
|
261
|
+
* One concrete consequence: the cyan-strip cold-start bug
|
|
262
|
+
* (`@km/silvery/render-light-blue-bg-strip-residue`) emitted bg=$mutedbg paint
|
|
263
|
+
* at cells past a clip boundary, but every test passed because the bg color
|
|
264
|
+
* was indistinguishable from canvas in the test theme. Real users running at
|
|
265
|
+
* Nord saw a visible strip; tests saw nothing.
|
|
266
|
+
*
|
|
267
|
+
* The diagnostic theme uses pure black canvas + saturated primaries so blends
|
|
268
|
+
* preserve their step magnitude in every channel. Every Sterling/legacy token
|
|
269
|
+
* resolves to a distinct RGB tuple. A test that asserts "no non-canvas bg in
|
|
270
|
+
* region X" catches phantom bg paints regardless of which token emitted them.
|
|
271
|
+
*
|
|
272
|
+
* ## Usage
|
|
273
|
+
*
|
|
274
|
+
* ```ts
|
|
275
|
+
* import { diagnosticTheme } from "@silvery/test"
|
|
276
|
+
* import { createRenderer } from "@silvery/test"
|
|
277
|
+
*
|
|
278
|
+
* const r = createRenderer({ cols: 80, rows: 24, theme: diagnosticTheme })
|
|
279
|
+
* ```
|
|
280
|
+
*
|
|
281
|
+
* Or via `testBoard` in km-tui:
|
|
282
|
+
*
|
|
283
|
+
* ```ts
|
|
284
|
+
* const result = await testBoard(VAULT, { columns: 82, rows: 75, theme: diagnosticTheme })
|
|
285
|
+
* // assert no cells past col 40 have non-canvas bg
|
|
286
|
+
* for (let r = 0; r < 75; r++) {
|
|
287
|
+
* for (let c = 40; c < 82; c++) {
|
|
288
|
+
* const cell = result._result.cell(c, r)
|
|
289
|
+
* expect(cell.bg).toBeNull() // canvas = null bg
|
|
290
|
+
* }
|
|
291
|
+
* }
|
|
292
|
+
* ```
|
|
293
|
+
*
|
|
294
|
+
* ## Distinctness invariant
|
|
295
|
+
*
|
|
296
|
+
* Pure black canvas (rgb 0,0,0) + pure white fg (rgb 255,255,255) means every
|
|
297
|
+
* blend(bg, fg, t) for distinct `t` produces distinct RGBs. Sterling/legacy
|
|
298
|
+
* derive uses these `t` values:
|
|
299
|
+
*
|
|
300
|
+
* - `bg-surface-subtle` = blend(0.03) ≈ rgb(8, 8, 8)
|
|
301
|
+
* - `bg-surface-hover` = blend(0.10) ≈ rgb(26, 26, 26)
|
|
302
|
+
* - `bg-surface-raised` = blend(0.10) ≈ rgb(26, 26, 26)
|
|
303
|
+
* - `bg-surface-overlay` = blend(0.12) ≈ rgb(31, 31, 31)
|
|
304
|
+
* - `bg-muted` (Sterling)= blend(0.08) ≈ rgb(20, 20, 20)
|
|
305
|
+
* - `mutedbg` (legacy) = blend(0.04) ≈ rgb(10, 10, 10)
|
|
306
|
+
*
|
|
307
|
+
* All distinct from canvas (rgb 0,0,0) and from each other. The named ANSI
|
|
308
|
+
* slots are saturated primaries so accentBg, redBg, etc. paint in vivid
|
|
309
|
+
* unmistakable colors.
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Maximally-distinct ColorScheme for diagnostic rendering tests.
|
|
313
|
+
*
|
|
314
|
+
* Pure black canvas, pure white fg, saturated ANSI primaries. Every derived
|
|
315
|
+
* token gets a unique visible RGB.
|
|
316
|
+
*/
|
|
317
|
+
const diagnosticScheme = {
|
|
318
|
+
name: "diagnostic",
|
|
319
|
+
dark: true,
|
|
320
|
+
background: "#101828",
|
|
321
|
+
foreground: "#f0f8ff",
|
|
322
|
+
primary: "#ff00ff",
|
|
323
|
+
black: "#202830",
|
|
324
|
+
red: "#ff0000",
|
|
325
|
+
green: "#00ff00",
|
|
326
|
+
yellow: "#ffff00",
|
|
327
|
+
blue: "#4060ff",
|
|
328
|
+
magenta: "#ff00ff",
|
|
329
|
+
cyan: "#00ffff",
|
|
330
|
+
white: "#f0f8ff",
|
|
331
|
+
brightBlack: "#606870",
|
|
332
|
+
brightRed: "#ff8080",
|
|
333
|
+
brightGreen: "#80ff80",
|
|
334
|
+
brightYellow: "#ffff80",
|
|
335
|
+
brightBlue: "#8080ff",
|
|
336
|
+
brightMagenta: "#ff80ff",
|
|
337
|
+
brightCyan: "#80ffff",
|
|
338
|
+
brightWhite: "#ffffff",
|
|
339
|
+
cursorColor: "#ffaa00",
|
|
340
|
+
cursorText: "#101828",
|
|
341
|
+
selectionBackground: "#aa00ff",
|
|
342
|
+
selectionForeground: "#ffffff"
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* Pre-derived diagnostic theme. Pass to `createRenderer({ theme })` or
|
|
346
|
+
* `testBoard({ theme })` for pipeline regression coverage.
|
|
347
|
+
*/
|
|
348
|
+
const diagnosticTheme = deriveTheme(diagnosticScheme);
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region packages/test/src/cell-inspector.ts
|
|
351
|
+
function readCellSnapshot(buffer, x, y) {
|
|
352
|
+
return {
|
|
353
|
+
x,
|
|
354
|
+
y,
|
|
355
|
+
...buffer.getCell(x, y),
|
|
356
|
+
selectable: buffer.isCellSelectable(x, y)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function readCellRow(buffer, y) {
|
|
360
|
+
const cells = [];
|
|
361
|
+
for (let x = 0; x < buffer.width; x++) cells.push(readCellSnapshot(buffer, x, y));
|
|
362
|
+
return cells;
|
|
363
|
+
}
|
|
364
|
+
function formatSelectableCells(buffer) {
|
|
365
|
+
const lines = [];
|
|
366
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
367
|
+
const chars = readCellRow(buffer, y).map((cell) => {
|
|
368
|
+
if (cell.char === "") return cell.selectable ? "^" : ",";
|
|
369
|
+
if (cell.char === " ") return cell.selectable ? "_" : ".";
|
|
370
|
+
return cell.selectable ? cell.char : cell.char.toLowerCase();
|
|
371
|
+
}).join("");
|
|
372
|
+
lines.push(chars);
|
|
373
|
+
}
|
|
374
|
+
return lines.join("\n");
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region packages/test/src/frame-recorder.ts
|
|
378
|
+
const BLANK_CELL = Object.freeze({
|
|
379
|
+
char: " ",
|
|
380
|
+
fg: null,
|
|
381
|
+
bg: null,
|
|
382
|
+
bold: false,
|
|
383
|
+
dim: false,
|
|
384
|
+
italic: false,
|
|
385
|
+
underline: false,
|
|
386
|
+
underlineColor: null,
|
|
387
|
+
overline: false,
|
|
388
|
+
strikethrough: false,
|
|
389
|
+
inverse: false,
|
|
390
|
+
blink: false,
|
|
391
|
+
hidden: false,
|
|
392
|
+
wide: false,
|
|
393
|
+
continuation: false,
|
|
394
|
+
hyperlink: null
|
|
395
|
+
});
|
|
396
|
+
/**
|
|
397
|
+
* Build an immutable TextFrame snapshot from a termless emulator. Cell values
|
|
398
|
+
* must be copied eagerly: the emulator is mutable, so a lazy `cell()` read
|
|
399
|
+
* would make every historical frame report the terminal's latest style.
|
|
400
|
+
*/
|
|
401
|
+
function snapshotEmulator(emu) {
|
|
402
|
+
const width = emu.cols;
|
|
403
|
+
const height = emu.rows;
|
|
404
|
+
const lines = emu.screen.getLines().slice(0, height);
|
|
405
|
+
while (lines.length < height) lines.push("");
|
|
406
|
+
const text = lines.join("\n");
|
|
407
|
+
const cells = [];
|
|
408
|
+
for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
|
|
409
|
+
const view = emu.cell(row, col);
|
|
410
|
+
cells.push(Object.freeze({
|
|
411
|
+
char: view.char,
|
|
412
|
+
fg: view.fg,
|
|
413
|
+
bg: view.bg,
|
|
414
|
+
bold: view.bold,
|
|
415
|
+
dim: view.dim,
|
|
416
|
+
italic: view.italic,
|
|
417
|
+
underline: view.underline,
|
|
418
|
+
underlineColor: view.underlineColor,
|
|
419
|
+
overline: view.overline ?? false,
|
|
420
|
+
strikethrough: view.strikethrough,
|
|
421
|
+
inverse: view.inverse,
|
|
422
|
+
blink: view.blink,
|
|
423
|
+
hidden: view.hidden,
|
|
424
|
+
wide: view.wide,
|
|
425
|
+
continuation: view.continuation,
|
|
426
|
+
hyperlink: view.hyperlink
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
get text() {
|
|
431
|
+
return text;
|
|
432
|
+
},
|
|
433
|
+
get ansi() {
|
|
434
|
+
return text;
|
|
435
|
+
},
|
|
436
|
+
get lines() {
|
|
437
|
+
return lines;
|
|
438
|
+
},
|
|
439
|
+
width,
|
|
440
|
+
height,
|
|
441
|
+
cell(col, row) {
|
|
442
|
+
if (col < 0 || col >= width || row < 0 || row >= height) return BLANK_CELL;
|
|
443
|
+
return cells[row * width + col];
|
|
444
|
+
},
|
|
445
|
+
containsText(needle) {
|
|
446
|
+
return text.includes(needle);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function cellsEqual(a, b) {
|
|
451
|
+
if (a.char !== b.char) return false;
|
|
452
|
+
if (!rgbEqual(a.fg, b.fg)) return false;
|
|
453
|
+
if (!rgbEqual(a.bg, b.bg)) return false;
|
|
454
|
+
if (a.bold !== b.bold) return false;
|
|
455
|
+
if (a.dim !== b.dim) return false;
|
|
456
|
+
if (a.italic !== b.italic) return false;
|
|
457
|
+
if (a.underline !== b.underline) return false;
|
|
458
|
+
if (!rgbEqual(a.underlineColor, b.underlineColor)) return false;
|
|
459
|
+
if (a.overline !== b.overline) return false;
|
|
460
|
+
if (a.strikethrough !== b.strikethrough) return false;
|
|
461
|
+
if (a.inverse !== b.inverse) return false;
|
|
462
|
+
if (a.blink !== b.blink) return false;
|
|
463
|
+
if (a.hidden !== b.hidden) return false;
|
|
464
|
+
if (a.wide !== b.wide) return false;
|
|
465
|
+
if (a.continuation !== b.continuation) return false;
|
|
466
|
+
if (a.hyperlink !== b.hyperlink) return false;
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
function rgbEqual(a, b) {
|
|
470
|
+
if (a === b) return true;
|
|
471
|
+
if (!a || !b) return false;
|
|
472
|
+
return a.r === b.r && a.g === b.g && a.b === b.b;
|
|
473
|
+
}
|
|
474
|
+
function diffFrames(a, b) {
|
|
475
|
+
const width = Math.min(a.width, b.width);
|
|
476
|
+
const height = Math.min(a.height, b.height);
|
|
477
|
+
const changes = [];
|
|
478
|
+
for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
|
|
479
|
+
const from = a.cell(col, row);
|
|
480
|
+
const to = b.cell(col, row);
|
|
481
|
+
if (!cellsEqual(from, to)) changes.push({
|
|
482
|
+
col,
|
|
483
|
+
row,
|
|
484
|
+
from,
|
|
485
|
+
to
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return changes;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Start recording frames from a Term (or any object exposing `.term`).
|
|
492
|
+
*
|
|
493
|
+
* The recorder observes the emulator's ANSI feed so that every render patch
|
|
494
|
+
* triggers a fresh TextFrame snapshot. Only calls made AFTER `recordFrames()`
|
|
495
|
+
* returns are captured — the initial render is not retroactively recorded.
|
|
496
|
+
* Call `recording.stop()` to end recording early.
|
|
497
|
+
*
|
|
498
|
+
* For emulator-backed terms created via `createTermless()`, snapshots reflect
|
|
499
|
+
* the real terminal state including resolved colors and wide characters. For
|
|
500
|
+
* other Term types, this function throws — cell-level recording requires an
|
|
501
|
+
* emulator.
|
|
502
|
+
*/
|
|
503
|
+
function recordFrames(target) {
|
|
504
|
+
const emulator = getEmulator(isFrameRecordable(target) ? target.term : target);
|
|
505
|
+
if (!emulator) throw new Error("recordFrames(): term has no emulator backend. Use createTermless() to create an emulator-backed Term for frame recording.");
|
|
506
|
+
const frames = [];
|
|
507
|
+
let stopped = false;
|
|
508
|
+
const originalFeed = emulator.feed;
|
|
509
|
+
const recordingFeed = function(data) {
|
|
510
|
+
originalFeed.call(this, data);
|
|
511
|
+
if (!stopped) frames.push(snapshotEmulator(emulator));
|
|
512
|
+
};
|
|
513
|
+
emulator.feed = recordingFeed;
|
|
514
|
+
return {
|
|
515
|
+
get frames() {
|
|
516
|
+
return frames;
|
|
517
|
+
},
|
|
518
|
+
get count() {
|
|
519
|
+
return frames.length;
|
|
520
|
+
},
|
|
521
|
+
diff(i, j) {
|
|
522
|
+
const a = frames[i];
|
|
523
|
+
const b = frames[j];
|
|
524
|
+
if (!a || !b) return [];
|
|
525
|
+
return diffFrames(a, b);
|
|
526
|
+
},
|
|
527
|
+
hasTransientChange(predicate) {
|
|
528
|
+
for (let i = 0; i + 2 < frames.length; i++) if (predicate(frames[i], frames[i + 1], frames[i + 2])) return true;
|
|
529
|
+
return false;
|
|
530
|
+
},
|
|
531
|
+
clear() {
|
|
532
|
+
frames.length = 0;
|
|
533
|
+
},
|
|
534
|
+
stop() {
|
|
535
|
+
if (stopped) return;
|
|
536
|
+
stopped = true;
|
|
537
|
+
if (emulator.feed === recordingFeed) emulator.feed = originalFeed;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function isFrameRecordable(value) {
|
|
542
|
+
if (value === null || typeof value !== "object") return false;
|
|
543
|
+
const maybe = value;
|
|
544
|
+
if (maybe.term == null) return false;
|
|
545
|
+
const t = typeof maybe.term;
|
|
546
|
+
return t === "object" || t === "function";
|
|
547
|
+
}
|
|
548
|
+
function getEmulator(term) {
|
|
549
|
+
const emu = term._emulator;
|
|
550
|
+
if (emu == null) return null;
|
|
551
|
+
const t = typeof emu;
|
|
552
|
+
if (t !== "object" && t !== "function") return null;
|
|
553
|
+
const candidate = emu;
|
|
554
|
+
if (typeof candidate.cols !== "number") return null;
|
|
555
|
+
if (typeof candidate.rows !== "number") return null;
|
|
556
|
+
if (typeof candidate.cell !== "function") return null;
|
|
557
|
+
if (candidate.screen == null) return null;
|
|
558
|
+
return emu;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region packages/ag-term/src/bound-term.ts
|
|
562
|
+
/**
|
|
563
|
+
* Create a BoundTerm from a buffer and root node getter
|
|
564
|
+
*/
|
|
565
|
+
function createBoundTerm(buffer, getRoot, getText) {
|
|
566
|
+
return {
|
|
567
|
+
cell(x, y) {
|
|
568
|
+
return buffer.getCell(x, y);
|
|
569
|
+
},
|
|
570
|
+
nodeAt(x, y) {
|
|
571
|
+
return findNodeAtScreenPosition(getRoot(), x, y);
|
|
572
|
+
},
|
|
573
|
+
get text() {
|
|
574
|
+
return getText();
|
|
575
|
+
},
|
|
576
|
+
get columns() {
|
|
577
|
+
return buffer.width;
|
|
578
|
+
},
|
|
579
|
+
get rows() {
|
|
580
|
+
return buffer.height;
|
|
581
|
+
},
|
|
582
|
+
get buffer() {
|
|
583
|
+
return buffer;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Find the deepest node at the given screen coordinates
|
|
589
|
+
*/
|
|
590
|
+
function findNodeAtScreenPosition(node, x, y) {
|
|
591
|
+
const rect = node.scrollRect;
|
|
592
|
+
if (!rect) return null;
|
|
593
|
+
if (x < rect.x || x >= rect.x + rect.width || y < rect.y || y >= rect.y + rect.height) return null;
|
|
594
|
+
for (const child of node.children) {
|
|
595
|
+
const found = findNodeAtScreenPosition(child, x, y);
|
|
596
|
+
if (found) return found;
|
|
597
|
+
}
|
|
598
|
+
if (node.type === "silvery-text") for (let i = node.children.length - 1; i >= 0; i--) {
|
|
599
|
+
const child = node.children[i];
|
|
600
|
+
if (child.inlineRects) {
|
|
601
|
+
for (const inlineRect of child.inlineRects) if (x >= inlineRect.x && x < inlineRect.x + inlineRect.width && y >= inlineRect.y && y < inlineRect.y + inlineRect.height) return child;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return node;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region packages/ag-term/src/screenshot.ts
|
|
608
|
+
function createScreenshotter() {
|
|
609
|
+
let browser = null;
|
|
610
|
+
let page = null;
|
|
611
|
+
async function ensureBrowser() {
|
|
612
|
+
if (browser && page) return page;
|
|
613
|
+
const { chromium } = await import("playwright");
|
|
614
|
+
browser = await chromium.launch();
|
|
615
|
+
page = await (await browser.newContext()).newPage();
|
|
616
|
+
return page;
|
|
617
|
+
}
|
|
618
|
+
async function capture(html, outputPath) {
|
|
619
|
+
const p = await ensureBrowser();
|
|
620
|
+
await p.setContent(html, { waitUntil: "load" });
|
|
621
|
+
await p.waitForTimeout(50);
|
|
622
|
+
const buffer = await p.screenshot({ fullPage: true });
|
|
623
|
+
if (outputPath) await writeFile(outputPath, buffer);
|
|
624
|
+
return buffer;
|
|
625
|
+
}
|
|
626
|
+
async function close() {
|
|
627
|
+
if (browser) {
|
|
628
|
+
await browser.close();
|
|
629
|
+
browser = null;
|
|
630
|
+
page = null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
capture,
|
|
635
|
+
close,
|
|
636
|
+
[Symbol.asyncDispose]: close
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region packages/ag-react/src/debug/render-path.ts
|
|
641
|
+
function hostTypeLabel(type) {
|
|
642
|
+
if (type === "silvery-box") return "Box";
|
|
643
|
+
if (type === "silvery-text") return "Text";
|
|
644
|
+
return type;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Resolve a node to its display name.
|
|
648
|
+
* Mirrors `getDebugComponentName` in reconciler/host-config.ts (kept in
|
|
649
|
+
* sync; if you change one, update the other).
|
|
650
|
+
*/
|
|
651
|
+
function getComponentName(node) {
|
|
652
|
+
const props = node.props;
|
|
653
|
+
const explicit = props["data-component"];
|
|
654
|
+
if (typeof explicit === "string" && explicit.length > 0) return explicit;
|
|
655
|
+
const base = hostTypeLabel(node.type);
|
|
656
|
+
const testID = props.testID;
|
|
657
|
+
if (typeof testID === "string" && testID.length > 0) return `${base}#${testID}`;
|
|
658
|
+
const id = props.id;
|
|
659
|
+
if (typeof id === "string" && id.length > 0) return `${base}#${id}`;
|
|
660
|
+
return base;
|
|
661
|
+
}
|
|
662
|
+
function summarize(node) {
|
|
663
|
+
const out = {
|
|
664
|
+
name: getComponentName(node),
|
|
665
|
+
type: node.type
|
|
666
|
+
};
|
|
667
|
+
if (node.boxRect) out.boxRect = {
|
|
668
|
+
x: node.boxRect.x,
|
|
669
|
+
y: node.boxRect.y,
|
|
670
|
+
width: node.boxRect.width,
|
|
671
|
+
height: node.boxRect.height
|
|
672
|
+
};
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Return the parent chain from `root` down to the first node matching
|
|
677
|
+
* `componentName`. Each element is a `RenderPathNode` summary.
|
|
678
|
+
*
|
|
679
|
+
* Returns `[]` if no match is found.
|
|
680
|
+
*
|
|
681
|
+
* If multiple nodes match, returns the path to the FIRST in tree order.
|
|
682
|
+
* Use `findNodesByComponentName` to inspect all matches.
|
|
683
|
+
*/
|
|
684
|
+
function getRenderPath(root, componentName) {
|
|
685
|
+
function walk(node, chain) {
|
|
686
|
+
const next = [...chain, node];
|
|
687
|
+
if (getComponentName(node) === componentName) return next;
|
|
688
|
+
for (const child of node.children) {
|
|
689
|
+
const hit = walk(child, next);
|
|
690
|
+
if (hit) return hit;
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
const hit = walk(root, []);
|
|
695
|
+
return hit ? hit.map(summarize) : [];
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Recursive JSON dump of the entire mount tree rooted at `root`.
|
|
699
|
+
*
|
|
700
|
+
* Useful for snapshot tests asserting on structural invariants —
|
|
701
|
+
* e.g., "ToolBlock is always wrapped in a Content.Body[width=full]".
|
|
702
|
+
*/
|
|
703
|
+
function getMountTree(root) {
|
|
704
|
+
return {
|
|
705
|
+
...summarize(root),
|
|
706
|
+
children: root.children.map(getMountTree)
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region packages/ag-term/src/app.ts
|
|
711
|
+
/**
|
|
712
|
+
* Create an App instance
|
|
713
|
+
*/
|
|
714
|
+
function buildApp(options) {
|
|
715
|
+
const { getContainer, getBuffer, sendInput, rerender, unmount, waitUntilExit, clear, exitCalled = () => false, exitError = () => void 0, freshRender: freshRenderFn, debugFn, frames = [], columns, rows, kittyMode = false, actAndRender, resize: resizeFn, focusManager: fm, waitForLayoutStable: waitForLayoutStableFn, clsMonitor } = options;
|
|
716
|
+
const createLocator = () => createAutoLocator(getContainer);
|
|
717
|
+
const getText = () => {
|
|
718
|
+
const buffer = getBuffer();
|
|
719
|
+
return buffer ? bufferToText(buffer) : "";
|
|
720
|
+
};
|
|
721
|
+
let boundTerm = null;
|
|
722
|
+
const mouseState = createMouseEventProcessor();
|
|
723
|
+
let screenshotter = null;
|
|
724
|
+
const app = {
|
|
725
|
+
get text() {
|
|
726
|
+
return getText();
|
|
727
|
+
},
|
|
728
|
+
get ansi() {
|
|
729
|
+
const buffer = getBuffer();
|
|
730
|
+
return buffer ? bufferToStyledText(buffer) : "";
|
|
731
|
+
},
|
|
732
|
+
get lines() {
|
|
733
|
+
return getText().split("\n");
|
|
734
|
+
},
|
|
735
|
+
get width() {
|
|
736
|
+
return getBuffer()?.width ?? columns;
|
|
737
|
+
},
|
|
738
|
+
get height() {
|
|
739
|
+
return getBuffer()?.height ?? rows;
|
|
740
|
+
},
|
|
741
|
+
cell(col, row) {
|
|
742
|
+
const buffer = getBuffer();
|
|
743
|
+
if (!buffer) return EMPTY_FRAME_CELL;
|
|
744
|
+
return cellToFrameCell(buffer.getCell(col, row));
|
|
745
|
+
},
|
|
746
|
+
containsText(text) {
|
|
747
|
+
return getText().includes(text);
|
|
748
|
+
},
|
|
749
|
+
nodeAt(x, y) {
|
|
750
|
+
return findNodeAtContentPosition(getContainer(), x, y);
|
|
751
|
+
},
|
|
752
|
+
getByTestId(id) {
|
|
753
|
+
return createLocator().getByTestId(id);
|
|
754
|
+
},
|
|
755
|
+
getByText(text) {
|
|
756
|
+
return createLocator().getByText(text);
|
|
757
|
+
},
|
|
758
|
+
locator(selector) {
|
|
759
|
+
return createLocator().locator(selector);
|
|
760
|
+
},
|
|
761
|
+
press(key) {
|
|
762
|
+
return makeChain((async () => {
|
|
763
|
+
const hotkey = parseHotkey(key);
|
|
764
|
+
updateKeyboardModifiers(mouseState, {
|
|
765
|
+
super: hotkey.super,
|
|
766
|
+
hyper: hotkey.hyper,
|
|
767
|
+
eventType: "press"
|
|
768
|
+
});
|
|
769
|
+
sendInput(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key));
|
|
770
|
+
await Promise.resolve();
|
|
771
|
+
return app;
|
|
772
|
+
})());
|
|
773
|
+
},
|
|
774
|
+
keyDown(key) {
|
|
775
|
+
return makeChain((async () => {
|
|
776
|
+
const hotkey = parseHotkey(key);
|
|
777
|
+
updateKeyboardModifiers(mouseState, {
|
|
778
|
+
super: hotkey.super || keyIsModifier(key, "Super"),
|
|
779
|
+
hyper: hotkey.hyper || keyIsModifier(key, "Hyper"),
|
|
780
|
+
eventType: "press"
|
|
781
|
+
});
|
|
782
|
+
sendInput(injectKittyEventType(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key), 1));
|
|
783
|
+
await Promise.resolve();
|
|
784
|
+
return app;
|
|
785
|
+
})());
|
|
786
|
+
},
|
|
787
|
+
keyUp(key) {
|
|
788
|
+
return makeChain((async () => {
|
|
789
|
+
parseHotkey(key);
|
|
790
|
+
updateKeyboardModifiers(mouseState, {
|
|
791
|
+
super: false,
|
|
792
|
+
hyper: false,
|
|
793
|
+
eventType: "release"
|
|
794
|
+
});
|
|
795
|
+
sendInput(injectKittyEventType(kittyMode ? keyToKittyAnsi(key) : keyToAnsi(key), 3));
|
|
796
|
+
await Promise.resolve();
|
|
797
|
+
return app;
|
|
798
|
+
})());
|
|
799
|
+
},
|
|
800
|
+
pressSequence(...keys) {
|
|
801
|
+
return makeChain((async () => {
|
|
802
|
+
for (const key of keys) await app.press(key);
|
|
803
|
+
return app;
|
|
804
|
+
})());
|
|
805
|
+
},
|
|
806
|
+
type(text) {
|
|
807
|
+
return makeChain((async () => {
|
|
808
|
+
for (const char of text) sendInput(char);
|
|
809
|
+
await Promise.resolve();
|
|
810
|
+
return app;
|
|
811
|
+
})());
|
|
812
|
+
},
|
|
813
|
+
click(x, y, options) {
|
|
814
|
+
return makeChain((async () => {
|
|
815
|
+
const button = options?.button ?? 0;
|
|
816
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = true;
|
|
817
|
+
const doClick = () => {
|
|
818
|
+
const parsed = {
|
|
819
|
+
button,
|
|
820
|
+
x,
|
|
821
|
+
y,
|
|
822
|
+
coordinateMode: "cell",
|
|
823
|
+
action: "down",
|
|
824
|
+
shift: options?.shift ?? false,
|
|
825
|
+
meta: options?.meta ?? false,
|
|
826
|
+
ctrl: options?.ctrl ?? false
|
|
827
|
+
};
|
|
828
|
+
processMouseEvent(mouseState, parsed, getContainer());
|
|
829
|
+
processMouseEvent(mouseState, {
|
|
830
|
+
...parsed,
|
|
831
|
+
action: "up"
|
|
832
|
+
}, getContainer());
|
|
833
|
+
};
|
|
834
|
+
if (actAndRender) actAndRender(doClick);
|
|
835
|
+
else doClick();
|
|
836
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = false;
|
|
837
|
+
await Promise.resolve();
|
|
838
|
+
return app;
|
|
839
|
+
})());
|
|
840
|
+
},
|
|
841
|
+
doubleClick(x, y, options) {
|
|
842
|
+
return makeChain((async () => {
|
|
843
|
+
const button = options?.button ?? 0;
|
|
844
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = true;
|
|
845
|
+
const doDblClick = () => {
|
|
846
|
+
const baseParsed = {
|
|
847
|
+
button,
|
|
848
|
+
x,
|
|
849
|
+
y,
|
|
850
|
+
coordinateMode: "cell",
|
|
851
|
+
action: "down",
|
|
852
|
+
shift: options?.shift ?? false,
|
|
853
|
+
meta: options?.meta ?? false,
|
|
854
|
+
ctrl: options?.ctrl ?? false
|
|
855
|
+
};
|
|
856
|
+
processMouseEvent(mouseState, baseParsed, getContainer());
|
|
857
|
+
processMouseEvent(mouseState, {
|
|
858
|
+
...baseParsed,
|
|
859
|
+
action: "up"
|
|
860
|
+
}, getContainer());
|
|
861
|
+
processMouseEvent(mouseState, baseParsed, getContainer());
|
|
862
|
+
processMouseEvent(mouseState, {
|
|
863
|
+
...baseParsed,
|
|
864
|
+
action: "up"
|
|
865
|
+
}, getContainer());
|
|
866
|
+
};
|
|
867
|
+
if (actAndRender) actAndRender(doDblClick);
|
|
868
|
+
else doDblClick();
|
|
869
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = false;
|
|
870
|
+
await Promise.resolve();
|
|
871
|
+
return app;
|
|
872
|
+
})());
|
|
873
|
+
},
|
|
874
|
+
hover(x, y, options) {
|
|
875
|
+
return makeChain((async () => {
|
|
876
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = true;
|
|
877
|
+
const doHover = () => {
|
|
878
|
+
processMouseEvent(mouseState, {
|
|
879
|
+
button: 0,
|
|
880
|
+
x,
|
|
881
|
+
y,
|
|
882
|
+
coordinateMode: "cell",
|
|
883
|
+
action: "move",
|
|
884
|
+
shift: options?.shift ?? false,
|
|
885
|
+
meta: options?.meta ?? false,
|
|
886
|
+
ctrl: options?.ctrl ?? false
|
|
887
|
+
}, getContainer());
|
|
888
|
+
};
|
|
889
|
+
if (actAndRender) actAndRender(doHover);
|
|
890
|
+
else doHover();
|
|
891
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = false;
|
|
892
|
+
await Promise.resolve();
|
|
893
|
+
return app;
|
|
894
|
+
})());
|
|
895
|
+
},
|
|
896
|
+
wheel(x, y, delta, options) {
|
|
897
|
+
return makeChain((async () => {
|
|
898
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = true;
|
|
899
|
+
const doWheel = () => {
|
|
900
|
+
processMouseEvent(mouseState, {
|
|
901
|
+
button: 0,
|
|
902
|
+
x,
|
|
903
|
+
y,
|
|
904
|
+
coordinateMode: "cell",
|
|
905
|
+
action: "wheel",
|
|
906
|
+
delta,
|
|
907
|
+
shift: options?.shift ?? false,
|
|
908
|
+
meta: options?.meta ?? false,
|
|
909
|
+
ctrl: options?.ctrl ?? false
|
|
910
|
+
}, getContainer());
|
|
911
|
+
};
|
|
912
|
+
if (actAndRender) actAndRender(doWheel);
|
|
913
|
+
else doWheel();
|
|
914
|
+
if (options?.cmd) mouseState.keyboardModifiers.super = false;
|
|
915
|
+
await Promise.resolve();
|
|
916
|
+
return app;
|
|
917
|
+
})());
|
|
918
|
+
},
|
|
919
|
+
resize(cols, rows) {
|
|
920
|
+
if (!resizeFn) throw new Error("resize() is only available in test renderer");
|
|
921
|
+
resizeFn(cols, rows);
|
|
922
|
+
},
|
|
923
|
+
async run() {
|
|
924
|
+
return waitUntilExit();
|
|
925
|
+
},
|
|
926
|
+
get term() {
|
|
927
|
+
const buffer = getBuffer();
|
|
928
|
+
if (!buffer) return createBoundTerm({
|
|
929
|
+
width: columns,
|
|
930
|
+
height: rows,
|
|
931
|
+
getCell: () => ({
|
|
932
|
+
char: " ",
|
|
933
|
+
fg: null,
|
|
934
|
+
bg: null,
|
|
935
|
+
attrs: {},
|
|
936
|
+
wide: false,
|
|
937
|
+
continuation: false
|
|
938
|
+
}),
|
|
939
|
+
setCell: () => {},
|
|
940
|
+
clear: () => {},
|
|
941
|
+
inBounds: () => false
|
|
942
|
+
}, getContainer, getText);
|
|
943
|
+
if (!boundTerm || boundTerm.buffer !== buffer) boundTerm = createBoundTerm(buffer, getContainer, getText);
|
|
944
|
+
return boundTerm;
|
|
945
|
+
},
|
|
946
|
+
async screenshot(outputPath) {
|
|
947
|
+
const buffer = getBuffer();
|
|
948
|
+
if (!buffer) throw new Error("No buffer available for screenshot");
|
|
949
|
+
const html = bufferToHTML(buffer);
|
|
950
|
+
if (!screenshotter) screenshotter = createScreenshotter();
|
|
951
|
+
return screenshotter.capture(html, outputPath);
|
|
952
|
+
},
|
|
953
|
+
rerender,
|
|
954
|
+
unmount() {
|
|
955
|
+
if (screenshotter) {
|
|
956
|
+
screenshotter.close().catch(() => {});
|
|
957
|
+
screenshotter = null;
|
|
958
|
+
}
|
|
959
|
+
unmount();
|
|
960
|
+
},
|
|
961
|
+
[Symbol.dispose]() {
|
|
962
|
+
app.unmount();
|
|
963
|
+
},
|
|
964
|
+
waitUntilExit,
|
|
965
|
+
waitForLayoutStable(opts) {
|
|
966
|
+
if (!waitForLayoutStableFn) return Promise.reject(/* @__PURE__ */ new Error("waitForLayoutStable() is only available in the test renderer"));
|
|
967
|
+
return waitForLayoutStableFn(opts);
|
|
968
|
+
},
|
|
969
|
+
beginCLSCapture(classifier) {
|
|
970
|
+
if (!clsMonitor) throw new Error("beginCLSCapture: no ClsMonitor was provided to buildApp(). The render harness must instantiate `createClsMonitor()` and pass it via AppOptions.clsMonitor for CLS capture to work.");
|
|
971
|
+
clsMonitor.beginCapture(classifier);
|
|
972
|
+
},
|
|
973
|
+
endCLSCapture() {
|
|
974
|
+
if (!clsMonitor) throw new Error("endCLSCapture: no ClsMonitor was provided to buildApp().");
|
|
975
|
+
return clsMonitor.endCapture();
|
|
976
|
+
},
|
|
977
|
+
cancelCLSCapture() {
|
|
978
|
+
clsMonitor?.cancelCapture();
|
|
979
|
+
},
|
|
980
|
+
clear,
|
|
981
|
+
debug() {
|
|
982
|
+
if (debugFn) debugFn();
|
|
983
|
+
else console.log(app.text);
|
|
984
|
+
},
|
|
985
|
+
freshRender() {
|
|
986
|
+
if (!freshRenderFn) throw new Error("freshRender() is only available in test renderer");
|
|
987
|
+
return freshRenderFn();
|
|
988
|
+
},
|
|
989
|
+
exitCalled,
|
|
990
|
+
exitError,
|
|
991
|
+
stdin: { write: sendInput },
|
|
992
|
+
frames,
|
|
993
|
+
lastFrame() {
|
|
994
|
+
return frames[frames.length - 1];
|
|
995
|
+
},
|
|
996
|
+
lastBuffer() {
|
|
997
|
+
return getBuffer() ?? void 0;
|
|
998
|
+
},
|
|
999
|
+
lastFrameText() {
|
|
1000
|
+
const buffer = getBuffer();
|
|
1001
|
+
return buffer ? bufferToText(buffer) : void 0;
|
|
1002
|
+
},
|
|
1003
|
+
getContainer() {
|
|
1004
|
+
return getContainer();
|
|
1005
|
+
},
|
|
1006
|
+
focus(testID) {
|
|
1007
|
+
if (fm) {
|
|
1008
|
+
const root = getContainer();
|
|
1009
|
+
fm.focusById(testID, root, "programmatic");
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
getFocusPath() {
|
|
1013
|
+
if (fm) {
|
|
1014
|
+
const root = getContainer();
|
|
1015
|
+
return fm.getFocusPath(root);
|
|
1016
|
+
}
|
|
1017
|
+
return [];
|
|
1018
|
+
},
|
|
1019
|
+
get focusManager() {
|
|
1020
|
+
if (!fm) throw new Error("FocusManager not available — pass focusManager to buildApp()");
|
|
1021
|
+
return fm;
|
|
1022
|
+
},
|
|
1023
|
+
getCursorState() {
|
|
1024
|
+
const layoutCursor = findActiveCursorRect(getContainer());
|
|
1025
|
+
if (layoutCursor) return {
|
|
1026
|
+
x: layoutCursor.x,
|
|
1027
|
+
y: layoutCursor.y,
|
|
1028
|
+
visible: layoutCursor.visible,
|
|
1029
|
+
shape: layoutCursor.shape
|
|
1030
|
+
};
|
|
1031
|
+
return options.getCursorState?.() ?? null;
|
|
1032
|
+
},
|
|
1033
|
+
renderPath(componentName) {
|
|
1034
|
+
return getRenderPath(getContainer(), componentName);
|
|
1035
|
+
},
|
|
1036
|
+
mountTree() {
|
|
1037
|
+
return getMountTree(getContainer());
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
return app;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Wrap a `Promise<App>` in a `ChainableApp` — a thenable that exposes
|
|
1044
|
+
* the action methods so consumers can compose without explicit `await`
|
|
1045
|
+
* between every step:
|
|
1046
|
+
*
|
|
1047
|
+
* await app.keyDown("Super").hover(x, y).keyUp("Super")
|
|
1048
|
+
*
|
|
1049
|
+
* Each chained method returns a new `ChainableApp` whose backing promise
|
|
1050
|
+
* `.then`s into the previous step + the new action. The terminal `await`
|
|
1051
|
+
* resolves the entire chain to the underlying `App`.
|
|
1052
|
+
*
|
|
1053
|
+
* Bead: @km/silvery/fluent-chain-actions.
|
|
1054
|
+
*/
|
|
1055
|
+
function makeChain(p) {
|
|
1056
|
+
return {
|
|
1057
|
+
press: (key) => makeChain(p.then((a) => a.press(key))),
|
|
1058
|
+
keyDown: (key) => makeChain(p.then((a) => a.keyDown(key))),
|
|
1059
|
+
keyUp: (key) => makeChain(p.then((a) => a.keyUp(key))),
|
|
1060
|
+
pressSequence: (...keys) => makeChain(p.then((a) => a.pressSequence(...keys))),
|
|
1061
|
+
type: (text) => makeChain(p.then((a) => a.type(text))),
|
|
1062
|
+
click: (x, y, options) => makeChain(p.then((a) => a.click(x, y, options))),
|
|
1063
|
+
doubleClick: (x, y, options) => makeChain(p.then((a) => a.doubleClick(x, y, options))),
|
|
1064
|
+
hover: (x, y, options) => makeChain(p.then((a) => a.hover(x, y, options))),
|
|
1065
|
+
wheel: (x, y, delta, options) => makeChain(p.then((a) => a.wheel(x, y, delta, options))),
|
|
1066
|
+
then: (onFulfilled, onRejected) => p.then(onFulfilled, onRejected)
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Inject a Kitty CSI u event-type byte (`:1` press, `:2` repeat, `:3`
|
|
1071
|
+
* release) before the trailing terminator (`u`, `~`, or a CSI letter)
|
|
1072
|
+
* of a Kitty key sequence. Used by `keyDown`/`keyUp` to express held
|
|
1073
|
+
* vs. transient state — `keyToKittyAnsi` returns the bare-press shape
|
|
1074
|
+
* (no event-type byte), which the parser interprets as "press" by
|
|
1075
|
+
* default; explicit `:1`/`:3` lets tests drive paired down/up events.
|
|
1076
|
+
*
|
|
1077
|
+
* Examples:
|
|
1078
|
+
*
|
|
1079
|
+
* `\x1b[57444;9u` → `\x1b[57444;9:1u` (left-super press)
|
|
1080
|
+
* `\x1b[57444;9u` → `\x1b[57444;9:3u` (left-super release)
|
|
1081
|
+
* `\x1b[1;5A` → `\x1b[1;5:1A` (Ctrl+ArrowUp press)
|
|
1082
|
+
* `\x1b[3;5~` → `\x1b[3;5:1~` (Ctrl+Delete press)
|
|
1083
|
+
*
|
|
1084
|
+
* Sequences without a modifier byte (`\x1b[CPu`) get a `;1:eventType`
|
|
1085
|
+
* insert: `\x1b[57444u` → `\x1b[57444;1:1u`. The base modifier is `1`
|
|
1086
|
+
* (no modifiers + 1 base) per the Kitty spec.
|
|
1087
|
+
*
|
|
1088
|
+
* Bead: @km/silvery/keydown-keyup-test-primitives.
|
|
1089
|
+
*/
|
|
1090
|
+
function injectKittyEventType(seq, eventType) {
|
|
1091
|
+
const withMod = seq.replace(/(;[0-9]+)([A-Za-z~])$/, `$1:${eventType}$2`);
|
|
1092
|
+
if (withMod !== seq) return withMod;
|
|
1093
|
+
return seq.replace(/^(\x1b\[[0-9]+)([A-Za-z~])$/, `$1;1:${eventType}$2`);
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Detect whether a key string represents a specific bare modifier
|
|
1097
|
+
* (e.g., `keyIsModifier("Super", "Super")` → true,
|
|
1098
|
+
* `keyIsModifier("leftsuper", "Super")` → true,
|
|
1099
|
+
* `keyIsModifier("a", "Super")` → false). Used by `keyDown`/`keyUp`
|
|
1100
|
+
* to update `mouseState.keyboardModifiers` even when the caller passes
|
|
1101
|
+
* a bare modifier name like "Super" rather than a chord like
|
|
1102
|
+
* "Super+a". `parseHotkey` only sets `.super` when the key is a chord
|
|
1103
|
+
* with Super as a modifier; it doesn't set it for the bare modifier
|
|
1104
|
+
* name itself.
|
|
1105
|
+
*
|
|
1106
|
+
* Bead: @km/silvery/keydown-keyup-test-primitives.
|
|
1107
|
+
*/
|
|
1108
|
+
function keyIsModifier(key, target) {
|
|
1109
|
+
const parts = key.split("+");
|
|
1110
|
+
const last = parts[parts.length - 1]?.toLowerCase() ?? "";
|
|
1111
|
+
if (target === "Super") return last === "super" || last === "cmd" || last === "command" || last === "leftsuper" || last === "rightsuper";
|
|
1112
|
+
return last === "hyper" || last === "lefthyper" || last === "righthyper";
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Find node at content coordinates (not screen coordinates)
|
|
1116
|
+
*/
|
|
1117
|
+
function findNodeAtContentPosition(node, x, y) {
|
|
1118
|
+
const rect = node.boxRect;
|
|
1119
|
+
if (!rect) return null;
|
|
1120
|
+
if (!pointInRect(x, y, rect)) return null;
|
|
1121
|
+
for (const child of node.children) {
|
|
1122
|
+
const found = findNodeAtContentPosition(child, x, y);
|
|
1123
|
+
if (found) return found;
|
|
1124
|
+
}
|
|
1125
|
+
return node;
|
|
1126
|
+
}
|
|
1127
|
+
const RESIDUE_SENTINEL_RGB = {
|
|
1128
|
+
r: 254,
|
|
1129
|
+
g: 0,
|
|
1130
|
+
b: 254
|
|
1131
|
+
};
|
|
1132
|
+
/** SILVERY_STRICT slug for the residue check. Tier 2 by design. */
|
|
1133
|
+
const RESIDUE_STRICT_SLUG = "residue";
|
|
1134
|
+
/** Returns true when the residue check should fire. */
|
|
1135
|
+
function isResidueStrictEnabled() {
|
|
1136
|
+
return isStrictEnabled(RESIDUE_STRICT_SLUG, 2);
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Fill every cell of `buffer` with the residue sentinel — a magenta-ish
|
|
1140
|
+
* RGB no theme uses + the thorn character "þ". This is a destructive
|
|
1141
|
+
* operation; callers must operate on a clone of the live prev buffer.
|
|
1142
|
+
*/
|
|
1143
|
+
function poisonBufferWithSentinel(buffer) {
|
|
1144
|
+
buffer.fill(0, 0, buffer.width, buffer.height, {
|
|
1145
|
+
char: "þ",
|
|
1146
|
+
fg: RESIDUE_SENTINEL_RGB,
|
|
1147
|
+
bg: RESIDUE_SENTINEL_RGB,
|
|
1148
|
+
wide: false,
|
|
1149
|
+
continuation: false
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Compare a sentinel-poison-prev incremental render to a fresh render,
|
|
1154
|
+
* accounting for the legitimate "cascade skipped this cell because prev
|
|
1155
|
+
* was already correct" pattern.
|
|
1156
|
+
*
|
|
1157
|
+
* The check semantic in detail:
|
|
1158
|
+
*
|
|
1159
|
+
* 1. Real prev buffer P captures the pre-frame state (the buffer the
|
|
1160
|
+
* production incremental render correctly clones from).
|
|
1161
|
+
* 2. Poisoned prev P' = P with every cell replaced by sentinel.
|
|
1162
|
+
* 3. Incremental-with-poison buffer I' is what runPipeline produced
|
|
1163
|
+
* against P'. Cells the cascade skipped retain sentinel; cells it
|
|
1164
|
+
* repainted have fresh content.
|
|
1165
|
+
* 4. Fresh-from-zero buffer F is the all-paths-recomputed reference.
|
|
1166
|
+
* 5. INVARIANT: at every (x,y),
|
|
1167
|
+
* I'[x,y] is sentinel ⇒ P[x,y] === F[x,y]
|
|
1168
|
+
* (cascade skipped this cell ⇒ prev pixel was already what fresh
|
|
1169
|
+
* paints; no stale carry-over).
|
|
1170
|
+
* 6. Equivalently: at any cell where P[x,y] ≠ F[x,y], the incremental
|
|
1171
|
+
* cascade MUST repaint, and I'[x,y] ≠ sentinel.
|
|
1172
|
+
*
|
|
1173
|
+
* A violation = "the cascade decided this cell is clean, but the real
|
|
1174
|
+
* prev had different content from what fresh paints". That's exactly
|
|
1175
|
+
* the cyan-strip-residue class: prev had cyan bg at row 5; fresh paints
|
|
1176
|
+
* default bg; cascade skipped row 5; user sees cyan on a row that
|
|
1177
|
+
* should be default. Sentinel-poison reveals it because I' has the
|
|
1178
|
+
* sentinel where the cascade skipped, and F[x,y] ≠ P[x,y] at that cell.
|
|
1179
|
+
*
|
|
1180
|
+
* Pipeline-state contamination is detected as a side effect: if any
|
|
1181
|
+
* cross-pass state (postState / accumulators) leaks, I' diverges from
|
|
1182
|
+
* F at cells the sentinel doesn't cover, and `cellEquals` catches it.
|
|
1183
|
+
*
|
|
1184
|
+
* Throws on first divergence with a diagnostic identifying the verdict
|
|
1185
|
+
* (sentinel leak vs pipeline contamination), the cell coordinates, and
|
|
1186
|
+
* the value triple (P, I', F) for the offending cell.
|
|
1187
|
+
*/
|
|
1188
|
+
function verifyNoResidueLeak(realPrevBuffer, incrPoisonedBuffer, freshBuffer, frameCount) {
|
|
1189
|
+
const w = Math.min(realPrevBuffer.width, incrPoisonedBuffer.width, freshBuffer.width);
|
|
1190
|
+
const h = Math.min(realPrevBuffer.height, incrPoisonedBuffer.height, freshBuffer.height);
|
|
1191
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
1192
|
+
const incr = incrPoisonedBuffer.getCell(x, y);
|
|
1193
|
+
const fresh = freshBuffer.getCell(x, y);
|
|
1194
|
+
if (cellEquals(incr, fresh)) continue;
|
|
1195
|
+
if (isSentinelCell(incr)) {
|
|
1196
|
+
const prev = realPrevBuffer.getCell(x, y);
|
|
1197
|
+
if (cellEquals(prev, fresh)) continue;
|
|
1198
|
+
throw new IncrementalRenderMismatchError(`STRICT residue check (frame ${frameCount}): SENTINEL LEAK at (${x},${y})\n real prev: char='${prev.char}' fg=${formatColor(prev.fg)} bg=${formatColor(prev.bg)}\n incremental: char='${incr.char}' fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} (sentinel — cascade skipped)\n fresh: char='${fresh.char}' fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)}\n Real prev ≠ fresh at this cell, but the incremental cascade decided\n not to repaint it. The user sees a stale prev pixel where fresh\n would have painted differently — the cyan-strip-residue class.\n Likely root cause: missing dirty flag propagation, a skipBgFill\n that should have been bgRefillNeeded, or a transparent-Box\n ancestorCleared cascade gap.\n Slug: SILVERY_STRICT=${RESIDUE_STRICT_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${RESIDUE_STRICT_SLUG}.`);
|
|
1199
|
+
}
|
|
1200
|
+
throw new IncrementalRenderMismatchError(`STRICT residue check (frame ${frameCount}): pipeline-state contamination at (${x},${y})\n incremental: char='${incr.char}' fg=${formatColor(incr.fg)} bg=${formatColor(incr.bg)} (repainted)\n fresh: char='${fresh.char}' fg=${formatColor(fresh.fg)} bg=${formatColor(fresh.bg)}\n Both passes painted this cell but disagreed. Cross-pass state\n (postState / scrollOffset / outlineSnapshots) is leaking between\n the sentinel-poisoned incremental render and the fresh baseline.\n Slug: SILVERY_STRICT=${RESIDUE_STRICT_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${RESIDUE_STRICT_SLUG}.`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/** True when a cell still carries the residue sentinel. */
|
|
1204
|
+
function isSentinelCell(c) {
|
|
1205
|
+
if (c.char !== "þ") return false;
|
|
1206
|
+
const bg = c.bg;
|
|
1207
|
+
if (typeof bg !== "object" || bg === null) return false;
|
|
1208
|
+
const o = bg;
|
|
1209
|
+
return o.r === RESIDUE_SENTINEL_RGB.r && o.g === RESIDUE_SENTINEL_RGB.g && o.b === RESIDUE_SENTINEL_RGB.b;
|
|
1210
|
+
}
|
|
1211
|
+
function formatColor(c) {
|
|
1212
|
+
if (c === null || c === void 0) return "default";
|
|
1213
|
+
if (typeof c === "number") return `${c}`;
|
|
1214
|
+
if (typeof c === "object") {
|
|
1215
|
+
const o = c;
|
|
1216
|
+
return `rgb(${o.r},${o.g},${o.b})`;
|
|
1217
|
+
}
|
|
1218
|
+
return String(c);
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region packages/ag-term/src/renderer.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* Unified Render API for silvery
|
|
1224
|
+
*
|
|
1225
|
+
* Composable primitives:
|
|
1226
|
+
* - render(element, opts | store) — always sync, returns full App
|
|
1227
|
+
* - createStore(providers) — flattens providers into { cols, rows, events() }
|
|
1228
|
+
* - run(app, events?) — event loop driver (sync or async)
|
|
1229
|
+
* - createApp(element, providers) — sugar: render + createStore + run
|
|
1230
|
+
* - createRenderer(opts | store) — factory with auto-cleanup
|
|
1231
|
+
*/
|
|
1232
|
+
const act = React.act ?? ((cb) => cb());
|
|
1233
|
+
const log = createLogger("silvery:render");
|
|
1234
|
+
function invalidateRootAfterLayoutFeedback(root) {
|
|
1235
|
+
root.dirtyBits = 31;
|
|
1236
|
+
root.dirtyEpoch = getRenderEpoch();
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Track all active (mounted) render instances to detect leaks.
|
|
1240
|
+
* Uses a Set of WeakRefs so GC can clean up unreferenced apps.
|
|
1241
|
+
*/
|
|
1242
|
+
const activeRenders = /* @__PURE__ */ new Set();
|
|
1243
|
+
let renderIdCounter = 0;
|
|
1244
|
+
/**
|
|
1245
|
+
* Maximum number of active render instances before throwing.
|
|
1246
|
+
* Set high to allow large test files (each test may create a render without unmount),
|
|
1247
|
+
* but catch genuine leaks like infinite loops creating renders.
|
|
1248
|
+
*/
|
|
1249
|
+
const ACTIVE_RENDER_LEAK_THRESHOLD = 1e3;
|
|
1250
|
+
/**
|
|
1251
|
+
* Prune GC'd entries from activeRenders and return live count.
|
|
1252
|
+
*/
|
|
1253
|
+
function pruneAndCountActiveRenders() {
|
|
1254
|
+
let count = 0;
|
|
1255
|
+
for (const ref of activeRenders) if (ref.deref() === void 0) activeRenders.delete(ref);
|
|
1256
|
+
else count++;
|
|
1257
|
+
return count;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Assert that the layout engine is initialized before rendering.
|
|
1261
|
+
* This catches the common mistake of calling render() without await ensureEngine().
|
|
1262
|
+
*/
|
|
1263
|
+
function assertLayoutEngine() {
|
|
1264
|
+
if (!isLayoutEngineInitialized()) throw new Error("silvery: Layout engine not initialized. Call `await ensureEngine()` before render(), or use the testing module which initializes it automatically via top-level await.");
|
|
1265
|
+
}
|
|
1266
|
+
let engineReady = null;
|
|
1267
|
+
/**
|
|
1268
|
+
* Ensure layout engine is initialized (async, cached).
|
|
1269
|
+
*/
|
|
1270
|
+
async function ensureEngine() {
|
|
1271
|
+
if (isLayoutEngineInitialized()) return;
|
|
1272
|
+
if (!engineReady) engineReady = ensureDefaultLayoutEngine();
|
|
1273
|
+
await engineReady;
|
|
1274
|
+
}
|
|
1275
|
+
function isStore(arg) {
|
|
1276
|
+
if (arg === null || typeof arg !== "object") return false;
|
|
1277
|
+
const obj = arg;
|
|
1278
|
+
return typeof obj.cols === "number" && typeof obj.rows === "number" && !("layoutEngine" in obj) && !("debug" in obj) && !("incremental" in obj) && !("maxLayoutPasses" in obj) && !("autoRender" in obj) && !("onFrame" in obj) && !("kittyMode" in obj) && !("wrapRoot" in obj) && !("stdin" in obj);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Render a React element synchronously. Returns a full App with locators,
|
|
1282
|
+
* press(), text, ansi, etc.
|
|
1283
|
+
*
|
|
1284
|
+
* Layout engine must be initialized before calling (use ensureEngine() or
|
|
1285
|
+
* the top-level await in testing module).
|
|
1286
|
+
*
|
|
1287
|
+
* Overloads:
|
|
1288
|
+
* - render(element, { cols, rows }) — headless with dimensions
|
|
1289
|
+
* - render(element, store) — with a Store from createStore()
|
|
1290
|
+
*
|
|
1291
|
+
* @example
|
|
1292
|
+
* ```tsx
|
|
1293
|
+
* const app = render(<Counter />, { cols: 80, rows: 24 })
|
|
1294
|
+
* expect(app.text).toContain('Count: 0')
|
|
1295
|
+
* await app.press('j')
|
|
1296
|
+
* expect(app.text).toContain('Count: 1')
|
|
1297
|
+
* ```
|
|
1298
|
+
*/
|
|
1299
|
+
function render(element, optsOrStore = {}) {
|
|
1300
|
+
assertLayoutEngine();
|
|
1301
|
+
const storeMode = isStore(optsOrStore);
|
|
1302
|
+
const cols = storeMode ? optsOrStore.cols : optsOrStore.cols ?? 80;
|
|
1303
|
+
const rows = storeMode ? optsOrStore.rows : optsOrStore.rows ?? 24;
|
|
1304
|
+
const debug = storeMode ? false : optsOrStore.debug ?? false;
|
|
1305
|
+
const incremental = storeMode ? true : optsOrStore.incremental ?? true;
|
|
1306
|
+
const maxLayoutPasses = storeMode ? MAX_CONVERGENCE_PASSES : optsOrStore.maxLayoutPasses ?? MAX_CONVERGENCE_PASSES;
|
|
1307
|
+
const kittyMode = storeMode ? false : optsOrStore.kittyMode ?? false;
|
|
1308
|
+
const autoRender = storeMode ? false : optsOrStore.autoRender ?? false;
|
|
1309
|
+
const handleTabCycling = storeMode ? true : optsOrStore.handleTabCycling ?? true;
|
|
1310
|
+
const onFrame = storeMode ? void 0 : optsOrStore.onFrame;
|
|
1311
|
+
const onBufferReady = storeMode ? void 0 : optsOrStore.onBufferReady;
|
|
1312
|
+
const wrapRoot = storeMode ? void 0 : optsOrStore.wrapRoot;
|
|
1313
|
+
const stdinStream = storeMode ? void 0 : optsOrStore.stdin;
|
|
1314
|
+
const liveCount = pruneAndCountActiveRenders();
|
|
1315
|
+
if (liveCount >= ACTIVE_RENDER_LEAK_THRESHOLD) throw new Error(`silvery: ${liveCount} active render instances without unmount(). This is a render leak. Use createRenderer() for auto-cleanup, or call unmount() when done with each render.`);
|
|
1316
|
+
if (!storeMode && optsOrStore.layoutEngine) setLayoutEngine(optsOrStore.layoutEngine);
|
|
1317
|
+
const renderId = ++renderIdCounter;
|
|
1318
|
+
const instance = {
|
|
1319
|
+
frames: [],
|
|
1320
|
+
container: null,
|
|
1321
|
+
fiberRoot: null,
|
|
1322
|
+
prevBuffer: null,
|
|
1323
|
+
prevPaintedBuffer: null,
|
|
1324
|
+
postState: createRenderPostState(),
|
|
1325
|
+
mounted: true,
|
|
1326
|
+
rendering: false,
|
|
1327
|
+
columns: cols,
|
|
1328
|
+
rows,
|
|
1329
|
+
inputEmitter: new EventEmitter(),
|
|
1330
|
+
debug,
|
|
1331
|
+
incremental,
|
|
1332
|
+
renderCount: 0,
|
|
1333
|
+
maxLayoutPasses,
|
|
1334
|
+
kittyActive: false
|
|
1335
|
+
};
|
|
1336
|
+
let hadReactCommit = false;
|
|
1337
|
+
let autoRenderScheduled = false;
|
|
1338
|
+
let inRenderCycle = false;
|
|
1339
|
+
let currentElement = element;
|
|
1340
|
+
const clsMonitor = createClsMonitor();
|
|
1341
|
+
instance.container = createContainer(() => {
|
|
1342
|
+
hadReactCommit = true;
|
|
1343
|
+
if (autoRender && !instance.rendering && !inRenderCycle && !autoRenderScheduled && instance.mounted) {
|
|
1344
|
+
autoRenderScheduled = true;
|
|
1345
|
+
queueMicrotask(() => {
|
|
1346
|
+
autoRenderScheduled = false;
|
|
1347
|
+
if (!instance.mounted || instance.rendering || inRenderCycle) return;
|
|
1348
|
+
inRenderCycle = true;
|
|
1349
|
+
try {
|
|
1350
|
+
let newFrame = doRender();
|
|
1351
|
+
const root = getContainerRoot(instance.container);
|
|
1352
|
+
hadReactCommit = false;
|
|
1353
|
+
withActEnvironment(() => {
|
|
1354
|
+
act(() => {
|
|
1355
|
+
commitLayoutSnapshot(root);
|
|
1356
|
+
});
|
|
1357
|
+
if (hadReactCommit) act(() => {
|
|
1358
|
+
reconciler.flushSyncWork();
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
if (hadReactCommit) newFrame = doRender();
|
|
1362
|
+
instance.frames.push(newFrame);
|
|
1363
|
+
onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
|
|
1364
|
+
} finally {
|
|
1365
|
+
inRenderCycle = false;
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
instance.fiberRoot = createFiberRoot(instance.container);
|
|
1371
|
+
/**
|
|
1372
|
+
* Get the content extent of the root node's children (for buffer padding trimming).
|
|
1373
|
+
* Returns the max outer bottom edge of the root's direct children, including
|
|
1374
|
+
* margins. The root itself stretches to fill the terminal, but its children
|
|
1375
|
+
* have specific layout-computed heights plus margins that extend beyond.
|
|
1376
|
+
* Returns 0 if no children have layout (empty tree).
|
|
1377
|
+
* Returns undefined if root has no layout at all.
|
|
1378
|
+
*/
|
|
1379
|
+
const getRootContentHeight = () => {
|
|
1380
|
+
try {
|
|
1381
|
+
const root = getContainerRoot(instance.container);
|
|
1382
|
+
if (!root?.boxRect) return void 0;
|
|
1383
|
+
let maxBottom = 0;
|
|
1384
|
+
let hasChildren = false;
|
|
1385
|
+
for (const child of root.children) if (child.boxRect) {
|
|
1386
|
+
hasChildren = true;
|
|
1387
|
+
const props = child.props;
|
|
1388
|
+
const mb = props.marginBottom ?? props.marginY ?? props.margin ?? 0;
|
|
1389
|
+
const childBottom = child.boxRect.y + child.boxRect.height + mb;
|
|
1390
|
+
if (childBottom > maxBottom) maxBottom = childBottom;
|
|
1391
|
+
}
|
|
1392
|
+
return hasChildren ? maxBottom : 0;
|
|
1393
|
+
} catch {
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
let exitCalledFlag = false;
|
|
1398
|
+
let exitErrorValue;
|
|
1399
|
+
const handleExit = (error) => {
|
|
1400
|
+
exitCalledFlag = true;
|
|
1401
|
+
exitErrorValue = error;
|
|
1402
|
+
if (debug) console.log("[silvery] exit() called", error ? `with error: ${error.message}` : "");
|
|
1403
|
+
};
|
|
1404
|
+
const stdoutEmitter = new EventEmitter();
|
|
1405
|
+
const mockStdout = {
|
|
1406
|
+
columns: instance.columns,
|
|
1407
|
+
rows: instance.rows,
|
|
1408
|
+
write: () => true,
|
|
1409
|
+
isTTY: true,
|
|
1410
|
+
on: (event, listener) => {
|
|
1411
|
+
stdoutEmitter.on(event, listener);
|
|
1412
|
+
return mockStdout;
|
|
1413
|
+
},
|
|
1414
|
+
off: (event, listener) => {
|
|
1415
|
+
stdoutEmitter.off(event, listener);
|
|
1416
|
+
return mockStdout;
|
|
1417
|
+
},
|
|
1418
|
+
once: (event, listener) => {
|
|
1419
|
+
stdoutEmitter.once(event, listener);
|
|
1420
|
+
return mockStdout;
|
|
1421
|
+
},
|
|
1422
|
+
removeListener: (event, listener) => {
|
|
1423
|
+
stdoutEmitter.removeListener(event, listener);
|
|
1424
|
+
return mockStdout;
|
|
1425
|
+
},
|
|
1426
|
+
addListener: (event, listener) => {
|
|
1427
|
+
stdoutEmitter.addListener(event, listener);
|
|
1428
|
+
return mockStdout;
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
const mockTerm = createTerm({
|
|
1432
|
+
cols: instance.columns,
|
|
1433
|
+
rows: instance.rows,
|
|
1434
|
+
caps: { colorLevel: "truecolor" }
|
|
1435
|
+
});
|
|
1436
|
+
const updateMockTermSize = mockTerm.size.update;
|
|
1437
|
+
const focusManager = createFocusManager();
|
|
1438
|
+
setContainerNodeLifecycle(instance.container, {
|
|
1439
|
+
onNodeRemoved: (removedNode) => focusManager.handleSubtreeRemoved(removedNode),
|
|
1440
|
+
onNodeUpdated: (updatedNode) => focusManager.handleNodeUpdated(updatedNode),
|
|
1441
|
+
onSubtreeAttached: (attachedRoot) => focusManager.handleSubtreeAttached(attachedRoot)
|
|
1442
|
+
});
|
|
1443
|
+
const cursorStore = createCursorStore();
|
|
1444
|
+
const renderScope = createScope("test-render");
|
|
1445
|
+
const childApp = createChildApp();
|
|
1446
|
+
const chainAppContextValue = toChainAppContextValue(childApp);
|
|
1447
|
+
instance.inputEmitter.on("input", (data) => {
|
|
1448
|
+
const [input, key] = parseKey(data);
|
|
1449
|
+
childApp.rawKeys.notify(input, key);
|
|
1450
|
+
childApp.dispatch({
|
|
1451
|
+
type: "input:key",
|
|
1452
|
+
input,
|
|
1453
|
+
key
|
|
1454
|
+
});
|
|
1455
|
+
childApp.drainEffects();
|
|
1456
|
+
});
|
|
1457
|
+
instance.inputEmitter.on("paste", (text) => {
|
|
1458
|
+
childApp.dispatch({
|
|
1459
|
+
type: "term:paste",
|
|
1460
|
+
text
|
|
1461
|
+
});
|
|
1462
|
+
childApp.drainEffects();
|
|
1463
|
+
});
|
|
1464
|
+
const runtimeValue = {
|
|
1465
|
+
exit: handleExit,
|
|
1466
|
+
panic: (reason) => handleExit(reason instanceof Error ? reason : new Error(String(reason)))
|
|
1467
|
+
};
|
|
1468
|
+
function wrapWithContexts(el) {
|
|
1469
|
+
const inner = wrapRoot ? wrapRoot(el) : el;
|
|
1470
|
+
return React.createElement(ScopeProvider, {
|
|
1471
|
+
scope: renderScope,
|
|
1472
|
+
appScope: renderScope
|
|
1473
|
+
}, React.createElement(CursorProvider, { store: cursorStore }, React.createElement(TermContext.Provider, { value: mockTerm }, React.createElement(StdoutContext.Provider, { value: {
|
|
1474
|
+
stdout: mockStdout,
|
|
1475
|
+
write: () => {}
|
|
1476
|
+
} }, React.createElement(FocusManagerContext.Provider, { value: focusManager }, React.createElement(RuntimeContext.Provider, { value: runtimeValue }, React.createElement(ChainAppContext.Provider, { value: chainAppContextValue }, inner)))))));
|
|
1477
|
+
}
|
|
1478
|
+
const strictEnv = process.env.SILVERY_STRICT;
|
|
1479
|
+
const strictMode = incremental && strictEnv && strictEnv !== "0" && strictEnv !== "false";
|
|
1480
|
+
/** Run the full pipeline: layout + render + output phase. */
|
|
1481
|
+
function runPipeline(root, cols, rows, prevBuffer, opts) {
|
|
1482
|
+
const ag = createAg(root);
|
|
1483
|
+
ag.layout({
|
|
1484
|
+
cols,
|
|
1485
|
+
rows
|
|
1486
|
+
}, opts);
|
|
1487
|
+
const { buffer, carryForwardBuffer, overlay } = ag.render({
|
|
1488
|
+
prevBuffer,
|
|
1489
|
+
postState: opts?.fresh ? createRenderPostState() : instance.postState
|
|
1490
|
+
});
|
|
1491
|
+
let output = outputPhase(instance.prevPaintedBuffer ?? prevBuffer, buffer, "fullscreen");
|
|
1492
|
+
if (overlay.length > 0) {
|
|
1493
|
+
output += overlay;
|
|
1494
|
+
instance.kittyActive = true;
|
|
1495
|
+
} else if (instance.kittyActive) {
|
|
1496
|
+
output += "\x1B7" + kittyDeleteAllScrimPlacements() + "\x1B8";
|
|
1497
|
+
instance.kittyActive = false;
|
|
1498
|
+
}
|
|
1499
|
+
return {
|
|
1500
|
+
output,
|
|
1501
|
+
buffer,
|
|
1502
|
+
carryForwardBuffer,
|
|
1503
|
+
overlay
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
function doRender() {
|
|
1507
|
+
let output;
|
|
1508
|
+
let buffer;
|
|
1509
|
+
let incrementalOverlay = "";
|
|
1510
|
+
let multiPassConverged = false;
|
|
1511
|
+
let passCount = 0;
|
|
1512
|
+
const cap = instance.maxLayoutPasses;
|
|
1513
|
+
if (INSTRUMENT) beginConvergenceLoop();
|
|
1514
|
+
for (let pass = 0; pass < cap; pass++) {
|
|
1515
|
+
hadReactCommit = false;
|
|
1516
|
+
passCount++;
|
|
1517
|
+
if (INSTRUMENT) beginPass(pass);
|
|
1518
|
+
let renderError = null;
|
|
1519
|
+
let carryForwardBuffer;
|
|
1520
|
+
withActEnvironment(() => {
|
|
1521
|
+
act(() => {
|
|
1522
|
+
const root = getContainerRoot(instance.container);
|
|
1523
|
+
try {
|
|
1524
|
+
const result = runPipeline(root, instance.columns, instance.rows, incremental ? instance.prevBuffer : null);
|
|
1525
|
+
output = result.output;
|
|
1526
|
+
buffer = result.buffer;
|
|
1527
|
+
carryForwardBuffer = result.carryForwardBuffer;
|
|
1528
|
+
incrementalOverlay = result.overlay;
|
|
1529
|
+
} catch (e) {
|
|
1530
|
+
renderError = e;
|
|
1531
|
+
const attachedBuffer = e?.__silvery_buffer;
|
|
1532
|
+
if (attachedBuffer) {
|
|
1533
|
+
buffer = attachedBuffer;
|
|
1534
|
+
carryForwardBuffer = attachedBuffer;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (buffer) {
|
|
1538
|
+
instance.prevBuffer = carryForwardBuffer ?? buffer;
|
|
1539
|
+
instance.prevPaintedBuffer = buffer;
|
|
1540
|
+
}
|
|
1541
|
+
instance.renderCount++;
|
|
1542
|
+
onBufferReady?.(output, buffer, getRootContentHeight());
|
|
1543
|
+
});
|
|
1544
|
+
if (!hadReactCommit) act(() => {
|
|
1545
|
+
reconciler.flushSyncWork();
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
if (renderError) {
|
|
1549
|
+
if (!(renderError instanceof IncrementalRenderMismatchError)) throw renderError;
|
|
1550
|
+
}
|
|
1551
|
+
if (!hadReactCommit) {
|
|
1552
|
+
multiPassConverged = true;
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
invalidateRootAfterLayoutFeedback(getContainerRoot(instance.container));
|
|
1556
|
+
if (pass === cap - 1) recordPassRing("unknown", "layout-pass-exhaustion");
|
|
1557
|
+
if (INSTRUMENT) {
|
|
1558
|
+
notePassCommit(pass);
|
|
1559
|
+
if (pass === cap - 1) logPass({
|
|
1560
|
+
cause: "unknown",
|
|
1561
|
+
detail: "layout-pass-exhaustion"
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (hadReactCommit && passCount >= cap) assertBoundedConvergence(passCount, "layout-pass", cap);
|
|
1566
|
+
if (incremental && buffer && passCount > 1) buffer.markAllRowsDirty();
|
|
1567
|
+
clsMonitor.onCommit(getContainerRoot(instance.container), instance.columns, instance.rows, false);
|
|
1568
|
+
if (strictMode && instance.renderCount > 1 && multiPassConverged) {
|
|
1569
|
+
const root = getContainerRoot(instance.container);
|
|
1570
|
+
const { buffer: freshBuffer, overlay: freshOverlay } = doFreshRenderFull();
|
|
1571
|
+
if (incrementalOverlay !== freshOverlay) throw new IncrementalRenderMismatchError(formatRendererOverlayMismatch(incrementalOverlay, freshOverlay, instance.renderCount));
|
|
1572
|
+
for (let y = 0; y < buffer.height; y++) for (let x = 0; x < buffer.width; x++) {
|
|
1573
|
+
const a = buffer.getCell(x, y);
|
|
1574
|
+
const b = freshBuffer.getCell(x, y);
|
|
1575
|
+
if (!cellEquals(a, b)) {
|
|
1576
|
+
let trapInfo = "";
|
|
1577
|
+
const trap = {
|
|
1578
|
+
x,
|
|
1579
|
+
y,
|
|
1580
|
+
log: []
|
|
1581
|
+
};
|
|
1582
|
+
globalThis.__silvery_write_trap = trap;
|
|
1583
|
+
try {
|
|
1584
|
+
doFreshRender();
|
|
1585
|
+
} catch {}
|
|
1586
|
+
globalThis.__silvery_write_trap = null;
|
|
1587
|
+
if (trap.log.length > 0) trapInfo = `\nWRITE TRAP (${trap.log.length} writes to (${x},${y})):\n${trap.log.join("\n")}\n`;
|
|
1588
|
+
else trapInfo = `\nWRITE TRAP: NO WRITES to (${x},${y})\n`;
|
|
1589
|
+
const ctx = buildMismatchContext(root, x, y, a, b, instance.renderCount);
|
|
1590
|
+
const renderPhaseStats = globalThis.__silvery_content_detail ? structuredClone(globalThis.__silvery_content_detail) : void 0;
|
|
1591
|
+
const debugInfo = formatMismatchContext(ctx, renderPhaseStats);
|
|
1592
|
+
const incText = bufferToText(buffer);
|
|
1593
|
+
const freshText = bufferToText(freshBuffer);
|
|
1594
|
+
throw new IncrementalRenderMismatchError(debugInfo + trapInfo + `--- incremental ---\n${incText}\n--- fresh ---\n${freshText}`, {
|
|
1595
|
+
renderPhaseStats,
|
|
1596
|
+
mismatchContext: ctx
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (instance.prevBuffer && instance.renderCount > 1 && multiPassConverged && isResidueStrictEnabled()) {
|
|
1601
|
+
const root2 = getContainerRoot(instance.container);
|
|
1602
|
+
const realPrev = instance.prevBuffer.clone();
|
|
1603
|
+
const poisonedPrev = instance.prevBuffer.clone();
|
|
1604
|
+
poisonBufferWithSentinel(poisonedPrev);
|
|
1605
|
+
const sentinelResult = runPipeline(root2, instance.columns, instance.rows, poisonedPrev, {
|
|
1606
|
+
skipLayoutNotifications: true,
|
|
1607
|
+
skipScrollStateUpdates: true,
|
|
1608
|
+
fresh: true
|
|
1609
|
+
});
|
|
1610
|
+
const { buffer: freshFromZero } = doFreshRenderFull();
|
|
1611
|
+
verifyNoResidueLeak(realPrev, sentinelResult.buffer, freshFromZero, instance.renderCount);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return output;
|
|
1615
|
+
}
|
|
1616
|
+
function doFreshRenderFull() {
|
|
1617
|
+
const root = getContainerRoot(instance.container);
|
|
1618
|
+
const freshLayout = isStrictEnabled(FRESH_LAYOUT_STRICT_SLUG, 2);
|
|
1619
|
+
if (freshLayout) markLayoutTreeDirty(root);
|
|
1620
|
+
const { buffer, overlay } = runPipeline(root, instance.columns, instance.rows, null, {
|
|
1621
|
+
skipLayoutNotifications: true,
|
|
1622
|
+
skipScrollStateUpdates: true,
|
|
1623
|
+
fresh: true,
|
|
1624
|
+
forceFullPropagate: freshLayout
|
|
1625
|
+
});
|
|
1626
|
+
return {
|
|
1627
|
+
buffer,
|
|
1628
|
+
overlay
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
function doFreshRender() {
|
|
1632
|
+
return doFreshRenderFull().buffer;
|
|
1633
|
+
}
|
|
1634
|
+
instance.rendering = true;
|
|
1635
|
+
try {
|
|
1636
|
+
withActEnvironment(() => {
|
|
1637
|
+
act(() => {
|
|
1638
|
+
reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
|
|
1639
|
+
reconciler.flushSyncWork();
|
|
1640
|
+
});
|
|
1641
|
+
});
|
|
1642
|
+
} finally {
|
|
1643
|
+
instance.rendering = false;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Promote in-flight rect signals to their committed peers, then drain any
|
|
1647
|
+
* resulting React work with one more `doRender()` if needed.
|
|
1648
|
+
*
|
|
1649
|
+
* Reactive `useBoxRect()` / `useScrollRect()` / `useScreenRect()` hooks
|
|
1650
|
+
* read the COMMITTED rect signals (stable across all convergence passes
|
|
1651
|
+
* within one batch). Calling `commitLayoutSnapshot` advances them by one
|
|
1652
|
+
* frame, which may fire `effect()` callbacks that trigger React
|
|
1653
|
+
* `forceUpdate`s. Those forceUpdates need to land before the test
|
|
1654
|
+
* driver's caller observes the frame — otherwise the first frame would
|
|
1655
|
+
* show the empty-rect fallback for any reactive layout consumer.
|
|
1656
|
+
*
|
|
1657
|
+
* The settle pass is bounded to ONE doRender. Any in-flight rect changes
|
|
1658
|
+
* produced by it are intentionally NOT committed in this batch — they
|
|
1659
|
+
* belong to the next event boundary (one-frame-late contract).
|
|
1660
|
+
*
|
|
1661
|
+
* See bead `@km/silvery/use-deferred-box-rect-and-post-commit-observers`.
|
|
1662
|
+
*/
|
|
1663
|
+
function settleAfterCommit(prevOutput) {
|
|
1664
|
+
const root = getContainerRoot(instance.container);
|
|
1665
|
+
let scheduled = false;
|
|
1666
|
+
hadReactCommit = false;
|
|
1667
|
+
withActEnvironment(() => {
|
|
1668
|
+
act(() => {
|
|
1669
|
+
commitLayoutSnapshot(root);
|
|
1670
|
+
});
|
|
1671
|
+
if (hadReactCommit) {
|
|
1672
|
+
act(() => {
|
|
1673
|
+
reconciler.flushSyncWork();
|
|
1674
|
+
});
|
|
1675
|
+
scheduled = true;
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
if (!scheduled) return prevOutput;
|
|
1679
|
+
return doRender();
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Drain additional commit / layout cycles until the React tree is stable
|
|
1683
|
+
* (no pending commit, no dirty layout nodes) OR a budget is reached.
|
|
1684
|
+
* Resolves once stable so tests can assert post-convergence state.
|
|
1685
|
+
*
|
|
1686
|
+
* The default is production timing — `render()` exposes a frame after the
|
|
1687
|
+
* production-cap `MAX_CONVERGENCE_PASSES`. Tests asserting layout that
|
|
1688
|
+
* requires more passes to settle (multi-layer measurement chains,
|
|
1689
|
+
* Suspense-driven first-paint cascades, etc.) call this explicitly:
|
|
1690
|
+
*
|
|
1691
|
+
* ```ts
|
|
1692
|
+
* const app = render(<MyApp />)
|
|
1693
|
+
* await app.waitForLayoutStable()
|
|
1694
|
+
* expect(app.text).toMatchSnapshot()
|
|
1695
|
+
* ```
|
|
1696
|
+
*
|
|
1697
|
+
* Stability detection: a pass is run, then `flushSyncWork` runs in a
|
|
1698
|
+
* separate `act()`. If `hadReactCommit` flips during the flush, layout
|
|
1699
|
+
* is still in-flight; loop again (run another commit boundary + flush).
|
|
1700
|
+
* Same predicate the renderer uses internally — does not invent a new
|
|
1701
|
+
* readiness probe.
|
|
1702
|
+
*
|
|
1703
|
+
* Bounded by `maxPasses` (default 20) and `timeoutMs` (default 50ms).
|
|
1704
|
+
* If the cap is reached, resolves WITHOUT throwing — an infinitely
|
|
1705
|
+
* non-converging app is a bug, not a test fault. The contract is "best
|
|
1706
|
+
* effort within the budget."
|
|
1707
|
+
*
|
|
1708
|
+
* Bead: `@km/silvery/test-harness-convergence-cap-parity`.
|
|
1709
|
+
*/
|
|
1710
|
+
async function waitForLayoutStable(opts) {
|
|
1711
|
+
if (!instance.mounted) return;
|
|
1712
|
+
const timeoutMs = opts?.timeoutMs ?? 50;
|
|
1713
|
+
const maxPasses = opts?.maxPasses ?? 20;
|
|
1714
|
+
const start = performance.now();
|
|
1715
|
+
let lastFrame = instance.frames[instance.frames.length - 1] ?? "";
|
|
1716
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
1717
|
+
if (performance.now() - start >= timeoutMs) return;
|
|
1718
|
+
if (!instance.mounted) return;
|
|
1719
|
+
hadReactCommit = false;
|
|
1720
|
+
instance.rendering = true;
|
|
1721
|
+
try {
|
|
1722
|
+
await act(async () => {
|
|
1723
|
+
await Promise.resolve();
|
|
1724
|
+
});
|
|
1725
|
+
} finally {
|
|
1726
|
+
instance.rendering = false;
|
|
1727
|
+
}
|
|
1728
|
+
const reactCommittedThisPass = hadReactCommit;
|
|
1729
|
+
let next = lastFrame;
|
|
1730
|
+
if (reactCommittedThisPass) next = doRender();
|
|
1731
|
+
next = settleAfterCommit(next);
|
|
1732
|
+
if (!reactCommittedThisPass && next === lastFrame && !hadReactCommit) {
|
|
1733
|
+
try {
|
|
1734
|
+
const root = getContainerRoot(instance.container);
|
|
1735
|
+
if (root && !isAnyDirty(root.dirtyBits, root.dirtyEpoch)) return;
|
|
1736
|
+
} catch {
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (next !== lastFrame) {
|
|
1742
|
+
lastFrame = next;
|
|
1743
|
+
instance.frames.push(next);
|
|
1744
|
+
onFrame?.(next, instance.prevBuffer, getRootContentHeight());
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
let output = doRender();
|
|
1749
|
+
output = settleAfterCommit(output);
|
|
1750
|
+
instance.frames.push(output);
|
|
1751
|
+
onFrame?.(output, instance.prevBuffer, getRootContentHeight());
|
|
1752
|
+
if (debug) console.log("[silvery] Initial render:", output);
|
|
1753
|
+
if (instance.prevBuffer) {
|
|
1754
|
+
const bufW = instance.prevBuffer.width;
|
|
1755
|
+
const bufH = instance.prevBuffer.height;
|
|
1756
|
+
const totalCells = bufW * bufH;
|
|
1757
|
+
if (totalCells >= 4e3) {
|
|
1758
|
+
const painted = instance.prevBuffer.countPaintedCells();
|
|
1759
|
+
const ratio = painted / totalCells;
|
|
1760
|
+
if (ratio < .05) {
|
|
1761
|
+
const msg = `silvery: degenerate frame after first render — only ${painted} of ${totalCells} cells painted (${(ratio * 100).toFixed(2)}%) at ${bufW}x${bufH}. Likely cause: the root component does not pin width/height (no <Screen> wrapper). createRenderer({cols, rows}) passes dimensions as the AVAILABLE size to layout, but does NOT set root.style.width/height — wrap the tree in <Box width={cols} height={rows}> or <Screen>. Per-test opt-out: SILVERY_STRICT=1,!canary. See @km/silvery/render-degenerate-frame-canary.`;
|
|
1762
|
+
if (isStrictEnabled("canary", 2)) throw new Error(msg);
|
|
1763
|
+
else log.debug?.(msg);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
let stdinOnReadable;
|
|
1768
|
+
if (stdinStream) {
|
|
1769
|
+
stdinOnReadable = () => {
|
|
1770
|
+
let chunk;
|
|
1771
|
+
while ((chunk = stdinStream.read?.()) !== null && chunk !== void 0) instance.inputEmitter.emit("input", chunk);
|
|
1772
|
+
};
|
|
1773
|
+
stdinStream.on("readable", stdinOnReadable);
|
|
1774
|
+
}
|
|
1775
|
+
const getContainer = () => getContainerRoot(instance.container);
|
|
1776
|
+
const getBuffer = () => instance.prevPaintedBuffer ?? instance.prevBuffer;
|
|
1777
|
+
const sendInput = (data) => {
|
|
1778
|
+
if (!instance.mounted) throw new Error("Cannot write to stdin after unmount");
|
|
1779
|
+
if (instance.rendering) throw new Error("silvery: Re-entrant render detected. Cannot call press()/stdin.write() from inside a React render or effect. Use setTimeout or an event handler instead.");
|
|
1780
|
+
const t0 = performance.now();
|
|
1781
|
+
instance.rendering = true;
|
|
1782
|
+
try {
|
|
1783
|
+
let pasteResult = null;
|
|
1784
|
+
try {
|
|
1785
|
+
pasteResult = parseBracketedPaste(data);
|
|
1786
|
+
} catch (err) {
|
|
1787
|
+
if (isProtocolError(err)) log.debug?.(`bracketed paste parser flagged malformed input in press(): ${err.reason} (len=${err.inputLength})`);
|
|
1788
|
+
else throw err;
|
|
1789
|
+
}
|
|
1790
|
+
if (pasteResult) withActEnvironment(() => {
|
|
1791
|
+
act(() => {
|
|
1792
|
+
instance.inputEmitter.emit("paste", pasteResult.content);
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
else withActEnvironment(() => {
|
|
1796
|
+
for (const keypress of splitRawInput(data)) {
|
|
1797
|
+
const [, key] = parseKey(keypress);
|
|
1798
|
+
if (handleTabCycling && key.tab && !key.shift) {
|
|
1799
|
+
act(() => {
|
|
1800
|
+
const root = getContainerRoot(instance.container);
|
|
1801
|
+
focusManager.focusNext(root);
|
|
1802
|
+
});
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
if (handleTabCycling && key.tab && key.shift) {
|
|
1806
|
+
act(() => {
|
|
1807
|
+
const root = getContainerRoot(instance.container);
|
|
1808
|
+
focusManager.focusPrev(root);
|
|
1809
|
+
});
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (key.escape && focusManager.activeElement) {
|
|
1813
|
+
act(() => {
|
|
1814
|
+
focusManager.blur();
|
|
1815
|
+
});
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
act(() => {
|
|
1819
|
+
instance.inputEmitter.emit("input", keypress);
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
} finally {
|
|
1824
|
+
instance.rendering = false;
|
|
1825
|
+
}
|
|
1826
|
+
const t1 = performance.now();
|
|
1827
|
+
let newFrame = doRender();
|
|
1828
|
+
let doRenderCount = 1;
|
|
1829
|
+
if (instance.maxLayoutPasses <= MAX_CONVERGENCE_PASSES) {
|
|
1830
|
+
let flushCount = 0;
|
|
1831
|
+
if (INSTRUMENT) beginConvergenceLoop();
|
|
1832
|
+
for (let flush = 0; flush < MAX_CONVERGENCE_PASSES; flush++) {
|
|
1833
|
+
hadReactCommit = false;
|
|
1834
|
+
flushCount = flush + 1;
|
|
1835
|
+
if (INSTRUMENT) beginPass(flush);
|
|
1836
|
+
withActEnvironment(() => {
|
|
1837
|
+
act(() => {
|
|
1838
|
+
reconciler.flushSyncWork();
|
|
1839
|
+
});
|
|
1840
|
+
});
|
|
1841
|
+
if (!hadReactCommit) break;
|
|
1842
|
+
if (flush === MAX_CONVERGENCE_PASSES - 1) recordPassRing("unknown", "effect-flush-exhaustion");
|
|
1843
|
+
if (INSTRUMENT) {
|
|
1844
|
+
notePassCommit(flush);
|
|
1845
|
+
if (flush === MAX_CONVERGENCE_PASSES - 1) logPass({
|
|
1846
|
+
cause: "unknown",
|
|
1847
|
+
detail: "effect-flush-exhaustion"
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
newFrame = doRender();
|
|
1851
|
+
doRenderCount++;
|
|
1852
|
+
}
|
|
1853
|
+
if (flushCount >= MAX_CONVERGENCE_PASSES && hadReactCommit) assertBoundedConvergence(flushCount, "effect-flush", MAX_CONVERGENCE_PASSES);
|
|
1854
|
+
}
|
|
1855
|
+
const settled = settleAfterCommit(newFrame);
|
|
1856
|
+
if (settled !== newFrame) {
|
|
1857
|
+
newFrame = settled;
|
|
1858
|
+
doRenderCount++;
|
|
1859
|
+
}
|
|
1860
|
+
if (incremental && doRenderCount > 1 && instance.prevBuffer) instance.prevBuffer.markAllRowsDirty();
|
|
1861
|
+
const t2 = performance.now();
|
|
1862
|
+
instance.frames.push(newFrame);
|
|
1863
|
+
onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
|
|
1864
|
+
if (debug) console.log("[silvery] stdin.write:", newFrame);
|
|
1865
|
+
globalThis.__silvery_last_timing = {
|
|
1866
|
+
actMs: t1 - t0,
|
|
1867
|
+
renderMs: t2 - t1
|
|
1868
|
+
};
|
|
1869
|
+
};
|
|
1870
|
+
const rerenderFn = (newElement) => {
|
|
1871
|
+
if (!instance.mounted) throw new Error("Cannot rerender after unmount");
|
|
1872
|
+
if (instance.rendering) throw new Error("silvery: Re-entrant render detected. Cannot call rerender() from inside a React render or effect.");
|
|
1873
|
+
instance.rendering = true;
|
|
1874
|
+
try {
|
|
1875
|
+
withActEnvironment(() => {
|
|
1876
|
+
act(() => {
|
|
1877
|
+
currentElement = newElement;
|
|
1878
|
+
reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
|
|
1879
|
+
reconciler.flushSyncWork();
|
|
1880
|
+
});
|
|
1881
|
+
});
|
|
1882
|
+
} finally {
|
|
1883
|
+
instance.rendering = false;
|
|
1884
|
+
}
|
|
1885
|
+
const newFrame = settleAfterCommit(doRender());
|
|
1886
|
+
instance.frames.push(newFrame);
|
|
1887
|
+
onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
|
|
1888
|
+
if (debug) console.log("[silvery] Rerender:", newFrame);
|
|
1889
|
+
};
|
|
1890
|
+
const renderTracker = {
|
|
1891
|
+
unmount: () => {},
|
|
1892
|
+
id: renderId
|
|
1893
|
+
};
|
|
1894
|
+
const renderRef = new WeakRef(renderTracker);
|
|
1895
|
+
activeRenders.add(renderRef);
|
|
1896
|
+
const unmountFn = () => {
|
|
1897
|
+
if (!instance.mounted) throw new Error("Already unmounted");
|
|
1898
|
+
const fiberRoot = instance.fiberRoot;
|
|
1899
|
+
withActEnvironment(() => {
|
|
1900
|
+
act(() => {
|
|
1901
|
+
reconciler.updateContainerSync(null, fiberRoot, null, null);
|
|
1902
|
+
reconciler.flushSyncWork();
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
renderScope[Symbol.asyncDispose]().catch((error) => reportDisposeError(error, {
|
|
1906
|
+
phase: "app-exit",
|
|
1907
|
+
scope: renderScope
|
|
1908
|
+
}));
|
|
1909
|
+
instance.mounted = false;
|
|
1910
|
+
instance.rendering = false;
|
|
1911
|
+
autoRenderScheduled = false;
|
|
1912
|
+
inRenderCycle = false;
|
|
1913
|
+
instance.inputEmitter.removeAllListeners();
|
|
1914
|
+
stdoutEmitter.removeAllListeners();
|
|
1915
|
+
if (stdinStream && stdinOnReadable) {
|
|
1916
|
+
stdinStream.removeListener("readable", stdinOnReadable);
|
|
1917
|
+
stdinOnReadable = void 0;
|
|
1918
|
+
}
|
|
1919
|
+
activeRenders.delete(renderRef);
|
|
1920
|
+
clearFn();
|
|
1921
|
+
instance.kittyActive = false;
|
|
1922
|
+
releaseContainer(instance.container);
|
|
1923
|
+
instance.fiberRoot = null;
|
|
1924
|
+
if (debug) console.log("[silvery] Unmounted");
|
|
1925
|
+
};
|
|
1926
|
+
renderTracker.unmount = unmountFn;
|
|
1927
|
+
const clearFn = () => {
|
|
1928
|
+
instance.frames.length = 0;
|
|
1929
|
+
instance.prevBuffer = null;
|
|
1930
|
+
instance.prevPaintedBuffer = null;
|
|
1931
|
+
instance.postState = createRenderPostState();
|
|
1932
|
+
};
|
|
1933
|
+
const debugFn = () => {
|
|
1934
|
+
console.log(debugTree(getContainerRoot(instance.container)));
|
|
1935
|
+
};
|
|
1936
|
+
const actAndRenderFn = (fn) => {
|
|
1937
|
+
if (!instance.mounted) return;
|
|
1938
|
+
withActEnvironment(() => {
|
|
1939
|
+
act(() => {
|
|
1940
|
+
fn();
|
|
1941
|
+
reconciler.updateContainerSync(wrapWithContexts(currentElement), instance.fiberRoot, null, null);
|
|
1942
|
+
reconciler.flushSyncWork();
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
const newFrame = settleAfterCommit(doRender());
|
|
1946
|
+
instance.frames.push(newFrame);
|
|
1947
|
+
onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
|
|
1948
|
+
};
|
|
1949
|
+
const resizeFn = (newCols, newRows) => {
|
|
1950
|
+
if (!instance.mounted) throw new Error("Cannot resize after unmount");
|
|
1951
|
+
instance.columns = newCols;
|
|
1952
|
+
instance.rows = newRows;
|
|
1953
|
+
mockStdout.columns = newCols;
|
|
1954
|
+
mockStdout.rows = newRows;
|
|
1955
|
+
updateMockTermSize?.(newCols, newRows);
|
|
1956
|
+
stdoutEmitter.emit("resize");
|
|
1957
|
+
instance.prevBuffer = null;
|
|
1958
|
+
instance.prevPaintedBuffer = null;
|
|
1959
|
+
instance.postState = createRenderPostState();
|
|
1960
|
+
let newFrame = doRender();
|
|
1961
|
+
let doRenderCount = 1;
|
|
1962
|
+
if (instance.maxLayoutPasses <= MAX_CONVERGENCE_PASSES) for (let flush = 0; flush < MAX_CONVERGENCE_PASSES; flush++) {
|
|
1963
|
+
hadReactCommit = false;
|
|
1964
|
+
withActEnvironment(() => {
|
|
1965
|
+
act(() => {
|
|
1966
|
+
reconciler.flushSyncWork();
|
|
1967
|
+
});
|
|
1968
|
+
});
|
|
1969
|
+
if (!hadReactCommit) break;
|
|
1970
|
+
newFrame = doRender();
|
|
1971
|
+
doRenderCount++;
|
|
1972
|
+
}
|
|
1973
|
+
const settled = settleAfterCommit(newFrame);
|
|
1974
|
+
if (settled !== newFrame) {
|
|
1975
|
+
newFrame = settled;
|
|
1976
|
+
doRenderCount++;
|
|
1977
|
+
}
|
|
1978
|
+
const prevBufferForDirtying = instance.prevBuffer;
|
|
1979
|
+
if (incremental && doRenderCount > 1 && prevBufferForDirtying) prevBufferForDirtying.markAllRowsDirty();
|
|
1980
|
+
instance.frames.push(newFrame);
|
|
1981
|
+
onFrame?.(newFrame, instance.prevBuffer, getRootContentHeight());
|
|
1982
|
+
if (debug) console.log("[silvery] Resize:", newCols, "x", newRows);
|
|
1983
|
+
};
|
|
1984
|
+
return buildApp({
|
|
1985
|
+
getContainer,
|
|
1986
|
+
getBuffer,
|
|
1987
|
+
sendInput,
|
|
1988
|
+
rerender: rerenderFn,
|
|
1989
|
+
unmount: unmountFn,
|
|
1990
|
+
waitUntilExit: () => Promise.resolve(),
|
|
1991
|
+
clear: clearFn,
|
|
1992
|
+
exitCalled: () => exitCalledFlag,
|
|
1993
|
+
exitError: () => exitErrorValue,
|
|
1994
|
+
freshRender: doFreshRender,
|
|
1995
|
+
debugFn,
|
|
1996
|
+
frames: instance.frames,
|
|
1997
|
+
columns: cols,
|
|
1998
|
+
rows,
|
|
1999
|
+
kittyMode,
|
|
2000
|
+
actAndRender: actAndRenderFn,
|
|
2001
|
+
resize: resizeFn,
|
|
2002
|
+
focusManager,
|
|
2003
|
+
getCursorState: cursorStore.accessors.getCursorState,
|
|
2004
|
+
waitForLayoutStable,
|
|
2005
|
+
clsMonitor
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Create a Store from provider options.
|
|
2010
|
+
*
|
|
2011
|
+
* A Store is the TermDef-like environment: cols, rows, and optionally events.
|
|
2012
|
+
*
|
|
2013
|
+
* @example
|
|
2014
|
+
* ```tsx
|
|
2015
|
+
* const store = createStore({ cols: 80, rows: 24 })
|
|
2016
|
+
* const app = render(<App />, store)
|
|
2017
|
+
* ```
|
|
2018
|
+
*/
|
|
2019
|
+
function createStore(options = {}) {
|
|
2020
|
+
const { cols = 80, rows = 24, events: eventsSource } = options;
|
|
2021
|
+
const store = {
|
|
2022
|
+
cols,
|
|
2023
|
+
rows
|
|
2024
|
+
};
|
|
2025
|
+
if (eventsSource) store.events = () => eventsSource;
|
|
2026
|
+
return store;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Create a render function that auto-cleans previous renders.
|
|
2030
|
+
*
|
|
2031
|
+
* By default, incremental rendering is ENABLED for test renders.
|
|
2032
|
+
* This matches production behavior (live scheduler uses incremental)
|
|
2033
|
+
* and enables withDiagnostics to catch incremental vs fresh mismatches.
|
|
2034
|
+
*
|
|
2035
|
+
* **Instance reuse:** When the same renderer is called repeatedly with a
|
|
2036
|
+
* compatible element (no overrides that would require fresh state), the
|
|
2037
|
+
* returned function calls `current.rerender(element)` instead of
|
|
2038
|
+
* unmount+remount. This avoids paying the full React reconciler init +
|
|
2039
|
+
* fiberRoot creation cost on each iteration — which otherwise dominates
|
|
2040
|
+
* cold-render microbenchmarks. See bead `km-silvery.renderer-reuse`.
|
|
2041
|
+
*
|
|
2042
|
+
* Reuse is skipped (forcing fresh mount) when:
|
|
2043
|
+
* - The previous instance has been unmounted (rerender would throw)
|
|
2044
|
+
* - Per-render overrides are supplied that conflict with baseOpts
|
|
2045
|
+
* (e.g., flipping `incremental`, `maxLayoutPasses`, or `kittyMode`)
|
|
2046
|
+
* - The base options are a Store (store-mode renders manage their own
|
|
2047
|
+
* lifecycle and we conservatively force a fresh mount)
|
|
2048
|
+
*
|
|
2049
|
+
* @example
|
|
2050
|
+
* ```tsx
|
|
2051
|
+
* const render = createRenderer({ cols: 80, rows: 24 })
|
|
2052
|
+
* const app1 = render(<Foo />) // fresh mount
|
|
2053
|
+
* const app2 = render(<Bar />) // reuses app1 via rerender()
|
|
2054
|
+
* // app1 === app2 when reuse kicks in
|
|
2055
|
+
*
|
|
2056
|
+
* // Explicitly disable incremental if needed
|
|
2057
|
+
* const render2 = createRenderer({ cols: 80, rows: 24, incremental: false })
|
|
2058
|
+
* ```
|
|
2059
|
+
*/
|
|
2060
|
+
function createRenderer(optsOrStore = {}) {
|
|
2061
|
+
let current = null;
|
|
2062
|
+
let currentReusable = false;
|
|
2063
|
+
const baseOpts = isStore(optsOrStore) ? {
|
|
2064
|
+
incremental: true,
|
|
2065
|
+
cols: optsOrStore.cols,
|
|
2066
|
+
rows: optsOrStore.rows
|
|
2067
|
+
} : {
|
|
2068
|
+
incremental: true,
|
|
2069
|
+
...optsOrStore
|
|
2070
|
+
};
|
|
2071
|
+
return (element, overrides) => {
|
|
2072
|
+
if (current && currentReusable && canReuseInstance(overrides, baseOpts)) try {
|
|
2073
|
+
current.rerender(element);
|
|
2074
|
+
return current;
|
|
2075
|
+
} catch {
|
|
2076
|
+
currentReusable = false;
|
|
2077
|
+
}
|
|
2078
|
+
if (current) try {
|
|
2079
|
+
current.unmount();
|
|
2080
|
+
} catch {}
|
|
2081
|
+
let opts = baseOpts;
|
|
2082
|
+
if (overrides && !isStore(opts)) opts = {
|
|
2083
|
+
...opts,
|
|
2084
|
+
...overrides
|
|
2085
|
+
};
|
|
2086
|
+
current = render(element, opts);
|
|
2087
|
+
currentReusable = true;
|
|
2088
|
+
return current;
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Can we reuse the current App instance for this call? Overrides that would
|
|
2093
|
+
* change reconciler/layout semantics force a fresh mount.
|
|
2094
|
+
*/
|
|
2095
|
+
function canReuseInstance(overrides, baseOpts) {
|
|
2096
|
+
if (!overrides) return true;
|
|
2097
|
+
if (overrides.incremental !== void 0 && overrides.incremental !== (baseOpts.incremental ?? true)) return false;
|
|
2098
|
+
if (overrides.maxLayoutPasses !== void 0 && overrides.maxLayoutPasses !== baseOpts.maxLayoutPasses) return false;
|
|
2099
|
+
if (overrides.kittyMode !== void 0 && overrides.kittyMode !== (baseOpts.kittyMode ?? false)) return false;
|
|
2100
|
+
return true;
|
|
2101
|
+
}
|
|
2102
|
+
function run(app, events) {
|
|
2103
|
+
if (events !== void 0 && !isAsyncIterable(events)) {
|
|
2104
|
+
const iter = Array.isArray(events) ? events : events;
|
|
2105
|
+
const processedEvents = [];
|
|
2106
|
+
for (const key of iter) {
|
|
2107
|
+
app.stdin.write(keyToAnsi(key));
|
|
2108
|
+
processedEvents.push(key);
|
|
2109
|
+
}
|
|
2110
|
+
return {
|
|
2111
|
+
get text() {
|
|
2112
|
+
return app.text;
|
|
2113
|
+
},
|
|
2114
|
+
app,
|
|
2115
|
+
[Symbol.iterator]() {
|
|
2116
|
+
return processedEvents[Symbol.iterator]();
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
let stopped = false;
|
|
2121
|
+
const unmount = () => {
|
|
2122
|
+
stopped = true;
|
|
2123
|
+
app.unmount();
|
|
2124
|
+
};
|
|
2125
|
+
return {
|
|
2126
|
+
get text() {
|
|
2127
|
+
return app.text;
|
|
2128
|
+
},
|
|
2129
|
+
app,
|
|
2130
|
+
unmount,
|
|
2131
|
+
then(onfulfilled, onrejected) {
|
|
2132
|
+
return (async () => {
|
|
2133
|
+
if (events) for await (const key of events) {
|
|
2134
|
+
if (stopped) break;
|
|
2135
|
+
await app.press(key);
|
|
2136
|
+
}
|
|
2137
|
+
else await app.run();
|
|
2138
|
+
})().then(onfulfilled, onrejected);
|
|
2139
|
+
},
|
|
2140
|
+
[Symbol.asyncIterator]() {
|
|
2141
|
+
if (!events) {
|
|
2142
|
+
let yielded = false;
|
|
2143
|
+
return { async next() {
|
|
2144
|
+
if (yielded || stopped) return {
|
|
2145
|
+
done: true,
|
|
2146
|
+
value: void 0
|
|
2147
|
+
};
|
|
2148
|
+
yielded = true;
|
|
2149
|
+
return {
|
|
2150
|
+
done: false,
|
|
2151
|
+
value: app.text
|
|
2152
|
+
};
|
|
2153
|
+
} };
|
|
2154
|
+
}
|
|
2155
|
+
const iter = events[Symbol.asyncIterator]();
|
|
2156
|
+
return {
|
|
2157
|
+
async next() {
|
|
2158
|
+
if (stopped) return {
|
|
2159
|
+
done: true,
|
|
2160
|
+
value: void 0
|
|
2161
|
+
};
|
|
2162
|
+
const result = await iter.next();
|
|
2163
|
+
if (result.done) return {
|
|
2164
|
+
done: true,
|
|
2165
|
+
value: void 0
|
|
2166
|
+
};
|
|
2167
|
+
await app.press(result.value);
|
|
2168
|
+
return {
|
|
2169
|
+
done: false,
|
|
2170
|
+
value: app.text
|
|
2171
|
+
};
|
|
2172
|
+
},
|
|
2173
|
+
async return() {
|
|
2174
|
+
unmount();
|
|
2175
|
+
return {
|
|
2176
|
+
done: true,
|
|
2177
|
+
value: void 0
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
function isAsyncIterable(value) {
|
|
2185
|
+
return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Run a function with IS_REACT_ACT_ENVIRONMENT temporarily set to true.
|
|
2189
|
+
* This ensures act() works correctly without polluting the global scope.
|
|
2190
|
+
*/
|
|
2191
|
+
function withActEnvironment(fn) {
|
|
2192
|
+
const prev = globalThis.IS_REACT_ACT_ENVIRONMENT;
|
|
2193
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
2194
|
+
try {
|
|
2195
|
+
fn();
|
|
2196
|
+
} finally {
|
|
2197
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = prev;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Get the number of currently active (mounted) render instances.
|
|
2202
|
+
* Useful for tests to verify cleanup.
|
|
2203
|
+
*/
|
|
2204
|
+
function getActiveRenderCount() {
|
|
2205
|
+
return pruneAndCountActiveRenders();
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Format a human-readable diff when the incremental and fresh Kitty overlays
|
|
2209
|
+
* disagree. The overlay is a Kitty graphics protocol escape sequence emitted
|
|
2210
|
+
* by `realizeToKitty` (see `pipeline/backdrop/`) — it
|
|
2211
|
+
* places translucent "scrim" images over emoji cells inside faded regions.
|
|
2212
|
+
* The invariant is that the fresh and incremental paths emit byte-identical
|
|
2213
|
+
* strings for the same tree; any drift is a determinism bug.
|
|
2214
|
+
*
|
|
2215
|
+
* The diff surfaces the placement IDs that differ — the Kitty protocol uses
|
|
2216
|
+
* `p=<id>` in each placement command, so grep-friendly IDs are the quickest
|
|
2217
|
+
* path from "test failed" to "which cell drifted".
|
|
2218
|
+
*/
|
|
2219
|
+
function formatRendererOverlayMismatch(incremental, fresh, renderNum) {
|
|
2220
|
+
const incPlacements = extractOverlayPlacementIds(incremental);
|
|
2221
|
+
const freshPlacements = extractOverlayPlacementIds(fresh);
|
|
2222
|
+
const incSet = new Set(incPlacements);
|
|
2223
|
+
const freshSet = new Set(freshPlacements);
|
|
2224
|
+
const onlyIncremental = incPlacements.filter((id) => !freshSet.has(id));
|
|
2225
|
+
const onlyFresh = freshPlacements.filter((id) => !incSet.has(id));
|
|
2226
|
+
const lines = [
|
|
2227
|
+
`[SILVERY_STRICT] Kitty overlay mismatch at render #${renderNum}`,
|
|
2228
|
+
` incremental length: ${incremental.length} bytes, placements: ${incPlacements.length}`,
|
|
2229
|
+
` fresh length: ${fresh.length} bytes, placements: ${freshPlacements.length}`
|
|
2230
|
+
];
|
|
2231
|
+
if (onlyIncremental.length > 0) lines.push(` only in incremental (moved/appeared): ${onlyIncremental.join(", ")}`);
|
|
2232
|
+
if (onlyFresh.length > 0) lines.push(` only in fresh (missing/disappeared): ${onlyFresh.join(", ")}`);
|
|
2233
|
+
if (onlyIncremental.length === 0 && onlyFresh.length === 0) lines.push(` placements match — drift is in scrim-image payload or ordering`);
|
|
2234
|
+
const cap = 400;
|
|
2235
|
+
lines.push(` incremental[0..${cap}]: ${JSON.stringify(incremental.slice(0, cap))}`, ` fresh [0..${cap}]: ${JSON.stringify(fresh.slice(0, cap))}`);
|
|
2236
|
+
return lines.join("\n");
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Extract the list of `p=<id>` placement IDs from a Kitty overlay string, in
|
|
2240
|
+
* emission order. Used by the STRICT diagnostic to show which placements
|
|
2241
|
+
* moved/appeared/disappeared.
|
|
2242
|
+
*/
|
|
2243
|
+
function extractOverlayPlacementIds(overlay) {
|
|
2244
|
+
const ids = [];
|
|
2245
|
+
const re = /p=(\d+)/g;
|
|
2246
|
+
let m;
|
|
2247
|
+
while ((m = re.exec(overlay)) !== null) ids.push(m[1]);
|
|
2248
|
+
return ids;
|
|
2249
|
+
}
|
|
2250
|
+
//#endregion
|
|
2251
|
+
//#region packages/test/src/index.tsx
|
|
2252
|
+
/**
|
|
2253
|
+
* Silvery Testing Library
|
|
2254
|
+
*
|
|
2255
|
+
* Unified App-based API for testing Silvery components.
|
|
2256
|
+
* Uses the actual silvery render pipeline for accurate ANSI output.
|
|
2257
|
+
*
|
|
2258
|
+
* ## Import Syntax
|
|
2259
|
+
*
|
|
2260
|
+
* ```tsx
|
|
2261
|
+
* import { createRenderer, bufferToText, stripAnsi } from '@silvery/test';
|
|
2262
|
+
* ```
|
|
2263
|
+
*
|
|
2264
|
+
* ## Auto-cleanup
|
|
2265
|
+
*
|
|
2266
|
+
* Each render() call from createRenderer automatically unmounts the previous render,
|
|
2267
|
+
* so you don't need explicit cleanup.
|
|
2268
|
+
*
|
|
2269
|
+
* ## Basic Testing
|
|
2270
|
+
*
|
|
2271
|
+
* @example
|
|
2272
|
+
* ```tsx
|
|
2273
|
+
* import { createRenderer } from '@silvery/test';
|
|
2274
|
+
* import { Text, Box } from '@silvery/ag-react';
|
|
2275
|
+
*
|
|
2276
|
+
* const render = createRenderer({ cols: 80, rows: 24 });
|
|
2277
|
+
*
|
|
2278
|
+
* test('renders text', () => {
|
|
2279
|
+
* const app = render(<Text>Hello</Text>);
|
|
2280
|
+
*
|
|
2281
|
+
* // Plain text (no ANSI)
|
|
2282
|
+
* expect(app.text).toContain('Hello');
|
|
2283
|
+
*
|
|
2284
|
+
* // Auto-refreshing locators
|
|
2285
|
+
* expect(app.getByText('Hello').count()).toBe(1);
|
|
2286
|
+
* });
|
|
2287
|
+
* ```
|
|
2288
|
+
*
|
|
2289
|
+
* ## Keyboard Input Testing
|
|
2290
|
+
*
|
|
2291
|
+
* @example
|
|
2292
|
+
* ```tsx
|
|
2293
|
+
* test('handles keyboard', () => {
|
|
2294
|
+
* const app = render(<MyComponent />);
|
|
2295
|
+
*
|
|
2296
|
+
* await app.press('j'); // Letter key
|
|
2297
|
+
* await app.press('ArrowUp'); // Arrow keys
|
|
2298
|
+
* await app.press('Escape'); // Special keys
|
|
2299
|
+
* await app.press('Enter'); // Enter
|
|
2300
|
+
*
|
|
2301
|
+
* expect(app.text).toContain('expected result');
|
|
2302
|
+
* });
|
|
2303
|
+
* ```
|
|
2304
|
+
*
|
|
2305
|
+
* ## Auto-refreshing Locators
|
|
2306
|
+
*
|
|
2307
|
+
* @example
|
|
2308
|
+
* ```tsx
|
|
2309
|
+
* test('locators auto-refresh', () => {
|
|
2310
|
+
* const app = render(<Board />);
|
|
2311
|
+
* const cursor = app.locator('[data-cursor]');
|
|
2312
|
+
*
|
|
2313
|
+
* expect(cursor.textContent()).toBe('item1');
|
|
2314
|
+
* await app.press('j');
|
|
2315
|
+
* expect(cursor.textContent()).toBe('item2'); // Same locator, fresh result!
|
|
2316
|
+
* });
|
|
2317
|
+
* ```
|
|
2318
|
+
*
|
|
2319
|
+
* ## Querying by ID
|
|
2320
|
+
*
|
|
2321
|
+
* Two equivalent approaches for identifying components:
|
|
2322
|
+
*
|
|
2323
|
+
* @example
|
|
2324
|
+
* ```tsx
|
|
2325
|
+
* // Option 1: id prop with #id selector (CSS-style, preferred)
|
|
2326
|
+
* const app = render(<Box id="sidebar">Content</Box>);
|
|
2327
|
+
* expect(app.locator('#sidebar').textContent()).toBe('Content');
|
|
2328
|
+
*
|
|
2329
|
+
* // Option 2: testID prop with getByTestId (React Testing Library style)
|
|
2330
|
+
* const app = render(<Box testID="sidebar">Content</Box>);
|
|
2331
|
+
* expect(app.getByTestId('sidebar').textContent()).toBe('Content');
|
|
2332
|
+
* ```
|
|
2333
|
+
*/
|
|
2334
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
2335
|
+
await ensureDefaultLayoutEngine();
|
|
2336
|
+
await preloadStrictTerminalBackends({
|
|
2337
|
+
ghostty: true,
|
|
2338
|
+
initGhosttyWasm: false
|
|
2339
|
+
});
|
|
2340
|
+
/**
|
|
2341
|
+
* Live-termless tracker. Each `createTermless()` registers a WeakRef to the
|
|
2342
|
+
* returned Term; when the Term is disposed (Symbol.dispose → emulator.close
|
|
2343
|
+
* → backend.destroy → xterm Terminal.dispose), the GC eventually clears the
|
|
2344
|
+
* ref. A large number of live un-disposed terminals typically means a test
|
|
2345
|
+
* forgot to use `using term = createTermless(...)` — the xterm Terminal
|
|
2346
|
+
* (1000-line scrollback buffer) stays resident until the worker exits.
|
|
2347
|
+
*
|
|
2348
|
+
* See bead km-silvery.termless-memleak.
|
|
2349
|
+
*/
|
|
2350
|
+
const liveTermlessInstances = /* @__PURE__ */ new Set();
|
|
2351
|
+
/** Tunable guard threshold — print a warning when we exceed this many live Terms. */
|
|
2352
|
+
const TERMLESS_LEAK_WARN_THRESHOLD = 128;
|
|
2353
|
+
/** Warning ID for the shared warnOnce latch. */
|
|
2354
|
+
const TERMLESS_LEAK_WARNING_ID = "silvery/test:termless-leak";
|
|
2355
|
+
/**
|
|
2356
|
+
* Prune GC'd entries from the tracker and return the count of still-live
|
|
2357
|
+
* instances. Cheap O(n) scan; n is small in practice.
|
|
2358
|
+
*/
|
|
2359
|
+
function pruneLiveTermless() {
|
|
2360
|
+
let live = 0;
|
|
2361
|
+
for (const ref of liveTermlessInstances) if (ref.deref() === void 0) liveTermlessInstances.delete(ref);
|
|
2362
|
+
else live++;
|
|
2363
|
+
return live;
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Return the current count of live (un-GC'd) termless Term instances.
|
|
2367
|
+
* Exported for tests that want to assert cleanup happened.
|
|
2368
|
+
*/
|
|
2369
|
+
function getActiveTermlessCount() {
|
|
2370
|
+
return pruneLiveTermless();
|
|
2371
|
+
}
|
|
2372
|
+
function parseTermlessTrackpadFlickProfileFromDebugLog(logText, options = {}) {
|
|
2373
|
+
const events = [];
|
|
2374
|
+
for (const line of logText.split(/\r?\n/)) {
|
|
2375
|
+
if (!line.startsWith("{")) continue;
|
|
2376
|
+
let record;
|
|
2377
|
+
try {
|
|
2378
|
+
record = JSON.parse(line);
|
|
2379
|
+
} catch {
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
if (typeof record !== "object" || record === null) continue;
|
|
2383
|
+
const obj = record;
|
|
2384
|
+
if (obj.name !== "silvery:input-owner" || typeof obj.msg !== "string") continue;
|
|
2385
|
+
if (!obj.msg.startsWith("parsed mouse: action=wheel")) continue;
|
|
2386
|
+
const parsed = parseDebugWheelMessage(obj.msg, obj.time);
|
|
2387
|
+
if (parsed !== null) events.push(parsed);
|
|
2388
|
+
}
|
|
2389
|
+
if (events.length === 0) throw new Error("No silvery input-owner wheel events found in debug log");
|
|
2390
|
+
const first = events[0];
|
|
2391
|
+
const cellSize = options.cellSize ?? inferDebugWheelCellSize(first);
|
|
2392
|
+
const coordinateMode = options.coordinateMode ?? (cellSize === void 0 ? "cell" : "pixel");
|
|
2393
|
+
return {
|
|
2394
|
+
x: options.x ?? first.x,
|
|
2395
|
+
y: options.y ?? first.y,
|
|
2396
|
+
direction: first.delta < 0 ? "up" : "down",
|
|
2397
|
+
coordinateMode,
|
|
2398
|
+
...cellSize === void 0 ? {} : { cellSize },
|
|
2399
|
+
packets: eventsToTrackpadPackets(events)
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
function parseDebugWheelMessage(msg, time) {
|
|
2403
|
+
if (typeof time !== "string") return null;
|
|
2404
|
+
const timestamp = Date.parse(time);
|
|
2405
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
2406
|
+
const coords = msg.match(/\bx=([-0-9.]+)\s+y=([-0-9.]+)\s+delta=([-0-9.]+)/);
|
|
2407
|
+
if (coords === null) return null;
|
|
2408
|
+
const x = Number(coords[1]);
|
|
2409
|
+
const y = Number(coords[2]);
|
|
2410
|
+
const delta = Number(coords[3]);
|
|
2411
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(delta)) return null;
|
|
2412
|
+
const sgr = msg.match(/\\u001b\[<(\d+);(\d+);(\d+)[Mm]/) ?? msg.match(new RegExp(`${String.fromCharCode(27)}\\[<(\\d+);(\\d+);(\\d+)[Mm]`));
|
|
2413
|
+
return {
|
|
2414
|
+
time: timestamp,
|
|
2415
|
+
x,
|
|
2416
|
+
y,
|
|
2417
|
+
delta,
|
|
2418
|
+
...sgr === null ? {} : {
|
|
2419
|
+
button: Number(sgr[1]),
|
|
2420
|
+
sgrX: Number(sgr[2]),
|
|
2421
|
+
sgrY: Number(sgr[3])
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
function inferDebugWheelCellSize(event) {
|
|
2426
|
+
if (event.sgrX === void 0 || event.sgrY === void 0 || event.x <= 0 || event.y <= 0) return;
|
|
2427
|
+
const width = (event.sgrX - 1) / event.x;
|
|
2428
|
+
const height = (event.sgrY - 1) / event.y;
|
|
2429
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return void 0;
|
|
2430
|
+
if (width <= 1 || height <= 1) return void 0;
|
|
2431
|
+
return {
|
|
2432
|
+
width: Math.round(width * 1e3) / 1e3,
|
|
2433
|
+
height: Math.round(height * 1e3) / 1e3
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
function eventsToTrackpadPackets(events) {
|
|
2437
|
+
const firstTime = events[0].time;
|
|
2438
|
+
const packets = [];
|
|
2439
|
+
for (const event of events) {
|
|
2440
|
+
const atMs = Math.max(0, Math.round(event.time - firstTime));
|
|
2441
|
+
const last = packets[packets.length - 1];
|
|
2442
|
+
if (last !== void 0 && last.atMs === atMs && last.delta === event.delta && last.button === event.button && last.x === void 0 && last.y === void 0) {
|
|
2443
|
+
last.count = (last.count ?? 1) + 1;
|
|
2444
|
+
continue;
|
|
2445
|
+
}
|
|
2446
|
+
packets.push({
|
|
2447
|
+
atMs,
|
|
2448
|
+
count: 1,
|
|
2449
|
+
delta: event.delta,
|
|
2450
|
+
...event.button === void 0 ? {} : { button: event.button }
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
return packets;
|
|
2454
|
+
}
|
|
2455
|
+
/** Default sleep between steps in `drag()`. Short enough to be fast, long
|
|
2456
|
+
* enough for async event dispatch to flush. */
|
|
2457
|
+
const DEFAULT_DRAG_STEP_DELAY_MS = 20;
|
|
2458
|
+
const DEFAULT_WHEEL_CADENCE_MS = 8;
|
|
2459
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2460
|
+
const DEFAULT_REFLOW_RESIDUE_MARKER = "CMUX-REFLOW-RESIDUE";
|
|
2461
|
+
function strictTerminalRequestsGhostty(value) {
|
|
2462
|
+
const normalized = value?.toLowerCase().trim();
|
|
2463
|
+
if (!normalized) return false;
|
|
2464
|
+
if (normalized === "all" || normalized === "both") return true;
|
|
2465
|
+
return normalized.split(",").map((part) => part.trim()).includes("ghostty");
|
|
2466
|
+
}
|
|
2467
|
+
function inferBackendFromStrictTerminal() {
|
|
2468
|
+
if (typeof process === "undefined") return void 0;
|
|
2469
|
+
return strictTerminalRequestsGhostty(process.env.SILVERY_STRICT_TERMINAL) ? "ghostty" : void 0;
|
|
2470
|
+
}
|
|
2471
|
+
function withReflowResidue(backend, marker = DEFAULT_REFLOW_RESIDUE_MARKER) {
|
|
2472
|
+
let armed = false;
|
|
2473
|
+
const encoder = new TextEncoder();
|
|
2474
|
+
const decoder = new TextDecoder();
|
|
2475
|
+
const residue = {
|
|
2476
|
+
marker,
|
|
2477
|
+
arm() {
|
|
2478
|
+
armed = true;
|
|
2479
|
+
},
|
|
2480
|
+
clear() {
|
|
2481
|
+
armed = false;
|
|
2482
|
+
},
|
|
2483
|
+
get armed() {
|
|
2484
|
+
return armed;
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
const wrapped = Object.create(backend);
|
|
2488
|
+
Object.defineProperties(wrapped, {
|
|
2489
|
+
name: { get() {
|
|
2490
|
+
return `${backend.name}+reflow-residue`;
|
|
2491
|
+
} },
|
|
2492
|
+
init: { value(opts) {
|
|
2493
|
+
backend.init(opts);
|
|
2494
|
+
} },
|
|
2495
|
+
destroy: { value() {
|
|
2496
|
+
backend.destroy();
|
|
2497
|
+
} },
|
|
2498
|
+
feed: { value(data) {
|
|
2499
|
+
const text = decoder.decode(data);
|
|
2500
|
+
const defeatsResidue = text.includes("\x1B[2J") || text.includes("\x1B[H");
|
|
2501
|
+
if (defeatsResidue) armed = false;
|
|
2502
|
+
backend.feed(data);
|
|
2503
|
+
if (armed && !defeatsResidue) backend.feed(encoder.encode(`\x1b[H${marker}`));
|
|
2504
|
+
} },
|
|
2505
|
+
resize: { value(cols, rows) {
|
|
2506
|
+
backend.resize(cols, rows);
|
|
2507
|
+
armed = true;
|
|
2508
|
+
} }
|
|
2509
|
+
});
|
|
2510
|
+
return {
|
|
2511
|
+
backend: wrapped,
|
|
2512
|
+
residue
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
function applyMouseModifierBits(btn, options) {
|
|
2516
|
+
if (options?.shift) btn += 4;
|
|
2517
|
+
if (options?.alt || options?.meta) btn += 8;
|
|
2518
|
+
if (options?.ctrl) btn += 16;
|
|
2519
|
+
return btn;
|
|
2520
|
+
}
|
|
2521
|
+
function sgrButtonByte(options) {
|
|
2522
|
+
return applyMouseModifierBits(options?.button ?? 0, options);
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Create a Term backed by a termless xterm.js emulator for full ANSI testing.
|
|
2526
|
+
*
|
|
2527
|
+
* Convenience wrapper around `createTerm(createXtermBackend(), dims)` that
|
|
2528
|
+
* handles the xterm.js backend import. Use with `run()` to render components
|
|
2529
|
+
* into a real terminal emulator in-process — no PTY, no timing issues.
|
|
2530
|
+
*
|
|
2531
|
+
* The returned Term exposes two testing conveniences:
|
|
2532
|
+
* - `term.mouse.*` — ergonomic mouse API (click, drag, down/up/move, wheel)
|
|
2533
|
+
* - `term.clipboard` — OSC 52 capture (`term.clipboard.last`, `.all`)
|
|
2534
|
+
*
|
|
2535
|
+
* **Always dispose with `using` or an explicit `term[Symbol.dispose]()`.**
|
|
2536
|
+
* Without disposal, each call leaks the xterm.js Terminal (~1 MB scrollback
|
|
2537
|
+
* per instance). This accumulated to 18-28 GB per vitest worker in CI before
|
|
2538
|
+
* `km-silvery.termless-memleak` was fixed.
|
|
2539
|
+
*
|
|
2540
|
+
* @example Mouse drag + clipboard capture
|
|
2541
|
+
* ```tsx
|
|
2542
|
+
* using term = createTermless({ cols: 40, rows: 10 })
|
|
2543
|
+
* const handle = await run(<App />, term, { selection: true, mouse: true })
|
|
2544
|
+
*
|
|
2545
|
+
* await term.mouse.drag({ from: [5, 2], to: [20, 2] })
|
|
2546
|
+
* expect(term.clipboard.last).toContain("selected text")
|
|
2547
|
+
* ```
|
|
2548
|
+
*
|
|
2549
|
+
* @example Plain click
|
|
2550
|
+
* ```tsx
|
|
2551
|
+
* await term.mouse.click(10, 5)
|
|
2552
|
+
* await term.mouse.click(10, 5, { shift: true })
|
|
2553
|
+
* ```
|
|
2554
|
+
*
|
|
2555
|
+
* @example Override capabilities (defaults-contract tests)
|
|
2556
|
+
* ```tsx
|
|
2557
|
+
* using term = createTermless({ cols: 80, rows: 24, caps: { colorLevel: '256' } })
|
|
2558
|
+
* ```
|
|
2559
|
+
*
|
|
2560
|
+
* Without a `caps` override, `term.caps` is populated from `defaultCaps()`
|
|
2561
|
+
* (truecolor / unicode / mouse). Callers that want a specific terminal
|
|
2562
|
+
* profile for a test — Apple_Terminal's lack of `maybeWideEmojis`, Kitty's
|
|
2563
|
+
* `kittyKeyboard`, etc. — can pass a `Partial<TerminalCaps>` here.
|
|
2564
|
+
*/
|
|
2565
|
+
function createTermless(dims = {
|
|
2566
|
+
cols: 80,
|
|
2567
|
+
rows: 24
|
|
2568
|
+
}) {
|
|
2569
|
+
const envBackend = typeof process !== "undefined" ? process.env.TERMLESS_BACKEND : void 0;
|
|
2570
|
+
const choice = dims.backend ?? envBackend ?? inferBackendFromStrictTerminal() ?? "xterm";
|
|
2571
|
+
let backend;
|
|
2572
|
+
if (choice === "ghostty") backend = createGhosttyBackend();
|
|
2573
|
+
else if (choice === "ghostty-native") backend = createGhosttyNativeBackend();
|
|
2574
|
+
else if (choice === "xterm") backend = createXtermBackend();
|
|
2575
|
+
else throw new Error(`[silvery/test] createTermless: unknown backend "${choice}". Valid choices: "xterm", "ghostty", "ghostty-native". For a custom backend, call createTerm(backend, dims) directly.`);
|
|
2576
|
+
let reflowResidue;
|
|
2577
|
+
if (dims.reflowResidue) {
|
|
2578
|
+
const marker = typeof dims.reflowResidue === "object" ? dims.reflowResidue.marker : void 0;
|
|
2579
|
+
const wrapped = withReflowResidue(backend, marker);
|
|
2580
|
+
backend = wrapped.backend;
|
|
2581
|
+
reflowResidue = wrapped.residue;
|
|
2582
|
+
}
|
|
2583
|
+
const term = createTerm(backend, dims);
|
|
2584
|
+
liveTermlessInstances.add(new WeakRef(term));
|
|
2585
|
+
const live = pruneLiveTermless();
|
|
2586
|
+
if (live >= TERMLESS_LEAK_WARN_THRESHOLD) warnOnce(TERMLESS_LEAK_WARNING_ID, () => {
|
|
2587
|
+
console.warn(`[silvery/test] ${live} live termless Term instances detected — likely a test forgot to use \`using term = createTermless(...)\`. Each un-disposed Term retains an xterm.js Terminal with ~1 MB scrollback. See bead km-silvery.termless-memleak.`);
|
|
2588
|
+
});
|
|
2589
|
+
const sendInput = (data) => {
|
|
2590
|
+
term.sendInput(data);
|
|
2591
|
+
};
|
|
2592
|
+
const PIXEL_CELL_W = 8;
|
|
2593
|
+
const PIXEL_CELL_H = 17;
|
|
2594
|
+
const SGR_PIXELS_ENABLE = "\x1B[?1016h";
|
|
2595
|
+
let cachedPixelMode = null;
|
|
2596
|
+
const out = term.out;
|
|
2597
|
+
function isPixelMode() {
|
|
2598
|
+
if (cachedPixelMode !== null) return cachedPixelMode;
|
|
2599
|
+
if (!out) return false;
|
|
2600
|
+
if (out.getText().includes(SGR_PIXELS_ENABLE)) {
|
|
2601
|
+
cachedPixelMode = true;
|
|
2602
|
+
return true;
|
|
2603
|
+
}
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
function encodeCoord(cellX, cellY, options) {
|
|
2607
|
+
const coordinateMode = options?.coordinateMode ?? "auto";
|
|
2608
|
+
if (coordinateMode === "pixel" || coordinateMode === "auto" && isPixelMode()) {
|
|
2609
|
+
const cellSize = options?.cellSize ?? {
|
|
2610
|
+
width: PIXEL_CELL_W,
|
|
2611
|
+
height: PIXEL_CELL_H
|
|
2612
|
+
};
|
|
2613
|
+
const pixelX = cellX * cellSize.width;
|
|
2614
|
+
const pixelY = cellY * cellSize.height;
|
|
2615
|
+
return {
|
|
2616
|
+
sgrX: Math.round(pixelX) + 1,
|
|
2617
|
+
sgrY: Math.round(pixelY) + 1
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
return {
|
|
2621
|
+
sgrX: cellX + 1,
|
|
2622
|
+
sgrY: cellY + 1
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
function packetEventCount(packet) {
|
|
2626
|
+
const count = packet.count ?? packet.events ?? Math.abs(packet.delta ?? 1);
|
|
2627
|
+
return Math.max(0, Math.floor(count));
|
|
2628
|
+
}
|
|
2629
|
+
function packetDelta(packet, profile) {
|
|
2630
|
+
if (packet.delta !== void 0) return packet.delta;
|
|
2631
|
+
return (packet.direction ?? profile.direction ?? "up") === "up" ? -1 : 1;
|
|
2632
|
+
}
|
|
2633
|
+
function packetButton(packet, profile) {
|
|
2634
|
+
if (packet.button !== void 0) return packet.button;
|
|
2635
|
+
return packetDelta(packet, profile) < 0 ? 64 : 65;
|
|
2636
|
+
}
|
|
2637
|
+
function groupTrackpadPackets(packets) {
|
|
2638
|
+
const sorted = packets.map((packet, order) => ({
|
|
2639
|
+
packet,
|
|
2640
|
+
order
|
|
2641
|
+
})).sort((a, b) => a.packet.atMs - b.packet.atMs || a.order - b.order);
|
|
2642
|
+
const groups = [];
|
|
2643
|
+
for (const { packet } of sorted) {
|
|
2644
|
+
const atMs = Math.max(0, Math.round(packet.atMs));
|
|
2645
|
+
const last = groups[groups.length - 1];
|
|
2646
|
+
if (last && last.atMs === atMs) last.packets.push(packet);
|
|
2647
|
+
else groups.push({
|
|
2648
|
+
atMs,
|
|
2649
|
+
packets: [packet]
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
return groups;
|
|
2653
|
+
}
|
|
2654
|
+
const mouse = {
|
|
2655
|
+
async down(x, y, options) {
|
|
2656
|
+
const btn = sgrButtonByte(options);
|
|
2657
|
+
const { sgrX, sgrY } = encodeCoord(x, y);
|
|
2658
|
+
sendInput(`\x1b[<${btn};${sgrX};${sgrY}M`);
|
|
2659
|
+
await Promise.resolve();
|
|
2660
|
+
},
|
|
2661
|
+
async up(x, y, options) {
|
|
2662
|
+
const btn = sgrButtonByte(options);
|
|
2663
|
+
const { sgrX, sgrY } = encodeCoord(x, y);
|
|
2664
|
+
sendInput(`\x1b[<${btn};${sgrX};${sgrY}m`);
|
|
2665
|
+
await Promise.resolve();
|
|
2666
|
+
},
|
|
2667
|
+
async move(x, y, options) {
|
|
2668
|
+
const btn = 32 + sgrButtonByte(options);
|
|
2669
|
+
const { sgrX, sgrY } = encodeCoord(x, y);
|
|
2670
|
+
sendInput(`\x1b[<${btn};${sgrX};${sgrY}M`);
|
|
2671
|
+
await Promise.resolve();
|
|
2672
|
+
},
|
|
2673
|
+
async click(x, y, options) {
|
|
2674
|
+
await mouse.down(x, y, options);
|
|
2675
|
+
await mouse.up(x, y, options);
|
|
2676
|
+
},
|
|
2677
|
+
async dblclick(x, y, options) {
|
|
2678
|
+
await mouse.click(x, y, options);
|
|
2679
|
+
await mouse.click(x, y, options);
|
|
2680
|
+
},
|
|
2681
|
+
async drag({ from, to, via, options, stepDelay = DEFAULT_DRAG_STEP_DELAY_MS }) {
|
|
2682
|
+
await mouse.down(from[0], from[1], options);
|
|
2683
|
+
await sleep(stepDelay);
|
|
2684
|
+
if (via) for (const [x, y] of via) {
|
|
2685
|
+
await mouse.move(x, y, options);
|
|
2686
|
+
await sleep(stepDelay);
|
|
2687
|
+
}
|
|
2688
|
+
await mouse.move(to[0], to[1], options);
|
|
2689
|
+
await sleep(stepDelay);
|
|
2690
|
+
await mouse.up(to[0], to[1], options);
|
|
2691
|
+
},
|
|
2692
|
+
async wheel(x, y, deltaOrOptions, maybeOptions) {
|
|
2693
|
+
const options = typeof deltaOrOptions === "number" ? maybeOptions : deltaOrOptions;
|
|
2694
|
+
const deltaX = typeof deltaOrOptions === "number" ? options?.deltaX ?? 0 : deltaOrOptions.deltaX ?? 0;
|
|
2695
|
+
const deltaY = typeof deltaOrOptions === "number" ? deltaOrOptions : deltaOrOptions.deltaY ?? deltaOrOptions.lines ?? 0;
|
|
2696
|
+
const { sgrX, sgrY } = encodeCoord(x, y, {
|
|
2697
|
+
coordinateMode: options?.coordinateMode,
|
|
2698
|
+
cellSize: options?.cellSize
|
|
2699
|
+
});
|
|
2700
|
+
const reports = [];
|
|
2701
|
+
const appendReports = (baseButton, count) => {
|
|
2702
|
+
const button = applyMouseModifierBits(baseButton, options);
|
|
2703
|
+
for (let i = 0; i < count; i++) reports.push(`\x1b[<${button};${sgrX};${sgrY}M`);
|
|
2704
|
+
};
|
|
2705
|
+
const horizontalCount = Math.max(0, Math.floor(Math.abs(deltaX)));
|
|
2706
|
+
if (horizontalCount > 0) appendReports(deltaX < 0 ? 66 : 67, horizontalCount);
|
|
2707
|
+
const verticalCount = Math.max(0, Math.floor(Math.abs(deltaY)));
|
|
2708
|
+
if (verticalCount > 0) appendReports(deltaY < 0 ? 64 : 65, verticalCount);
|
|
2709
|
+
const cadenceMs = options?.cadenceMs ?? DEFAULT_WHEEL_CADENCE_MS;
|
|
2710
|
+
for (let index = 0; index < reports.length; index++) {
|
|
2711
|
+
sendInput(reports[index]);
|
|
2712
|
+
await Promise.resolve();
|
|
2713
|
+
if (cadenceMs > 0 && index < reports.length - 1) await sleep(cadenceMs);
|
|
2714
|
+
}
|
|
2715
|
+
},
|
|
2716
|
+
async trackpadFlick(profile, options) {
|
|
2717
|
+
const groups = groupTrackpadPackets(profile.packets);
|
|
2718
|
+
const deliveredGroups = [];
|
|
2719
|
+
let lastAtMs = 0;
|
|
2720
|
+
let eventCount = 0;
|
|
2721
|
+
for (let index = 0; index < groups.length; index++) {
|
|
2722
|
+
const group = groups[index];
|
|
2723
|
+
const waitMs = Math.max(0, group.atMs - lastAtMs);
|
|
2724
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
2725
|
+
lastAtMs = group.atMs;
|
|
2726
|
+
let chunk = "";
|
|
2727
|
+
let deltaSum = 0;
|
|
2728
|
+
let groupEventCount = 0;
|
|
2729
|
+
for (const packet of group.packets) {
|
|
2730
|
+
const count = packetEventCount(packet);
|
|
2731
|
+
if (count === 0) continue;
|
|
2732
|
+
const delta = packetDelta(packet, profile);
|
|
2733
|
+
const button = packetButton(packet, profile);
|
|
2734
|
+
const { sgrX, sgrY } = encodeCoord(packet.x ?? profile.x, packet.y ?? profile.y, {
|
|
2735
|
+
coordinateMode: profile.coordinateMode,
|
|
2736
|
+
cellSize: profile.cellSize
|
|
2737
|
+
});
|
|
2738
|
+
chunk += Array.from({ length: count }, () => `\x1b[<${button};${sgrX};${sgrY}M`).join("");
|
|
2739
|
+
deltaSum += delta * count;
|
|
2740
|
+
groupEventCount += count;
|
|
2741
|
+
}
|
|
2742
|
+
if (chunk.length === 0) continue;
|
|
2743
|
+
sendInput(chunk);
|
|
2744
|
+
eventCount += groupEventCount;
|
|
2745
|
+
await Promise.resolve();
|
|
2746
|
+
const deliveredGroup = {
|
|
2747
|
+
index,
|
|
2748
|
+
atMs: group.atMs,
|
|
2749
|
+
packetCount: group.packets.length,
|
|
2750
|
+
eventCount,
|
|
2751
|
+
deltaSum
|
|
2752
|
+
};
|
|
2753
|
+
deliveredGroups.push(deliveredGroup);
|
|
2754
|
+
await options?.afterGroup?.(deliveredGroup);
|
|
2755
|
+
}
|
|
2756
|
+
if (profile.settleMs !== void 0 && profile.settleMs > 0) await sleep(profile.settleMs);
|
|
2757
|
+
return {
|
|
2758
|
+
eventCount,
|
|
2759
|
+
durationMs: lastAtMs,
|
|
2760
|
+
groups: deliveredGroups
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
const clipboardPayloads = [];
|
|
2765
|
+
const emulator = term._emulator;
|
|
2766
|
+
if (emulator) {
|
|
2767
|
+
const origFeed = emulator.feed.bind(emulator);
|
|
2768
|
+
const OSC52_RE = /* @__PURE__ */ new RegExp("\\x1b\\]52;c;([A-Za-z0-9+/=]*)\\x07", "g");
|
|
2769
|
+
emulator.feed = (data) => {
|
|
2770
|
+
OSC52_RE.lastIndex = 0;
|
|
2771
|
+
let match;
|
|
2772
|
+
while ((match = OSC52_RE.exec(data)) !== null) try {
|
|
2773
|
+
const decoded = globalThis.Buffer.from(match[1], "base64").toString("utf-8");
|
|
2774
|
+
clipboardPayloads.push(decoded);
|
|
2775
|
+
} catch {}
|
|
2776
|
+
origFeed(data);
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
const clipboard = {
|
|
2780
|
+
get last() {
|
|
2781
|
+
return clipboardPayloads.length > 0 ? clipboardPayloads[clipboardPayloads.length - 1] : null;
|
|
2782
|
+
},
|
|
2783
|
+
get all() {
|
|
2784
|
+
return clipboardPayloads;
|
|
2785
|
+
},
|
|
2786
|
+
clear() {
|
|
2787
|
+
clipboardPayloads.length = 0;
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
Object.defineProperty(term, "mouse", {
|
|
2791
|
+
value: mouse,
|
|
2792
|
+
enumerable: true
|
|
2793
|
+
});
|
|
2794
|
+
Object.defineProperty(term, "clipboard", {
|
|
2795
|
+
value: clipboard,
|
|
2796
|
+
enumerable: true
|
|
2797
|
+
});
|
|
2798
|
+
if (reflowResidue) Object.defineProperty(term, "reflowResidue", {
|
|
2799
|
+
value: reflowResidue,
|
|
2800
|
+
enumerable: true
|
|
2801
|
+
});
|
|
2802
|
+
return term;
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* Normalize frame output for comparison.
|
|
2806
|
+
* - Strips ANSI codes
|
|
2807
|
+
* - Trims trailing whitespace from lines
|
|
2808
|
+
* - Removes empty trailing lines
|
|
2809
|
+
*/
|
|
2810
|
+
function normalizeFrame(frame) {
|
|
2811
|
+
return stripAnsi(frame).split("\n").map((line) => line.trimEnd()).join("\n").trimEnd();
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Wait for a condition to be true, polling at intervals.
|
|
2815
|
+
* Useful for waiting for async state updates.
|
|
2816
|
+
*/
|
|
2817
|
+
async function waitFor(condition, { timeout = 1e3, interval = 10 } = {}) {
|
|
2818
|
+
const start = Date.now();
|
|
2819
|
+
while (!condition()) {
|
|
2820
|
+
if (Date.now() - start > timeout) throw new Error(`waitFor timed out after ${timeout}ms`);
|
|
2821
|
+
await new Promise((resolve) => {
|
|
2822
|
+
setTimeout(resolve, interval);
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
//#endregion
|
|
2827
|
+
export { CLS_STRICT_MIN_TIER, CLS_STRICT_SLUG, CODE_TO_KEY, UnexpectedLayoutShiftError, bufferToHTML, bufferToStyledText, bufferToText, compareBuffers, createAutoLocator, createRenderer, createStore, createTermless, debugTree, diagnosticScheme, diagnosticTheme, ensureEngine, formatMismatch, formatSelectableCells, getActiveRenderCount, getActiveTermlessCount, isClsStrictEnabled, keyToAnsi, keyToKittyAnsi, normalizeFrame, parseTermlessTrackpadFlickProfileFromDebugLog, readCellRow, readCellSnapshot, recordFrames, render, run, stripAnsi, waitFor };
|
|
2828
|
+
|
|
2829
|
+
//# sourceMappingURL=test.mjs.map
|