@citisen/litearea 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive the built library in a real browser and assert what only a browser can.
|
|
3
|
+
*
|
|
4
|
+
* The unit tests answer everything that is a decision, because the engine is pure
|
|
5
|
+
* and a DOM would only hide that. Four things here are not decisions, though, and no
|
|
6
|
+
* stub can judge them:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Undo.** A textarea's undo stack is the browser's, kept by its editing
|
|
9
|
+
* pipeline. There is no API to read it and no way to fake it, so the only honest
|
|
10
|
+
* test is to make an edit and then undo it in a browser that has a real one. This
|
|
11
|
+
* check also runs a CONTROL first — a direct `value` assignment, which must NOT be
|
|
12
|
+
* undoable — because without it a passing test would only show that
|
|
13
|
+
* `execCommand('undo')` returned true.
|
|
14
|
+
* 2. **The caret.** Where a caret ends up after an edit is layout, and layout is the
|
|
15
|
+
* browser's.
|
|
16
|
+
* 3. **Alignment.** The painted layer and the transparent textarea are two elements
|
|
17
|
+
* and one typography. Whether they actually agree is a question about font
|
|
18
|
+
* metrics, wrapping, and scrollbar widths, and it can only be asked of a renderer
|
|
19
|
+
* that has them.
|
|
20
|
+
* 4. **Auto-sizing.** Whether a box has a scrollbar is `scrollHeight` against
|
|
21
|
+
* `clientHeight`, measured after a real layout.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* node scripts/browser-check.mjs [path/to/chrome]
|
|
25
|
+
*
|
|
26
|
+
* Environment:
|
|
27
|
+
* LITEAREA_CHROME browser executable, when it is not found automatically
|
|
28
|
+
* LITEAREA_REQUIRE set to 1 to turn "no browser here" into a failure
|
|
29
|
+
*
|
|
30
|
+
* The check SKIPS (exit 0) when no Chromium-based browser is present, because a
|
|
31
|
+
* clean CI runner may have none and a release must not fail for that.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { spawnSync } from 'node:child_process'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
37
|
+
import { dirname, join } from 'node:path'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
|
|
40
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
41
|
+
|
|
42
|
+
/** Give up (or fail, under LITEAREA_REQUIRE) because no browser is usable here. */
|
|
43
|
+
function skip(reason) {
|
|
44
|
+
console.log(`browser-check: SKIP — ${reason}`)
|
|
45
|
+
if (process.env.LITEAREA_REQUIRE === '1') {
|
|
46
|
+
console.error('browser-check: LITEAREA_REQUIRE=1, treating the skip as a failure')
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find a Chromium-based browser.
|
|
54
|
+
*
|
|
55
|
+
* Only Chromium matters: the undo stack this check exists for, the caret APIs, and
|
|
56
|
+
* the scrollbar behaviour are all its behaviour. A Firefox build would answer
|
|
57
|
+
* different questions.
|
|
58
|
+
* @returns the executable path, or undefined.
|
|
59
|
+
*/
|
|
60
|
+
function findBrowser() {
|
|
61
|
+
const explicit = process.argv[2] ?? process.env.LITEAREA_CHROME
|
|
62
|
+
if (explicit !== undefined) return existsSync(explicit) ? explicit : undefined
|
|
63
|
+
const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
|
|
64
|
+
const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
65
|
+
const localAppData = process.env.LOCALAPPDATA ?? ''
|
|
66
|
+
const candidates = [
|
|
67
|
+
join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
68
|
+
join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
69
|
+
join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
70
|
+
join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
71
|
+
join(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
72
|
+
'/usr/bin/google-chrome',
|
|
73
|
+
'/usr/bin/chromium',
|
|
74
|
+
'/usr/bin/chromium-browser',
|
|
75
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
76
|
+
]
|
|
77
|
+
return candidates.find((candidate) => existsSync(candidate))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const browser = findBrowser()
|
|
81
|
+
if (browser === undefined) {
|
|
82
|
+
skip('no Chromium-based browser found (pass one as an argument, or set LITEAREA_CHROME)')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Bundle the built library into one classic script.
|
|
87
|
+
*
|
|
88
|
+
* The page is opened over `file://`, and Chromium refuses to fetch an ES module from
|
|
89
|
+
* a file URL — so the library is bundled to an IIFE and inlined. The entry is built
|
|
90
|
+
* from the SHIPPED files rather than from `src/`, so what this check drives is the
|
|
91
|
+
* artifact a host would actually install.
|
|
92
|
+
* @returns the script source.
|
|
93
|
+
*/
|
|
94
|
+
async function bundle() {
|
|
95
|
+
const index = join(root, 'dist', 'index.js')
|
|
96
|
+
const grammars = join(root, 'dist', 'grammars.js')
|
|
97
|
+
if (!existsSync(index) || !existsSync(grammars)) {
|
|
98
|
+
console.error('browser-check: dist/ is missing; run `npm run build` first')
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}
|
|
101
|
+
const { build } = await import('esbuild')
|
|
102
|
+
const result = await build({
|
|
103
|
+
stdin: {
|
|
104
|
+
contents: `export * from ${JSON.stringify(index)}\nexport * from ${JSON.stringify(grammars)}\n`,
|
|
105
|
+
resolveDir: root,
|
|
106
|
+
sourcefile: 'litearea-browser-entry.js',
|
|
107
|
+
loader: 'js',
|
|
108
|
+
},
|
|
109
|
+
bundle: true,
|
|
110
|
+
format: 'iife',
|
|
111
|
+
globalName: 'litearea',
|
|
112
|
+
target: 'es2022',
|
|
113
|
+
platform: 'browser',
|
|
114
|
+
write: false,
|
|
115
|
+
logLevel: 'silent',
|
|
116
|
+
})
|
|
117
|
+
return result.outputFiles[0].text
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The in-page checklist.
|
|
122
|
+
*
|
|
123
|
+
* Written against the public API and the public DOM only. It is a template literal
|
|
124
|
+
* so the harness page reads as one document, which is why it contains no backticks
|
|
125
|
+
* and no `${` of its own.
|
|
126
|
+
*/
|
|
127
|
+
const CHECKLIST = String.raw`
|
|
128
|
+
const problems = []
|
|
129
|
+
const notes = []
|
|
130
|
+
const check = (condition, message) => {
|
|
131
|
+
if (!condition) problems.push(message)
|
|
132
|
+
}
|
|
133
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
134
|
+
// Named api rather than litearea, because the inlined bundle declares a global
|
|
135
|
+
// of that name and a second top-level binding for it is a syntax error in the
|
|
136
|
+
// whole page.
|
|
137
|
+
const api = window.litearea
|
|
138
|
+
|
|
139
|
+
const host = document.createElement('div')
|
|
140
|
+
host.style.width = '420px'
|
|
141
|
+
host.style.margin = '0'
|
|
142
|
+
document.body.appendChild(host)
|
|
143
|
+
|
|
144
|
+
const mount = (grammar, value, options) => {
|
|
145
|
+
const target = document.createElement('div')
|
|
146
|
+
host.appendChild(target)
|
|
147
|
+
return api.createEditor(target, Object.assign({ grammar: grammar, value: value }, options || {}))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const paintOf = (editor) => editor.element.querySelector('.litearea-paint')
|
|
151
|
+
const layerOf = (editor) => editor.element.querySelector('.litearea-layer')
|
|
152
|
+
const tipOf = (editor) => editor.element.querySelector('.litearea-tooltip')
|
|
153
|
+
const rowsOf = (editor) => editor.element.querySelectorAll('.litearea-row')
|
|
154
|
+
|
|
155
|
+
const press = (field, key) => {
|
|
156
|
+
field.dispatchEvent(new KeyboardEvent('keydown', { key: key, bubbles: true, cancelable: true }))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sentry = api.dshSentryStyleGrammar()
|
|
160
|
+
const font = api.dshFontQueryGrammar({
|
|
161
|
+
catalogue: ['Inter', 'IBM Plex Mono', 'Geist Mono', 'Fira Code'],
|
|
162
|
+
enumerated: true,
|
|
163
|
+
styles: { 'Geist Mono': ['Regular', 'Medium', 'Bold'] },
|
|
164
|
+
shippedWeight: 400,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
;(async () => {
|
|
168
|
+
check(typeof api.LiteArea === 'function', 'the bundle did not expose LiteArea')
|
|
169
|
+
check(typeof api.createEditor === 'function', 'the bundle did not expose createEditor')
|
|
170
|
+
|
|
171
|
+
// ── 1. the layer reproduces the document, and lines up with it ──────
|
|
172
|
+
const one = mount(sentry, 'running circle blue turn 3')
|
|
173
|
+
const paint = paintOf(one)
|
|
174
|
+
check(
|
|
175
|
+
paint.textContent === one.input.value,
|
|
176
|
+
'the layer must reproduce the document character for character (got ' +
|
|
177
|
+
JSON.stringify(paint.textContent) + ', wanted ' + JSON.stringify(one.input.value) + ')',
|
|
178
|
+
)
|
|
179
|
+
check(paint.children.length >= 4, 'the layer must be split into more than one span (got ' + paint.children.length + ')')
|
|
180
|
+
const layer = layerOf(one)
|
|
181
|
+
const drift = Math.abs(layer.scrollHeight - one.input.scrollHeight)
|
|
182
|
+
check(
|
|
183
|
+
drift <= 2,
|
|
184
|
+
'the layer and the field must lay out to the same height (layer ' + layer.scrollHeight +
|
|
185
|
+
' vs field ' + one.input.scrollHeight + ')',
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// ── 1b. a problem is DRAWN, not merely computed ────────────────────
|
|
189
|
+
// The ranges, the severity, and the class can all be right while nothing appears on
|
|
190
|
+
// screen: a stylesheet rule of higher specificity once set text-decoration on every
|
|
191
|
+
// painted span, which quietly switched off every squiggle in the library. Nothing in
|
|
192
|
+
// the DOM reveals that, so the only honest check is the browser's own computed style.
|
|
193
|
+
const marked = mount(sentry, 'running bogus blue')
|
|
194
|
+
const markedSpans = [...marked.element.querySelectorAll('.litearea-paint > span')]
|
|
195
|
+
const flaggedSpan = markedSpans.find((span) => span.className.indexOf('litearea-diag-error') >= 0)
|
|
196
|
+
check(flaggedSpan !== undefined, 'a rejected word must carry a severity class')
|
|
197
|
+
if (flaggedSpan !== undefined) {
|
|
198
|
+
const flagged = getComputedStyle(flaggedSpan)
|
|
199
|
+
check(
|
|
200
|
+
flagged.textDecorationLine === 'underline',
|
|
201
|
+
'an error must be underlined (text-decoration-line is ' + flagged.textDecorationLine + ')',
|
|
202
|
+
)
|
|
203
|
+
check(
|
|
204
|
+
flagged.textDecorationStyle === 'wavy',
|
|
205
|
+
'an error must be wavy (text-decoration-style is ' + flagged.textDecorationStyle + ')',
|
|
206
|
+
)
|
|
207
|
+
const clean = markedSpans.find((span) => span.className.indexOf('litearea-diag-') < 0)
|
|
208
|
+
check(clean !== undefined, 'the document must have a token with nothing wrong with it')
|
|
209
|
+
if (clean !== undefined) {
|
|
210
|
+
check(
|
|
211
|
+
getComputedStyle(clean).textDecorationLine === 'none',
|
|
212
|
+
'a clean token must carry no underline at all',
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── 1c. a custom font and a custom theme ───────────────────────────
|
|
218
|
+
// The font is the HOST'S choice: the layer, the field, and the measuring mirror all read
|
|
219
|
+
// the same computed font, so a proportional face aligns as well as a monospace one. That
|
|
220
|
+
// is the first thing a host asks about, so it is measured rather than asserted in prose.
|
|
221
|
+
// Georgia is chosen precisely because it is unlike the default in every metric.
|
|
222
|
+
const themed = mount(
|
|
223
|
+
sentry,
|
|
224
|
+
'running circle blue turn 3\nwaiting rounded amber blink 1.1',
|
|
225
|
+
{
|
|
226
|
+
variables: {
|
|
227
|
+
font: 'Georgia, Times New Roman, serif',
|
|
228
|
+
'font-size': '15px',
|
|
229
|
+
'line-height': '26px',
|
|
230
|
+
'scope-state': '#b91c1c',
|
|
231
|
+
accent: '#c2410c',
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
check(
|
|
236
|
+
getComputedStyle(themed.input).fontFamily.indexOf('Georgia') >= 0,
|
|
237
|
+
'the font variable must reach the field (got ' + getComputedStyle(themed.input).fontFamily + ')',
|
|
238
|
+
)
|
|
239
|
+
check(
|
|
240
|
+
getComputedStyle(themed.input).fontSize === '15px',
|
|
241
|
+
'the size variable must reach the field (got ' + getComputedStyle(themed.input).fontSize + ')',
|
|
242
|
+
)
|
|
243
|
+
check(
|
|
244
|
+
getComputedStyle(themed.input).lineHeight === '26px',
|
|
245
|
+
'the line-height variable must reach the field (got ' + getComputedStyle(themed.input).lineHeight + ')',
|
|
246
|
+
)
|
|
247
|
+
check(
|
|
248
|
+
getComputedStyle(themed.input).fontVariantLigatures === 'none',
|
|
249
|
+
'ligatures must stay off: the layer splits spans, and a split ligature would be one glyph in the field and two in the paint',
|
|
250
|
+
)
|
|
251
|
+
check(
|
|
252
|
+
paintOf(themed).textContent === themed.input.value,
|
|
253
|
+
'a themed layer must still reproduce the document',
|
|
254
|
+
)
|
|
255
|
+
const themedState = [...themed.element.querySelectorAll('.litearea-paint > span')]
|
|
256
|
+
.find((span) => span.className.indexOf('litearea-scope-state') >= 0)
|
|
257
|
+
check(themedState !== undefined, 'the themed document must have a state token')
|
|
258
|
+
if (themedState !== undefined) {
|
|
259
|
+
check(
|
|
260
|
+
getComputedStyle(themedState).color === 'rgb(185, 28, 28)',
|
|
261
|
+
'a custom scope colour must reach the paint (got ' + getComputedStyle(themedState).color + ')',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
const themedDrift = Math.abs(layerOf(themed).scrollHeight - themed.input.scrollHeight)
|
|
265
|
+
check(
|
|
266
|
+
themedDrift <= 2,
|
|
267
|
+
'A PROPORTIONAL CUSTOM FONT MUST STILL ALIGN (the layer and the field differ by ' +
|
|
268
|
+
themedDrift + 'px)',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
// ── 2. a box that fits never scrolls; more lines grow it; fewer shrink it ──
|
|
272
|
+
check(
|
|
273
|
+
getComputedStyle(one.input).overflowY === 'hidden',
|
|
274
|
+
'a field whose content fits must not scroll (overflow-y is ' +
|
|
275
|
+
getComputedStyle(one.input).overflowY + ')',
|
|
276
|
+
)
|
|
277
|
+
check(
|
|
278
|
+
one.input.scrollHeight <= one.input.clientHeight + 1,
|
|
279
|
+
'a field whose content fits must have no overflow (' + one.input.scrollHeight +
|
|
280
|
+
' > ' + one.input.clientHeight + ')',
|
|
281
|
+
)
|
|
282
|
+
const oneLine = one.input.offsetHeight
|
|
283
|
+
one.setValue('running circle blue turn 3\nwaiting rounded amber blink 1.1\ndone circle green flush 1.6', true)
|
|
284
|
+
const threeLines = one.input.offsetHeight
|
|
285
|
+
check(threeLines > oneLine, 'more lines must grow the box (was ' + oneLine + ', now ' + threeLines + ')')
|
|
286
|
+
one.setValue('running circle blue turn 3', true)
|
|
287
|
+
const backToOne = one.input.offsetHeight
|
|
288
|
+
check(
|
|
289
|
+
Math.abs(backToOne - oneLine) <= 1,
|
|
290
|
+
'deleting the lines must shrink the box back (was ' + oneLine + ', now ' + backToOne + ')',
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
// ── 3. a maximum height clamps the box and introduces a scrollbar ───
|
|
294
|
+
const clamped = mount(
|
|
295
|
+
sentry,
|
|
296
|
+
'running circle blue turn 3\nwaiting rounded amber blink 1.1\ndone circle green flush 1.6\napproval rounded amber blink 1.9',
|
|
297
|
+
{ sizing: { maxRows: 2 } },
|
|
298
|
+
)
|
|
299
|
+
check(
|
|
300
|
+
getComputedStyle(clamped.input).overflowY === 'auto',
|
|
301
|
+
'a field clamped to its maximum height must scroll (overflow-y is ' +
|
|
302
|
+
getComputedStyle(clamped.input).overflowY + ')',
|
|
303
|
+
)
|
|
304
|
+
check(
|
|
305
|
+
clamped.input.scrollHeight > clamped.input.clientHeight,
|
|
306
|
+
'a clamped field must actually have overflow to scroll (' + clamped.input.scrollHeight +
|
|
307
|
+
' vs ' + clamped.input.clientHeight + ')',
|
|
308
|
+
)
|
|
309
|
+
// The scrollbar narrows the text, so the layer has to widen its own padding or
|
|
310
|
+
// the two wrap differently and every colour slides off its character.
|
|
311
|
+
const clampedDrift = Math.abs(layerOf(clamped).scrollHeight - clamped.input.scrollHeight)
|
|
312
|
+
check(
|
|
313
|
+
clampedDrift <= 2,
|
|
314
|
+
'a clamped field must still align with its layer (layer ' + layerOf(clamped).scrollHeight +
|
|
315
|
+
' vs field ' + clamped.input.scrollHeight + ')',
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// ── 4. the control: a direct assignment is NOT undoable ────────────
|
|
319
|
+
// Without this, a passing undo test below would only prove that
|
|
320
|
+
// execCommand('undo') returned true.
|
|
321
|
+
const control = document.createElement('textarea')
|
|
322
|
+
control.value = 'abc'
|
|
323
|
+
document.body.appendChild(control)
|
|
324
|
+
control.focus()
|
|
325
|
+
control.value = 'abcd'
|
|
326
|
+
document.execCommand('undo')
|
|
327
|
+
check(
|
|
328
|
+
control.value === 'abcd',
|
|
329
|
+
'CONTROL FAILED: assigning .value was undoable here, so the undo test below proves nothing (value is ' +
|
|
330
|
+
JSON.stringify(control.value) + ')',
|
|
331
|
+
)
|
|
332
|
+
control.remove()
|
|
333
|
+
notes.push('control: a direct value assignment is not undoable, as expected')
|
|
334
|
+
|
|
335
|
+
// ── 5. undo and redo survive a completion ──────────────────────────
|
|
336
|
+
const undoable = mount(sentry, '')
|
|
337
|
+
undoable.focus()
|
|
338
|
+
document.execCommand('insertText', false, 'runn')
|
|
339
|
+
check(
|
|
340
|
+
undoable.value === 'runn',
|
|
341
|
+
'typing through the editing pipeline must land (value is ' + JSON.stringify(undoable.value) + ')',
|
|
342
|
+
)
|
|
343
|
+
check(
|
|
344
|
+
undoable.currentCompletion !== undefined,
|
|
345
|
+
'typing a state prefix must open the completion list',
|
|
346
|
+
)
|
|
347
|
+
check(rowsOf(undoable).length > 0, 'the completion list must render rows')
|
|
348
|
+
const beforeCompletion = undoable.value
|
|
349
|
+
press(undoable.input, 'Enter')
|
|
350
|
+
check(
|
|
351
|
+
undoable.value.indexOf('running') === 0,
|
|
352
|
+
'Enter must accept the highlighted completion (value is ' + JSON.stringify(undoable.value) + ')',
|
|
353
|
+
)
|
|
354
|
+
check(
|
|
355
|
+
undoable.currentCompletion === undefined,
|
|
356
|
+
'accepting must close the list',
|
|
357
|
+
)
|
|
358
|
+
const afterCompletion = undoable.value
|
|
359
|
+
check(
|
|
360
|
+
paintOf(undoable).textContent === afterCompletion,
|
|
361
|
+
'the layer must be repainted to match after a completion',
|
|
362
|
+
)
|
|
363
|
+
const undone = document.execCommand('undo')
|
|
364
|
+
check(undone === true, 'the browser refused to undo')
|
|
365
|
+
check(
|
|
366
|
+
undoable.value === beforeCompletion,
|
|
367
|
+
'UNDO MUST RESTORE THE TEXT BEFORE THE COMPLETION (wanted ' +
|
|
368
|
+
JSON.stringify(beforeCompletion) + ', got ' + JSON.stringify(undoable.value) + ')',
|
|
369
|
+
)
|
|
370
|
+
document.execCommand('redo')
|
|
371
|
+
check(
|
|
372
|
+
undoable.value === afterCompletion,
|
|
373
|
+
'REDO MUST PUT THE COMPLETION BACK (wanted ' + JSON.stringify(afterCompletion) +
|
|
374
|
+
', got ' + JSON.stringify(undoable.value) + ')',
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
// ── 6. a list that opened on the first letter must cover the second ─
|
|
378
|
+
// The bug this guards, and it is worth spelling out because it is invisible to any
|
|
379
|
+
// test that types a word in one go: the list opens over the first letter, the user
|
|
380
|
+
// types a second, and a range held from the first keystroke replaces only that one
|
|
381
|
+
// letter — so accepting the suggestion produced the completion followed by the
|
|
382
|
+
// leftover character. Every character must be typed SEPARATELY here, so that the list
|
|
383
|
+
// is filtered once per keystroke exactly as a person would drive it.
|
|
384
|
+
const grown = mount(sentry, '')
|
|
385
|
+
grown.focus()
|
|
386
|
+
document.execCommand('insertText', false, 'r')
|
|
387
|
+
check(grown.currentCompletion !== undefined, 'the first letter must open the list')
|
|
388
|
+
document.execCommand('insertText', false, 'u')
|
|
389
|
+
check(grown.currentCompletion !== undefined, 'the list must stay open while typing')
|
|
390
|
+
const grownRange = grown.currentCompletion && grown.currentCompletion.range
|
|
391
|
+
check(
|
|
392
|
+
grownRange !== undefined && grownRange.to === 2,
|
|
393
|
+
'the range must grow with the typed word (got ' + JSON.stringify(grownRange) + ')',
|
|
394
|
+
)
|
|
395
|
+
press(grown.input, 'Enter')
|
|
396
|
+
check(
|
|
397
|
+
grown.value === 'running ',
|
|
398
|
+
'accepting after two letters must replace BOTH of them (got ' + JSON.stringify(grown.value) + ')',
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
// The same thing with a longer word and a middle-of-document caret, because the
|
|
402
|
+
// off-by-one is easiest to miss when the range is not anchored at zero.
|
|
403
|
+
const midword = mount(sentry, 'running circle blue turn 3\nwaiting rounded amber blink 1.1')
|
|
404
|
+
midword.focus()
|
|
405
|
+
const lineStart = midword.input.value.indexOf('waiting')
|
|
406
|
+
midword.setSelection(lineStart + 1)
|
|
407
|
+
document.execCommand('insertText', false, 'ai')
|
|
408
|
+
press(midword.input, 'Enter')
|
|
409
|
+
check(
|
|
410
|
+
midword.input.value.split('\n')[1] === 'waiting rounded amber blink 1.1',
|
|
411
|
+
'completing a word in the middle of a document must leave no debris (line is ' +
|
|
412
|
+
JSON.stringify(midword.input.value.split('\n')[1]) + ')',
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
// ── 7. a repaint does not move the caret ───────────────────────────
|
|
416
|
+
const careful = mount(sentry, 'running circle blue turn 3\nwaiting rounded amber blink 1.1')
|
|
417
|
+
careful.focus()
|
|
418
|
+
const at = careful.input.value.indexOf('rounded')
|
|
419
|
+
careful.setSelection(at)
|
|
420
|
+
document.execCommand('insertText', false, 'X')
|
|
421
|
+
const afterTyping = careful.input.selectionStart
|
|
422
|
+
check(
|
|
423
|
+
afterTyping === at + 1,
|
|
424
|
+
'the caret must follow the typed character (wanted ' + (at + 1) + ', got ' + afterTyping + ')',
|
|
425
|
+
)
|
|
426
|
+
// A full re-read and repaint, which is what a re-render used to trigger.
|
|
427
|
+
careful.refresh()
|
|
428
|
+
check(
|
|
429
|
+
careful.input.selectionStart === afterTyping,
|
|
430
|
+
'A REPAINT MUST NOT MOVE THE CARET (was ' + afterTyping + ', now ' + careful.input.selectionStart + ')',
|
|
431
|
+
)
|
|
432
|
+
check(
|
|
433
|
+
careful.input.value.charAt(at) === 'X',
|
|
434
|
+
'the typed character must still be where it was typed',
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
// ── 7. the list opens, navigates, and dismisses ─────────────────────
|
|
438
|
+
const listed = mount(sentry, 'running ')
|
|
439
|
+
listed.focus()
|
|
440
|
+
listed.setSelection(listed.input.value.length)
|
|
441
|
+
listed.showCompletions()
|
|
442
|
+
check(listed.currentCompletion !== undefined, 'showCompletions must open the list')
|
|
443
|
+
check(rowsOf(listed).length > 1, 'the list must offer more than one row (got ' + rowsOf(listed).length + ')')
|
|
444
|
+
const firstActive = listed.input.getAttribute('aria-activedescendant')
|
|
445
|
+
check(firstActive !== null, 'the field must point at the active row')
|
|
446
|
+
press(listed.input, 'ArrowDown')
|
|
447
|
+
check(
|
|
448
|
+
listed.input.getAttribute('aria-activedescendant') !== firstActive,
|
|
449
|
+
'ArrowDown must move the active row',
|
|
450
|
+
)
|
|
451
|
+
const activeRow = listed.element.querySelector('.litearea-row[aria-selected="true"]')
|
|
452
|
+
check(activeRow !== null, 'exactly one row must be marked active')
|
|
453
|
+
press(listed.input, 'Escape')
|
|
454
|
+
check(listed.currentCompletion === undefined, 'Escape must close the list')
|
|
455
|
+
check(listed.element.querySelectorAll('.litearea-row').length === 0, 'a closed list must be emptied')
|
|
456
|
+
|
|
457
|
+
// A pick with the mouse must not blur the field, or the caret is lost.
|
|
458
|
+
listed.showCompletions()
|
|
459
|
+
const target = listed.element.querySelector('.litearea-row')
|
|
460
|
+
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
|
|
461
|
+
check(
|
|
462
|
+
document.activeElement === listed.input,
|
|
463
|
+
'picking a row with the mouse must leave the field focused',
|
|
464
|
+
)
|
|
465
|
+
check(listed.currentCompletion === undefined, 'picking a row must close the list')
|
|
466
|
+
|
|
467
|
+
// ── 8. a long explanation must not be pushed out of reach ──────────
|
|
468
|
+
// The documentation used to live inside the scrolling element, after the rows. With a
|
|
469
|
+
// long list and a long explanation that made it unreachable: the arrows move the active
|
|
470
|
+
// row rather than the scrollbar, so a keyboard user never saw it, and a mouse user had
|
|
471
|
+
// to scroll down to read it and back up to reach the next row. The fix is positional, so
|
|
472
|
+
// this asks the layout rather than the DOM.
|
|
473
|
+
const bulky = litearea.defineGrammar({
|
|
474
|
+
id: 'bulky',
|
|
475
|
+
rules: [{ kind: 'match', scope: 'word', pattern: /[a-z]+/ }],
|
|
476
|
+
wordChars: /[\p{L}]/u,
|
|
477
|
+
compose: [
|
|
478
|
+
{
|
|
479
|
+
id: 'many',
|
|
480
|
+
range: (context) => context.word,
|
|
481
|
+
items: () =>
|
|
482
|
+
Array.from({ length: 40 }, (_, index) => ({
|
|
483
|
+
label: 'option ' + String(index),
|
|
484
|
+
detail: 'row ' + String(index),
|
|
485
|
+
documentation:
|
|
486
|
+
'A long explanation for option ' + String(index) + '. ' +
|
|
487
|
+
'It goes on at length so that the panel has to bound its own height and scroll itself, ' +
|
|
488
|
+
'rather than being carried along by the list. '.repeat(3),
|
|
489
|
+
})),
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
})
|
|
493
|
+
const bulkyEditor = mount(bulky, '')
|
|
494
|
+
bulkyEditor.focus()
|
|
495
|
+
bulkyEditor.showCompletions()
|
|
496
|
+
|
|
497
|
+
const bulkyPopup = bulkyEditor.element.querySelector('.litearea-popup')
|
|
498
|
+
const bulkyList = bulkyEditor.element.querySelector('.litearea-list')
|
|
499
|
+
const bulkyDocs = bulkyEditor.element.querySelector('.litearea-docs')
|
|
500
|
+
check(bulkyPopup !== null && bulkyList !== null && bulkyDocs !== null, 'the popup needs a list and a documentation panel')
|
|
501
|
+
if (bulkyPopup !== null && bulkyList !== null && bulkyDocs !== null) {
|
|
502
|
+
check(bulkyDocs.parentElement === bulkyPopup, 'the documentation must sit in the popup container')
|
|
503
|
+
check(!bulkyList.contains(bulkyDocs), 'THE DOCUMENTATION MUST NOT BE INSIDE THE SCROLLING LIST')
|
|
504
|
+
check(
|
|
505
|
+
bulkyList.scrollHeight > bulkyList.clientHeight + 1,
|
|
506
|
+
'the list must actually be scrollable, or this check proves nothing (' +
|
|
507
|
+
bulkyList.scrollHeight + ' vs ' + bulkyList.clientHeight + ')',
|
|
508
|
+
)
|
|
509
|
+
check(bulkyDocs.hidden === false, 'a row with an explanation must show the panel')
|
|
510
|
+
check(bulkyDocs.getBoundingClientRect().height > 0, 'the documentation panel must have a box')
|
|
511
|
+
|
|
512
|
+
const popupBox = bulkyPopup.getBoundingClientRect()
|
|
513
|
+
const docsBox = bulkyDocs.getBoundingClientRect()
|
|
514
|
+
check(
|
|
515
|
+
docsBox.bottom <= popupBox.bottom + 1,
|
|
516
|
+
'the documentation must fit inside the popup rather than hang below it (docs bottom ' +
|
|
517
|
+
docsBox.bottom + ', popup bottom ' + popupBox.bottom + ')',
|
|
518
|
+
)
|
|
519
|
+
check(docsBox.top >= popupBox.top - 1, 'the documentation must not start above the popup')
|
|
520
|
+
check(docsBox.bottom <= window.innerHeight, 'the documentation must be inside the viewport')
|
|
521
|
+
|
|
522
|
+
// The whole point: the list scrolls, the explanation does not move.
|
|
523
|
+
const docsTopBefore = docsBox.top
|
|
524
|
+
bulkyList.scrollTop = bulkyList.scrollHeight
|
|
525
|
+
const movedBy = bulkyDocs.getBoundingClientRect().top - docsTopBefore
|
|
526
|
+
check(
|
|
527
|
+
Math.abs(movedBy) <= 1,
|
|
528
|
+
'scrolling the list must not move the documentation (it moved ' + movedBy + 'px)',
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
// And the arrows still drive the list with it scrolled to the end.
|
|
532
|
+
const beforeArrow = bulkyEditor.currentCompletion && bulkyEditor.currentCompletion.rows.length
|
|
533
|
+
press(bulkyEditor.input, 'ArrowDown')
|
|
534
|
+
press(bulkyEditor.input, 'ArrowUp')
|
|
535
|
+
check(
|
|
536
|
+
bulkyEditor.currentCompletion !== undefined &&
|
|
537
|
+
bulkyEditor.currentCompletion.rows.length === beforeArrow,
|
|
538
|
+
'arrowing with the list scrolled must still drive the list',
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── 9. the tooltip explains a problem ───────────────────────────────
|
|
543
|
+
const hovered = mount(sentry, 'running bogus blue', { hover: { enabled: true, delay: 0 } })
|
|
544
|
+
const flagged = [...hovered.element.querySelectorAll('.litearea-paint > span')]
|
|
545
|
+
.find((span) => span.className.indexOf('litearea-diag-') >= 0)
|
|
546
|
+
check(flagged !== undefined, 'an unknown state must be underlined')
|
|
547
|
+
if (flagged !== undefined) {
|
|
548
|
+
const box = flagged.getBoundingClientRect()
|
|
549
|
+
hovered.input.dispatchEvent(new MouseEvent('mousemove', {
|
|
550
|
+
bubbles: true,
|
|
551
|
+
clientX: box.left + 2,
|
|
552
|
+
clientY: box.top + box.height / 2,
|
|
553
|
+
}))
|
|
554
|
+
await wait(60)
|
|
555
|
+
const tip = tipOf(hovered)
|
|
556
|
+
check(tip.dataset.open === 'true', 'hovering a problem must open a tooltip')
|
|
557
|
+
check(
|
|
558
|
+
tip.textContent.indexOf('Error') >= 0,
|
|
559
|
+
'the tooltip must lead with the severity (got ' + JSON.stringify(tip.textContent) + ')',
|
|
560
|
+
)
|
|
561
|
+
check(
|
|
562
|
+
tip.textContent.indexOf('bogus') >= 0,
|
|
563
|
+
'the tooltip must say what is wrong (got ' + JSON.stringify(tip.textContent) + ')',
|
|
564
|
+
)
|
|
565
|
+
// Moving off the text must take it away again.
|
|
566
|
+
hovered.input.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
|
|
567
|
+
check(tip.dataset.open === 'false', 'leaving the field must close the tooltip')
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── 9. the font grammar works in a browser too ─────────────────────
|
|
571
|
+
const fonts = mount(font, 'Geist Mono m')
|
|
572
|
+
fonts.focus()
|
|
573
|
+
fonts.setSelection(fonts.input.value.length)
|
|
574
|
+
fonts.showCompletions()
|
|
575
|
+
const fontRows = [...rowsOf(fonts)].map((row) => row.textContent)
|
|
576
|
+
check(
|
|
577
|
+
fontRows.some((label) => label.indexOf('medium') >= 0),
|
|
578
|
+
'the font grammar must offer Geist Mono weights (got ' + JSON.stringify(fontRows.slice(0, 6)) + ')',
|
|
579
|
+
)
|
|
580
|
+
check(
|
|
581
|
+
paintOf(fonts).textContent === 'Geist Mono m',
|
|
582
|
+
'the font layer must reproduce the query',
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
// ── 10. resting on blank space must explain nothing ────────────────
|
|
586
|
+
// Whitespace is painted with a scope like every other character, so a token IS found in a
|
|
587
|
+
// gap. Left alone, that produced a tooltip whose entire content was the name of the
|
|
588
|
+
// fallback scope — a user resting the pointer between two words was told "text", which is
|
|
589
|
+
// an implementation detail and not an explanation of anything.
|
|
590
|
+
//
|
|
591
|
+
// The grammar here is naive ON PURPOSE: it describes every token it is handed, including
|
|
592
|
+
// whitespace. Testing this against one of the reference grammars would prove nothing,
|
|
593
|
+
// because both of them happen to stay quiet about a scope they have no documentation
|
|
594
|
+
// for — the check has to exercise the ENGINE's refusal to ask, not a grammar's manners.
|
|
595
|
+
const naive = litearea.defineGrammar({
|
|
596
|
+
id: 'naive',
|
|
597
|
+
rules: [{ kind: 'match', scope: 'word', pattern: /[a-z]+/ }],
|
|
598
|
+
describe: (context) =>
|
|
599
|
+
context.token === undefined
|
|
600
|
+
? undefined
|
|
601
|
+
: { title: context.token.text, detail: context.token.scope },
|
|
602
|
+
})
|
|
603
|
+
const gaps = mount(naive, 'alpha beta', { hover: { enabled: true, delay: 0 } })
|
|
604
|
+
|
|
605
|
+
// The spans are looked up fresh on every use, and that is not fussiness: the editor
|
|
606
|
+
// repaints the layer when document.fonts.ready resolves, which REPLACES every span
|
|
607
|
+
// element. A reference held across an await is detached by then, and
|
|
608
|
+
// getBoundingClientRect on a detached node returns zeros — which reads exactly like a
|
|
609
|
+
// layout bug and is not one.
|
|
610
|
+
const spanFor = (scope) =>
|
|
611
|
+
[...gaps.element.querySelectorAll('.litearea-paint > span')].find(
|
|
612
|
+
(span) => span.className.indexOf(scope) >= 0,
|
|
613
|
+
)
|
|
614
|
+
const hoverScope = async (scope) => {
|
|
615
|
+
const span = spanFor(scope)
|
|
616
|
+
if (span === undefined) return false
|
|
617
|
+
const box = span.getBoundingClientRect()
|
|
618
|
+
gaps.input.dispatchEvent(new MouseEvent('mousemove', {
|
|
619
|
+
bubbles: true,
|
|
620
|
+
clientX: box.left + Math.max(1, box.width / 2),
|
|
621
|
+
clientY: box.top + box.height / 2,
|
|
622
|
+
}))
|
|
623
|
+
await wait(60)
|
|
624
|
+
return true
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
check(spanFor('litearea-scope-word') !== undefined, 'the naive document must have a word')
|
|
628
|
+
check(spanFor('litearea-scope-text') !== undefined, 'the naive document must have a gap')
|
|
629
|
+
|
|
630
|
+
// First the control: hovering a WORD must open a tooltip, or "no tooltip on the gap"
|
|
631
|
+
// would pass simply because hovering is broken.
|
|
632
|
+
const hoveredWord = await hoverScope('litearea-scope-word')
|
|
633
|
+
const gapTip = tipOf(gaps)
|
|
634
|
+
check(hoveredWord && gapTip.dataset.open === 'true', 'hovering a word must open a tooltip')
|
|
635
|
+
check(
|
|
636
|
+
gapTip.textContent.indexOf('word') >= 0,
|
|
637
|
+
'the tooltip must be the grammar\'s own (got ' + JSON.stringify(gapTip.textContent) + ')',
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
// Then the gap, which must close the tooltip the word opened.
|
|
641
|
+
await hoverScope('litearea-scope-text')
|
|
642
|
+
check(
|
|
643
|
+
gapTip.dataset.open === 'false',
|
|
644
|
+
'resting on blank space must not open a tooltip (it said ' + JSON.stringify(gapTip.textContent) + ')',
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
// And a point past the end of the document, which is where the pointer lands when it
|
|
648
|
+
// rests in the empty part of the box.
|
|
649
|
+
const farBox = gaps.input.getBoundingClientRect()
|
|
650
|
+
gaps.input.dispatchEvent(new MouseEvent('mousemove', {
|
|
651
|
+
bubbles: true,
|
|
652
|
+
clientX: farBox.right - 4,
|
|
653
|
+
clientY: farBox.bottom - 4,
|
|
654
|
+
}))
|
|
655
|
+
await wait(60)
|
|
656
|
+
check(
|
|
657
|
+
gapTip.dataset.open === 'false',
|
|
658
|
+
'resting in the empty part of the box must not open a tooltip (it said ' + JSON.stringify(gapTip.textContent) + ')',
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
// ── 11. one stylesheet, however many editors ───────────────────────
|
|
662
|
+
check(
|
|
663
|
+
document.querySelectorAll('style[data-litearea-styles]').length === 1,
|
|
664
|
+
'the stylesheet must be injected exactly once (got ' +
|
|
665
|
+
document.querySelectorAll('style[data-litearea-styles]').length + ')',
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
const edits = one.input.value
|
|
669
|
+
one.input.value = edits
|
|
670
|
+
|
|
671
|
+
document.title = 'LITEAREA-RESULT ' + problems.length + ' ' + problems.join(' | ')
|
|
672
|
+
})().catch((error) => {
|
|
673
|
+
document.title = 'LITEAREA-RESULT 1 the checklist itself threw: ' + (error && error.message)
|
|
674
|
+
})
|
|
675
|
+
`
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The harness page.
|
|
679
|
+
* @param library - the inlined bundle.
|
|
680
|
+
* @returns the HTML source.
|
|
681
|
+
*/
|
|
682
|
+
function harnessPage(library) {
|
|
683
|
+
return `<!doctype html>
|
|
684
|
+
<html lang="en">
|
|
685
|
+
<head>
|
|
686
|
+
<meta charset="utf-8" />
|
|
687
|
+
<title>litearea harness</title>
|
|
688
|
+
<style>
|
|
689
|
+
body { margin: 0; padding: 8px; font-family: system-ui, sans-serif; }
|
|
690
|
+
</style>
|
|
691
|
+
<script>
|
|
692
|
+
// A page whose only output channel is its title has to report its own
|
|
693
|
+
// failures through that channel. Without this a syntax error in the library
|
|
694
|
+
// bundle, or an uncaught throw before the checklist's own catch, shows up as
|
|
695
|
+
// an unchanged title and no evidence at all.
|
|
696
|
+
window.addEventListener('error', function (event) {
|
|
697
|
+
document.title = 'LITEAREA-RESULT 1 page error: ' + (event.message || String(event.error))
|
|
698
|
+
})
|
|
699
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
700
|
+
document.title = 'LITEAREA-RESULT 1 unhandled rejection: ' + String(event.reason)
|
|
701
|
+
})
|
|
702
|
+
</script>
|
|
703
|
+
</head>
|
|
704
|
+
<body>
|
|
705
|
+
<script>${library}</script>
|
|
706
|
+
<script>
|
|
707
|
+
${CHECKLIST}
|
|
708
|
+
</script>
|
|
709
|
+
</body>
|
|
710
|
+
</html>
|
|
711
|
+
`
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const library = await bundle()
|
|
715
|
+
const scope = mkdtempSync(join(tmpdir(), 'litearea-browser-check-'))
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Kill any browser process still holding this run's profile directory.
|
|
719
|
+
*
|
|
720
|
+
* Chromium does not reliably take its helper processes with it when `--dump-dom` returns, and a
|
|
721
|
+
* handful of runs of this script is enough to leave dozens of them resident — which is how a
|
|
722
|
+
* check script turns into a memory problem on a development machine.
|
|
723
|
+
*
|
|
724
|
+
* Only processes whose command line names OUR temporary profile are touched, so a browser the
|
|
725
|
+
* user has open is never affected. Best effort throughout: a stray process is untidy, and that is
|
|
726
|
+
* not a reason to fail a release check.
|
|
727
|
+
* @param executable - the browser that was launched.
|
|
728
|
+
* @param profile - the profile directory this run created.
|
|
729
|
+
*/
|
|
730
|
+
function reapBrowser(executable, profile) {
|
|
731
|
+
const name = executable.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
732
|
+
try {
|
|
733
|
+
if (process.platform === 'win32') {
|
|
734
|
+
const script =
|
|
735
|
+
`Get-CimInstance Win32_Process -Filter "Name='${name}'" | ` +
|
|
736
|
+
`Where-Object { $_.CommandLine -like '*${profile}*' } | ` +
|
|
737
|
+
'ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }'
|
|
738
|
+
spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
739
|
+
stdio: 'ignore',
|
|
740
|
+
})
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
spawnSync('pkill', ['-f', profile], { stdio: 'ignore' })
|
|
744
|
+
} catch {
|
|
745
|
+
// Nothing to be done, and nothing worth failing over.
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Run the browser and collect what it dumped.
|
|
751
|
+
*
|
|
752
|
+
* The output goes to FILES rather than through a pipe, and that is not a style
|
|
753
|
+
* choice: a browser's stdout cannot be captured through piped stdio in a confined
|
|
754
|
+
* environment, where it silently comes back empty — a failure that looks exactly
|
|
755
|
+
* like the harness page having produced no output at all. Passing file descriptors
|
|
756
|
+
* sidesteps it and works everywhere.
|
|
757
|
+
* @param url - the page to open.
|
|
758
|
+
* @returns the dumped DOM and whatever the browser said on stderr.
|
|
759
|
+
*/
|
|
760
|
+
function runBrowser(url) {
|
|
761
|
+
const domPath = join(scope, 'dump.html')
|
|
762
|
+
const errPath = join(scope, 'stderr.txt')
|
|
763
|
+
const profile = join(scope, 'profile')
|
|
764
|
+
const domFd = openSync(domPath, 'w')
|
|
765
|
+
const errFd = openSync(errPath, 'w')
|
|
766
|
+
let result
|
|
767
|
+
try {
|
|
768
|
+
result = spawnSync(
|
|
769
|
+
browser,
|
|
770
|
+
[
|
|
771
|
+
'--headless=new',
|
|
772
|
+
'--disable-gpu',
|
|
773
|
+
'--no-first-run',
|
|
774
|
+
'--no-default-browser-check',
|
|
775
|
+
// The crash reporter is no use to a page that is loaded, measured, and thrown away, and
|
|
776
|
+
// turning it off keeps a couple of helper processes out of the way. Chromium may still
|
|
777
|
+
// leave others behind, which `reapBrowser` clears up.
|
|
778
|
+
'--disable-crash-reporter',
|
|
779
|
+
'--no-crashpad',
|
|
780
|
+
`--user-data-dir=${profile}`,
|
|
781
|
+
'--window-size=900,700',
|
|
782
|
+
'--virtual-time-budget=15000',
|
|
783
|
+
'--dump-dom',
|
|
784
|
+
url,
|
|
785
|
+
],
|
|
786
|
+
{ stdio: ['ignore', domFd, errFd] },
|
|
787
|
+
)
|
|
788
|
+
} finally {
|
|
789
|
+
closeSync(domFd)
|
|
790
|
+
closeSync(errFd)
|
|
791
|
+
reapBrowser(browser, profile)
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
dom: existsSync(domPath) ? readFileSync(domPath, 'utf8') : '',
|
|
795
|
+
stderr: existsSync(errPath) ? readFileSync(errPath, 'utf8') : '',
|
|
796
|
+
error: result.error,
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
writeFileSync(join(scope, 'index.html'), harnessPage(library), 'utf8')
|
|
802
|
+
|
|
803
|
+
const url = `file:///${join(scope, 'index.html').replaceAll('\\', '/')}`
|
|
804
|
+
const { dom, stderr, error } = runBrowser(url)
|
|
805
|
+
|
|
806
|
+
const title = /<title>([^<]*)<\/title>/.exec(dom)?.[1]
|
|
807
|
+
if (title === undefined || !title.startsWith('LITEAREA-RESULT ')) {
|
|
808
|
+
if (error !== undefined) throw error
|
|
809
|
+
// The page produced no verdict, which means the bundle or the checklist threw
|
|
810
|
+
// before it could report. The dumped DOM and the browser's own log are the only
|
|
811
|
+
// evidence there is, so both are shown rather than a bare failure.
|
|
812
|
+
console.error('browser-check: the harness did not report a verdict')
|
|
813
|
+
console.error(` title: ${JSON.stringify(title ?? null)}`)
|
|
814
|
+
console.error(` dom bytes: ${String(dom.length)}`)
|
|
815
|
+
console.error(` stderr: ${stderr.slice(0, 1500)}`)
|
|
816
|
+
if (process.env.LITEAREA_KEEP === '1') {
|
|
817
|
+
console.error(` kept: ${scope}`)
|
|
818
|
+
} else {
|
|
819
|
+
rmSync(scope, { recursive: true, force: true })
|
|
820
|
+
}
|
|
821
|
+
process.exit(1)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const parsed = /^LITEAREA-RESULT (\d+)\s*(.*)$/.exec(title)
|
|
825
|
+
const failures = Number(parsed?.[1] ?? '1')
|
|
826
|
+
const detail = parsed?.[2] ?? title
|
|
827
|
+
if (failures > 0) {
|
|
828
|
+
console.error(`browser-check: ${String(failures)} problem(s) in ${browser}:`)
|
|
829
|
+
for (const problem of detail.split(' | ')) console.error(` - ${problem}`)
|
|
830
|
+
process.exit(1)
|
|
831
|
+
}
|
|
832
|
+
console.log(`browser-check: OK — ${browser}`)
|
|
833
|
+
console.log(
|
|
834
|
+
'browser-check: undo survives a completion, the caret survives a repaint, the layer aligns, and the box sizes itself',
|
|
835
|
+
)
|
|
836
|
+
} finally {
|
|
837
|
+
rmSync(scope, { recursive: true, force: true })
|
|
838
|
+
}
|