regen.mde 0.2.2 → 0.7.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/README.md +409 -295
- package/bin/build-corpus-editor.js +5 -3
- package/bin/postinstall.js +259 -187
- package/bin/regen-mdeditor-install.js +1 -1
- package/bin/regen-mdeditor-uninstall.js +1 -1
- package/desktop/BuildCorpusEditor/BuildCorpusBridge.cs +493 -270
- package/desktop/BuildCorpusEditor/EditorForm.cs +853 -540
- package/desktop/BuildCorpusEditor/Program.cs +85 -81
- package/dist/release/regen-mde-0.3.0-win-x64-setup.exe +0 -0
- package/dist/release/{regen.mde-0.2.2-win-x64.zip → regen-mde-0.3.0-win-x64.zip} +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64-setup.exe +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64.zip +0 -0
- package/dist/windows-editor/BuildCorpusEditor.dll +0 -0
- package/dist/windows-editor/BuildCorpusEditor.exe +0 -0
- package/dist/windows-editor/BuildCorpusEditor.pdb +0 -0
- package/dist/windows-editor/wwwroot/assets/index-C_VxJk4k.js +375 -0
- package/dist/windows-editor/wwwroot/assets/index-Wt9zSjIw.css +1 -0
- package/dist/windows-editor/wwwroot/index.html +3 -3
- package/editor-web/index.html +1 -1
- package/editor-web/src/main.jsx +1044 -399
- package/editor-web/src/styles.css +846 -602
- package/installer/install-regen-mde.ps1 +49 -10
- package/installer/regen-mde.nsi +16 -16
- package/package.json +90 -86
- package/pyproject.toml +35 -33
- package/requirements.txt +6 -4
- package/scripts/package-windows-editor.ps1 +8 -8
- package/scripts/release-dual.mjs +105 -0
- package/scripts/run-editor-implementation-plane.ps1 +29 -6
- package/src/build_corpus/docx_exporter.py +1055 -798
- package/src/build_corpus/equations.py +80 -0
- package/src/build_corpus/exporter.py +1488 -1195
- package/src/build_corpus/frontmatter.py +302 -0
- package/src/build_corpus/ppt_exporter.py +543 -532
- package/dist/release/regen.mde-0.2.2-win-x64-setup.exe +0 -0
- package/dist/windows-editor/wwwroot/assets/index-DjJ6xmhy.js +0 -326
- package/dist/windows-editor/wwwroot/assets/index-_dwMNNsm.css +0 -1
|
@@ -1,540 +1,853 @@
|
|
|
1
|
-
using System.Text.Json;
|
|
2
|
-
using System.Drawing.Imaging;
|
|
3
|
-
using Microsoft.Web.WebView2.Core;
|
|
4
|
-
using Microsoft.Web.WebView2.WinForms;
|
|
5
|
-
|
|
6
|
-
namespace BuildCorpusEditor;
|
|
7
|
-
|
|
8
|
-
internal sealed class EditorForm : Form
|
|
9
|
-
{
|
|
10
|
-
private
|
|
11
|
-
private
|
|
12
|
-
private readonly
|
|
13
|
-
private readonly
|
|
14
|
-
private readonly
|
|
15
|
-
private readonly string
|
|
16
|
-
private readonly
|
|
17
|
-
private readonly
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
protected override
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
var
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
LogSmoke(
|
|
127
|
-
Environment.Exit(
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
probe.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
probe
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
"""
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
await ClickButtonAsync("
|
|
334
|
-
await
|
|
335
|
-
await
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
await
|
|
344
|
-
await
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
"
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
using System.Drawing.Imaging;
|
|
3
|
+
using Microsoft.Web.WebView2.Core;
|
|
4
|
+
using Microsoft.Web.WebView2.WinForms;
|
|
5
|
+
|
|
6
|
+
namespace BuildCorpusEditor;
|
|
7
|
+
|
|
8
|
+
internal sealed class EditorForm : Form
|
|
9
|
+
{
|
|
10
|
+
private const int WsExNoActivate = 0x08000000;
|
|
11
|
+
private const int WsExToolWindow = 0x00000080;
|
|
12
|
+
private readonly BuildCorpusBridge bridge;
|
|
13
|
+
private readonly bool smokeUi;
|
|
14
|
+
private readonly bool background;
|
|
15
|
+
private readonly string? initialPath;
|
|
16
|
+
private readonly string smokeLogPath = Path.Combine(Path.GetTempPath(), "regen-mde-smoke.log");
|
|
17
|
+
private readonly string smokeOutPath = Path.Combine(Path.GetTempPath(), "regen-mde-smoke-output");
|
|
18
|
+
private readonly WebView2 webView;
|
|
19
|
+
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
|
|
20
|
+
|
|
21
|
+
public EditorForm(BuildCorpusBridge bridge, bool smokeUi = false, bool background = false)
|
|
22
|
+
{
|
|
23
|
+
this.bridge = bridge;
|
|
24
|
+
this.smokeUi = smokeUi;
|
|
25
|
+
this.background = background;
|
|
26
|
+
initialPath = bridge.Startup().InitialPath;
|
|
27
|
+
Text = "regen-mde";
|
|
28
|
+
Width = 1280;
|
|
29
|
+
Height = 860;
|
|
30
|
+
StartPosition = FormStartPosition.CenterScreen;
|
|
31
|
+
if (background)
|
|
32
|
+
{
|
|
33
|
+
FormBorderStyle = FormBorderStyle.None;
|
|
34
|
+
ShowInTaskbar = false;
|
|
35
|
+
StartPosition = FormStartPosition.Manual;
|
|
36
|
+
Location = new Point(-32000, -32000);
|
|
37
|
+
Size = new Size(1280, 860);
|
|
38
|
+
Opacity = 0;
|
|
39
|
+
}
|
|
40
|
+
if (smokeUi)
|
|
41
|
+
{
|
|
42
|
+
if (Directory.Exists(smokeOutPath)) Directory.Delete(smokeOutPath, recursive: true);
|
|
43
|
+
Directory.CreateDirectory(smokeOutPath);
|
|
44
|
+
File.WriteAllText(smokeLogPath, $"[{DateTimeOffset.Now:O}] regen-mde smoke started{Environment.NewLine}");
|
|
45
|
+
var watchdog = new System.Windows.Forms.Timer { Interval = 60000 };
|
|
46
|
+
watchdog.Tick += (_, _) =>
|
|
47
|
+
{
|
|
48
|
+
LogSmoke("watchdog timeout");
|
|
49
|
+
Environment.Exit(1);
|
|
50
|
+
};
|
|
51
|
+
watchdog.Start();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
webView = new WebView2 { Dock = DockStyle.Fill };
|
|
55
|
+
Controls.Add(webView);
|
|
56
|
+
Load += OnLoad;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected override bool ShowWithoutActivation => background;
|
|
60
|
+
|
|
61
|
+
protected override void SetVisibleCore(bool value)
|
|
62
|
+
{
|
|
63
|
+
base.SetVisibleCore(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected override CreateParams CreateParams
|
|
67
|
+
{
|
|
68
|
+
get
|
|
69
|
+
{
|
|
70
|
+
var cp = base.CreateParams;
|
|
71
|
+
if (background)
|
|
72
|
+
{
|
|
73
|
+
cp.ExStyle |= WsExToolWindow | WsExNoActivate;
|
|
74
|
+
}
|
|
75
|
+
return cp;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async void OnLoad(object? sender, EventArgs e)
|
|
80
|
+
{
|
|
81
|
+
try
|
|
82
|
+
{
|
|
83
|
+
LogSmoke("OnLoad");
|
|
84
|
+
var userDataFolder = Path.Combine(
|
|
85
|
+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
86
|
+
"regen-mde",
|
|
87
|
+
"WebView2UserData");
|
|
88
|
+
Directory.CreateDirectory(userDataFolder);
|
|
89
|
+
var environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
|
|
90
|
+
await webView.EnsureCoreWebView2Async(environment);
|
|
91
|
+
webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
|
|
92
|
+
webView.CoreWebView2.ProcessFailed += (_, args) =>
|
|
93
|
+
{
|
|
94
|
+
LogSmoke($"process failed: {args.ProcessFailedKind}");
|
|
95
|
+
if (smokeUi) Environment.Exit(1);
|
|
96
|
+
};
|
|
97
|
+
if (smokeUi)
|
|
98
|
+
{
|
|
99
|
+
await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
|
|
100
|
+
$"window.__REGEN_MDEDITOR_SMOKE_OUT = {JsonSerializer.Serialize(smokeOutPath)}; window.prompt = function(message, value) {{ return String(message || '').includes('Image') ? 'https://example.com/image.png' : 'https://example.com'; }};");
|
|
101
|
+
webView.CoreWebView2.NavigationCompleted += OnSmokeNavigationCompleted;
|
|
102
|
+
}
|
|
103
|
+
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
|
104
|
+
LogSmoke($"web root: {webRoot}");
|
|
105
|
+
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
|
|
106
|
+
"build-corpus-editor.local",
|
|
107
|
+
webRoot,
|
|
108
|
+
CoreWebView2HostResourceAccessKind.Allow);
|
|
109
|
+
webView.CoreWebView2.Navigate("https://build-corpus-editor.local/index.html");
|
|
110
|
+
}
|
|
111
|
+
catch (Exception ex)
|
|
112
|
+
{
|
|
113
|
+
LogSmoke($"OnLoad failed: {ex}");
|
|
114
|
+
if (smokeUi) Environment.Exit(1);
|
|
115
|
+
throw;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async void OnSmokeNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
|
|
120
|
+
{
|
|
121
|
+
try
|
|
122
|
+
{
|
|
123
|
+
LogSmoke($"navigation completed: success={e.IsSuccess} status={e.WebErrorStatus}");
|
|
124
|
+
if (!e.IsSuccess) Environment.Exit(1);
|
|
125
|
+
await WaitForRenderedContentAsync();
|
|
126
|
+
LogSmoke("smoke passed");
|
|
127
|
+
Environment.Exit(0);
|
|
128
|
+
}
|
|
129
|
+
catch (Exception ex)
|
|
130
|
+
{
|
|
131
|
+
LogSmoke($"smoke failed: {ex}");
|
|
132
|
+
Environment.Exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async Task WaitForRenderedContentAsync()
|
|
137
|
+
{
|
|
138
|
+
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
|
139
|
+
var expected = ExpectedSmokeText();
|
|
140
|
+
while (DateTimeOffset.UtcNow < deadline)
|
|
141
|
+
{
|
|
142
|
+
var script = """
|
|
143
|
+
(() => {
|
|
144
|
+
const body = document.body;
|
|
145
|
+
const text = body ? body.innerText : "";
|
|
146
|
+
const root = document.getElementById("root");
|
|
147
|
+
const rect = root ? root.getBoundingClientRect() : { width: 0, height: 0 };
|
|
148
|
+
return JSON.stringify({
|
|
149
|
+
title: document.title,
|
|
150
|
+
text,
|
|
151
|
+
rootWidth: rect.width,
|
|
152
|
+
rootHeight: rect.height,
|
|
153
|
+
error: window.__REGEN_MDEDITOR_ERROR || ""
|
|
154
|
+
});
|
|
155
|
+
})()
|
|
156
|
+
""";
|
|
157
|
+
var json = await ExecuteScriptWithTimeoutAsync(script, TimeSpan.FromSeconds(3));
|
|
158
|
+
var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
|
|
159
|
+
var probe = JsonSerializer.Deserialize<SmokeProbe>(inner, jsonOptions);
|
|
160
|
+
LogSmoke($"probe title='{probe?.Title}' root={probe?.RootWidth}x{probe?.RootHeight} error='{probe?.Error}' text='{TrimForLog(probe?.Text)}'");
|
|
161
|
+
if (!string.IsNullOrWhiteSpace(probe?.Error))
|
|
162
|
+
{
|
|
163
|
+
throw new InvalidOperationException(probe.Error);
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
probe is not null &&
|
|
167
|
+
probe.Title.Contains("regen-mde", StringComparison.OrdinalIgnoreCase) &&
|
|
168
|
+
probe.Text.Contains("regen-mde", StringComparison.OrdinalIgnoreCase) &&
|
|
169
|
+
probe.Text.Contains(expected, StringComparison.OrdinalIgnoreCase) &&
|
|
170
|
+
probe.RootWidth > 800 &&
|
|
171
|
+
probe.RootHeight > 500)
|
|
172
|
+
{
|
|
173
|
+
LogSmoke("render probe passed");
|
|
174
|
+
await AssertNonBlankScreenshotAsync();
|
|
175
|
+
LogSmoke("screenshot probe passed");
|
|
176
|
+
await AssertEditorControlsWorkAsync();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await Task.Delay(250);
|
|
180
|
+
}
|
|
181
|
+
throw new TimeoutException("Rendered regen-mde UI did not become visible.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private string ExpectedSmokeText()
|
|
185
|
+
{
|
|
186
|
+
if (string.IsNullOrWhiteSpace(initialPath)) return "Open";
|
|
187
|
+
var name = Path.GetFileNameWithoutExtension(initialPath);
|
|
188
|
+
return string.IsNullOrWhiteSpace(name) ? "Open" : name;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async Task AssertNonBlankScreenshotAsync()
|
|
192
|
+
{
|
|
193
|
+
await using var stream = new MemoryStream();
|
|
194
|
+
LogSmoke("capturing screenshot");
|
|
195
|
+
var capture = webView.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, stream);
|
|
196
|
+
var completed = await Task.WhenAny(capture, Task.Delay(TimeSpan.FromSeconds(5)));
|
|
197
|
+
if (completed != capture)
|
|
198
|
+
{
|
|
199
|
+
LogSmoke("WebView screenshot capture timed out; trying DrawToBitmap fallback");
|
|
200
|
+
try
|
|
201
|
+
{
|
|
202
|
+
using var fallback = new Bitmap(Math.Max(1, webView.Width), Math.Max(1, webView.Height));
|
|
203
|
+
webView.DrawToBitmap(fallback, new Rectangle(0, 0, fallback.Width, fallback.Height));
|
|
204
|
+
AssertBitmapNonBlank(fallback);
|
|
205
|
+
}
|
|
206
|
+
catch (Exception ex)
|
|
207
|
+
{
|
|
208
|
+
LogSmoke($"DrawToBitmap fallback unavailable: {ex.Message}");
|
|
209
|
+
await AssertVisibleDomPaintAsync();
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await capture;
|
|
214
|
+
if (stream.Length < 4096) throw new InvalidOperationException("WebView screenshot was too small.");
|
|
215
|
+
stream.Position = 0;
|
|
216
|
+
using var bitmap = new Bitmap(stream);
|
|
217
|
+
AssertBitmapNonBlank(bitmap);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private static void AssertBitmapNonBlank(Bitmap bitmap)
|
|
221
|
+
{
|
|
222
|
+
var first = bitmap.GetPixel(0, 0).ToArgb();
|
|
223
|
+
var different = 0;
|
|
224
|
+
for (var x = 0; x < bitmap.Width; x += Math.Max(1, bitmap.Width / 12))
|
|
225
|
+
{
|
|
226
|
+
for (var y = 0; y < bitmap.Height; y += Math.Max(1, bitmap.Height / 12))
|
|
227
|
+
{
|
|
228
|
+
if (bitmap.GetPixel(x, y).ToArgb() != first) different++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (different < 5) throw new InvalidOperationException("WebView screenshot appears blank.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async Task AssertVisibleDomPaintAsync()
|
|
235
|
+
{
|
|
236
|
+
var script = """
|
|
237
|
+
(() => {
|
|
238
|
+
const visible = [...document.querySelectorAll("main, header, aside, section, article, button, .cm-editor, .page, .canvas-wrap")]
|
|
239
|
+
.map((element) => {
|
|
240
|
+
const style = getComputedStyle(element);
|
|
241
|
+
const rect = element.getBoundingClientRect();
|
|
242
|
+
return {
|
|
243
|
+
tag: element.tagName,
|
|
244
|
+
text: (element.textContent || "").trim().slice(0, 80),
|
|
245
|
+
width: rect.width,
|
|
246
|
+
height: rect.height,
|
|
247
|
+
display: style.display,
|
|
248
|
+
visibility: style.visibility,
|
|
249
|
+
opacity: Number(style.opacity),
|
|
250
|
+
bg: style.backgroundColor,
|
|
251
|
+
color: style.color
|
|
252
|
+
};
|
|
253
|
+
})
|
|
254
|
+
.filter((item) =>
|
|
255
|
+
item.width > 8 &&
|
|
256
|
+
item.height > 8 &&
|
|
257
|
+
item.display !== "none" &&
|
|
258
|
+
item.visibility !== "hidden" &&
|
|
259
|
+
item.opacity > 0.05
|
|
260
|
+
);
|
|
261
|
+
const colors = new Set(visible.flatMap((item) => [item.bg, item.color]).filter(Boolean));
|
|
262
|
+
return JSON.stringify({
|
|
263
|
+
count: visible.length,
|
|
264
|
+
colors: colors.size,
|
|
265
|
+
text: document.body.innerText || ""
|
|
266
|
+
});
|
|
267
|
+
})()
|
|
268
|
+
""";
|
|
269
|
+
var json = await ExecuteScriptWithTimeoutAsync(script, TimeSpan.FromSeconds(3));
|
|
270
|
+
var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
|
|
271
|
+
var probe = JsonSerializer.Deserialize<DomPaintProbe>(inner, jsonOptions);
|
|
272
|
+
LogSmoke($"dom paint count={probe?.Count} colors={probe?.Colors}");
|
|
273
|
+
if (probe is null || probe.Count < 16 || probe.Colors < 4 || !probe.Text.Contains("regen-mde", StringComparison.OrdinalIgnoreCase))
|
|
274
|
+
{
|
|
275
|
+
throw new InvalidOperationException("DOM paint probe suggests the UI is blank or hidden.");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async Task AssertEditorControlsWorkAsync()
|
|
280
|
+
{
|
|
281
|
+
var markdownOutput = Path.Combine(smokeOutPath, "ui-save-as-markdown.md");
|
|
282
|
+
var wordOutput = Path.Combine(smokeOutPath, "ui-save-as-word.docx");
|
|
283
|
+
var styleFixtureMarkdown = """
|
|
284
|
+
# Style Fixture H1
|
|
285
|
+
|
|
286
|
+
## Style Fixture H2
|
|
287
|
+
|
|
288
|
+
### Style Fixture H3
|
|
289
|
+
|
|
290
|
+
Paragraph with **bold text**, *italic text*, ***bold italic text***, ~~strike text~~, `inline code`, and [fixture link](https://example.com).
|
|
291
|
+
|
|
292
|
+
> Fixture quote block that should remain visibly formatted in preview.
|
|
293
|
+
|
|
294
|
+
- Bullet one
|
|
295
|
+
- Bullet two
|
|
296
|
+
|
|
297
|
+
1. Ordered one
|
|
298
|
+
2. Ordered two
|
|
299
|
+
|
|
300
|
+
- [x] Complete task
|
|
301
|
+
- [ ] Open task
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
| Column | Value |
|
|
306
|
+
| --- | --- |
|
|
307
|
+
| Alpha | **Bold Cell** |
|
|
308
|
+
| Beta | `Code Cell` |
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
console.log("style fixture");
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+

|
|
315
|
+
|
|
316
|
+
""" + string.Join(Environment.NewLine + Environment.NewLine, Enumerable.Range(1, 90).Select(index => $"Scroll sync fixture paragraph {index}. This paragraph keeps the preview and Markdown panes tall enough for bidirectional scroll checks."));
|
|
317
|
+
var largeSmokeDocument = !string.IsNullOrWhiteSpace(initialPath)
|
|
318
|
+
&& File.Exists(initialPath)
|
|
319
|
+
&& Path.GetExtension(initialPath).ToLowerInvariant() is ".md" or ".markdown"
|
|
320
|
+
&& new FileInfo(initialPath).Length > 50_000;
|
|
321
|
+
|
|
322
|
+
await AssertControlStepAsync("required controls", """
|
|
323
|
+
(() => {
|
|
324
|
+
const requiredLabels = ["Open", "Save MD", "Save MD As...", "Export Word...", "Run Check", "Markdown", "Raw", "Split Screen", "Undo", "Redo", "Link", "Table", "Image"];
|
|
325
|
+
const text = document.body.innerText || "";
|
|
326
|
+
for (const label of requiredLabels) {
|
|
327
|
+
if (!text.includes(label)) throw new Error(`Missing control label: ${label}`);
|
|
328
|
+
}
|
|
329
|
+
return "controls present";
|
|
330
|
+
})()
|
|
331
|
+
""");
|
|
332
|
+
|
|
333
|
+
await ClickButtonAsync("Raw");
|
|
334
|
+
await Task.Delay(250);
|
|
335
|
+
await AssertControlStepAsync("edit markdown", """
|
|
336
|
+
(() => {
|
|
337
|
+
const source = document.querySelector(".cm-editor");
|
|
338
|
+
if (!source) throw new Error("Markdown editor was not visible.");
|
|
339
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API) throw new Error("Smoke editor API was not registered.");
|
|
340
|
+
return window.__REGEN_MDEDITOR_SMOKE_API.appendMarkdown("\n\nUI functional edit marker");
|
|
341
|
+
})()
|
|
342
|
+
""");
|
|
343
|
+
await Task.Delay(250);
|
|
344
|
+
await AssertControlStepAsync("markdown marker present", """
|
|
345
|
+
(() => {
|
|
346
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API?.getMarkdown().includes("UI functional edit marker")) throw new Error("Edited Markdown marker was not present in the editor buffer.");
|
|
347
|
+
return "marker present";
|
|
348
|
+
})()
|
|
349
|
+
""");
|
|
350
|
+
await AssertControlStepAsync("markdown toolbar place cursor in bold", """
|
|
351
|
+
(() => {
|
|
352
|
+
const api = window.__REGEN_MDEDITOR_SMOKE_API;
|
|
353
|
+
if (!api) throw new Error("Smoke editor API was not registered.");
|
|
354
|
+
api.setMarkdown("alpha **bold** beta *italic* gamma `code`");
|
|
355
|
+
api.setSelection(9, 9);
|
|
356
|
+
return "cursor placed in bold";
|
|
357
|
+
})()
|
|
358
|
+
""");
|
|
359
|
+
await Task.Delay(150);
|
|
360
|
+
await AssertControlStepAsync("markdown toolbar detects bold", """
|
|
361
|
+
(() => {
|
|
362
|
+
const toolbar = document.querySelector(".markdown-toolbar");
|
|
363
|
+
if (!toolbar) throw new Error("Markdown toolbar was not visible.");
|
|
364
|
+
const button = [...toolbar.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === "B");
|
|
365
|
+
if (!button) throw new Error("Markdown bold button was not present.");
|
|
366
|
+
if (!button.classList.contains("active")) throw new Error("Bold button did not detect cursor inside bold Markdown.");
|
|
367
|
+
button.click();
|
|
368
|
+
return "bold active and clicked";
|
|
369
|
+
})()
|
|
370
|
+
""");
|
|
371
|
+
await Task.Delay(250);
|
|
372
|
+
await AssertControlStepAsync("markdown toolbar toggles bold off", """
|
|
373
|
+
(() => {
|
|
374
|
+
const afterBold = window.__REGEN_MDEDITOR_SMOKE_API.getMarkdown();
|
|
375
|
+
if (afterBold.includes("**bold**")) throw new Error(`Bold button added syntax instead of toggling it off: ${afterBold}`);
|
|
376
|
+
return "bold toggle passed";
|
|
377
|
+
})()
|
|
378
|
+
""");
|
|
379
|
+
await AssertControlStepAsync("markdown toolbar place cursor in italic", """
|
|
380
|
+
(() => {
|
|
381
|
+
const api = window.__REGEN_MDEDITOR_SMOKE_API;
|
|
382
|
+
api.setMarkdown("alpha **bold** beta *italic* gamma `code`");
|
|
383
|
+
api.setSelection(23, 23);
|
|
384
|
+
return "cursor placed in italic";
|
|
385
|
+
})()
|
|
386
|
+
""");
|
|
387
|
+
await Task.Delay(150);
|
|
388
|
+
await AssertControlStepAsync("markdown toolbar detects italic", """
|
|
389
|
+
(() => {
|
|
390
|
+
const toolbar = document.querySelector(".markdown-toolbar");
|
|
391
|
+
const italic = [...toolbar.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === "I");
|
|
392
|
+
if (!italic.classList.contains("active")) throw new Error("Italic button did not detect cursor inside italic Markdown.");
|
|
393
|
+
italic.click();
|
|
394
|
+
return "italic active and clicked";
|
|
395
|
+
})()
|
|
396
|
+
""");
|
|
397
|
+
await Task.Delay(250);
|
|
398
|
+
await AssertControlStepAsync("markdown toolbar toggles italic off", """
|
|
399
|
+
(() => {
|
|
400
|
+
const afterItalic = window.__REGEN_MDEDITOR_SMOKE_API.getMarkdown();
|
|
401
|
+
if (afterItalic.includes("*italic*") || afterItalic.includes("_italic_")) throw new Error(`Italic button added syntax instead of toggling it off: ${afterItalic}`);
|
|
402
|
+
return "italic toggle passed";
|
|
403
|
+
})()
|
|
404
|
+
""");
|
|
405
|
+
await AssertControlStepAsync("markdown toolbar place cursor in code", """
|
|
406
|
+
(() => {
|
|
407
|
+
const api = window.__REGEN_MDEDITOR_SMOKE_API;
|
|
408
|
+
api.setMarkdown("alpha **bold** beta *italic* gamma `code`");
|
|
409
|
+
api.setSelection(37, 37);
|
|
410
|
+
return "cursor placed in code";
|
|
411
|
+
})()
|
|
412
|
+
""");
|
|
413
|
+
await Task.Delay(150);
|
|
414
|
+
await AssertControlStepAsync("markdown toolbar detects code", """
|
|
415
|
+
(() => {
|
|
416
|
+
const toolbar = document.querySelector(".markdown-toolbar");
|
|
417
|
+
const code = [...toolbar.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === "Code");
|
|
418
|
+
if (!code.classList.contains("active")) throw new Error("Code button did not detect cursor inside inline code Markdown.");
|
|
419
|
+
code.click();
|
|
420
|
+
return "code active and clicked";
|
|
421
|
+
})()
|
|
422
|
+
""");
|
|
423
|
+
await Task.Delay(250);
|
|
424
|
+
await AssertControlStepAsync("markdown toolbar toggles code off", """
|
|
425
|
+
(() => {
|
|
426
|
+
const afterCode = window.__REGEN_MDEDITOR_SMOKE_API.getMarkdown();
|
|
427
|
+
if (afterCode.includes("`code`")) throw new Error(`Code button added syntax instead of toggling it off: ${afterCode}`);
|
|
428
|
+
return "code toggle passed";
|
|
429
|
+
})()
|
|
430
|
+
""");
|
|
431
|
+
|
|
432
|
+
await ClickButtonAsync("Save MD");
|
|
433
|
+
await WaitForBodyTextAsync("Saved Markdown ", "Save MD did not update status.");
|
|
434
|
+
|
|
435
|
+
await AssertControlStepAsync("edit markdown for save as", """
|
|
436
|
+
(() => {
|
|
437
|
+
const source = document.querySelector(".cm-editor");
|
|
438
|
+
if (!source) throw new Error("Markdown editor was not visible.");
|
|
439
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API) throw new Error("Smoke editor API was not registered.");
|
|
440
|
+
return window.__REGEN_MDEDITOR_SMOKE_API.appendMarkdown("\n\nUI save-as edit marker");
|
|
441
|
+
})()
|
|
442
|
+
""");
|
|
443
|
+
await Task.Delay(250);
|
|
444
|
+
await AssertControlStepAsync("save-as marker present", """
|
|
445
|
+
(() => {
|
|
446
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API?.getMarkdown().includes("UI save-as edit marker")) throw new Error("Save-as Markdown marker was not present in the editor buffer.");
|
|
447
|
+
return "marker present";
|
|
448
|
+
})()
|
|
449
|
+
""");
|
|
450
|
+
|
|
451
|
+
await ClickButtonAsync("Save MD As...");
|
|
452
|
+
await WaitForBodyTextAsync("Saved Markdown ", "Save MD As did not update status.");
|
|
453
|
+
await WaitForFileContainsAsync(markdownOutput, "UI save-as edit marker", "Save MD As did not write the smoke Markdown output.");
|
|
454
|
+
|
|
455
|
+
if (!largeSmokeDocument)
|
|
456
|
+
{
|
|
457
|
+
var styleFixtureJson = JsonSerializer.Serialize(styleFixtureMarkdown);
|
|
458
|
+
var loadStyleFixtureScript = """
|
|
459
|
+
(() => {
|
|
460
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API) throw new Error("Smoke editor API was not registered.");
|
|
461
|
+
return window.__REGEN_MDEDITOR_SMOKE_API.setMarkdown(
|
|
462
|
+
""" + styleFixtureJson + """
|
|
463
|
+
);
|
|
464
|
+
})()
|
|
465
|
+
""";
|
|
466
|
+
await AssertControlStepAsync("load broad style fixture", loadStyleFixtureScript);
|
|
467
|
+
await Task.Delay(350);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await ClickButtonAsync("Split Screen");
|
|
471
|
+
await Task.Delay(250);
|
|
472
|
+
if (largeSmokeDocument)
|
|
473
|
+
{
|
|
474
|
+
await AssertControlStepAsync("large split preview visible", """
|
|
475
|
+
(() => {
|
|
476
|
+
const preview = document.querySelector(".readonly-preview .rendered-markdown");
|
|
477
|
+
const source = document.querySelector(".cm-editor");
|
|
478
|
+
if (!preview) throw new Error("Large read-only preview was not visible in Split Screen mode.");
|
|
479
|
+
if (!source) throw new Error("Markdown editor was not visible in Split Screen mode.");
|
|
480
|
+
if (!(preview.textContent || "").includes("UI save-as edit marker")) throw new Error("Large preview did not render the edited Markdown marker.");
|
|
481
|
+
return "large split preview visible";
|
|
482
|
+
})()
|
|
483
|
+
""");
|
|
484
|
+
}
|
|
485
|
+
else
|
|
486
|
+
{
|
|
487
|
+
await AssertControlStepAsync("normal split rich editor visible", """
|
|
488
|
+
(() => {
|
|
489
|
+
const rich = document.querySelector(".rich .ProseMirror");
|
|
490
|
+
const source = document.querySelector(".cm-editor");
|
|
491
|
+
if (!rich) throw new Error("Rich editor was not visible in Split Screen mode.");
|
|
492
|
+
if (!source) throw new Error("Markdown editor was not visible in Split Screen mode.");
|
|
493
|
+
return "normal split rich editor visible";
|
|
494
|
+
})()
|
|
495
|
+
""");
|
|
496
|
+
await AssertControlStepAsync("split style fixture renders", """
|
|
497
|
+
(() => {
|
|
498
|
+
const rich = document.querySelector(".rich .ProseMirror");
|
|
499
|
+
if (!rich) throw new Error("Rich editor was not visible in Split Screen mode.");
|
|
500
|
+
const checks = [
|
|
501
|
+
["h1", "Style Fixture H1"],
|
|
502
|
+
["h2", "Style Fixture H2"],
|
|
503
|
+
["h3", "Style Fixture H3"],
|
|
504
|
+
["strong", "bold text"],
|
|
505
|
+
["em", "italic text"],
|
|
506
|
+
["s", "strike text"],
|
|
507
|
+
["code", "inline code"],
|
|
508
|
+
["blockquote", "Fixture quote block"],
|
|
509
|
+
["ul li", "Bullet one"],
|
|
510
|
+
["ol li", "Ordered one"],
|
|
511
|
+
["a[href='https://example.com']", "fixture link"],
|
|
512
|
+
["table", "Bold Cell"],
|
|
513
|
+
["pre code", "style fixture"],
|
|
514
|
+
["img[alt='Fixture image']", ""],
|
|
515
|
+
["hr", ""]
|
|
516
|
+
];
|
|
517
|
+
for (const [selector, text] of checks) {
|
|
518
|
+
const node = rich.querySelector(selector);
|
|
519
|
+
if (!node) throw new Error(`Preview style did not render selector: ${selector}`);
|
|
520
|
+
if (text && !(node.textContent || "").includes(text)) throw new Error(`Preview style selector ${selector} had unexpected text: ${node.textContent}`);
|
|
521
|
+
}
|
|
522
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API.getMarkdown().includes("| Column | Value |")) throw new Error("Style fixture table Markdown was not kept in the source buffer.");
|
|
523
|
+
return "split style fixture renders";
|
|
524
|
+
})()
|
|
525
|
+
""");
|
|
526
|
+
await AssertControlStepAsync("split visual parity", """
|
|
527
|
+
(() => {
|
|
528
|
+
const sourcePane = document.querySelector(".markdown-pane");
|
|
529
|
+
const editor = document.querySelector(".code-source .cm-editor");
|
|
530
|
+
const page = document.querySelector(".page");
|
|
531
|
+
const lineNumber = document.querySelector(".cm-lineNumbers .cm-gutterElement");
|
|
532
|
+
if (!sourcePane || !editor || !page || !lineNumber) throw new Error("Split Screen visual elements were not present.");
|
|
533
|
+
const sourceStyle = getComputedStyle(sourcePane);
|
|
534
|
+
const editorStyle = getComputedStyle(editor);
|
|
535
|
+
const pageStyle = getComputedStyle(page);
|
|
536
|
+
const lineStyle = getComputedStyle(lineNumber);
|
|
537
|
+
if (sourceStyle.backgroundColor !== pageStyle.backgroundColor) throw new Error(`Source and page backgrounds differ: ${sourceStyle.backgroundColor} vs ${pageStyle.backgroundColor}`);
|
|
538
|
+
if (editorStyle.backgroundColor !== pageStyle.backgroundColor) throw new Error(`Code editor and page backgrounds differ: ${editorStyle.backgroundColor} vs ${pageStyle.backgroundColor}`);
|
|
539
|
+
if (parseFloat(lineStyle.fontSize) > 11) throw new Error(`Line number font is too large: ${lineStyle.fontSize}`);
|
|
540
|
+
if (parseFloat(lineStyle.opacity || "1") > 0.65) throw new Error(`Line numbers are too bright: ${lineStyle.opacity}`);
|
|
541
|
+
return "split visual parity";
|
|
542
|
+
})()
|
|
543
|
+
""");
|
|
544
|
+
await AssertControlStepAsync("split preview-to-source scroll sync", """
|
|
545
|
+
(() => {
|
|
546
|
+
const pageArea = document.querySelector(".page-area");
|
|
547
|
+
const sourceScroller = document.querySelector(".cm-scroller");
|
|
548
|
+
if (!pageArea || !sourceScroller) throw new Error("Split Screen scroll containers were not present.");
|
|
549
|
+
if (pageArea.scrollHeight <= pageArea.clientHeight + 200) throw new Error(`Preview was not tall enough to test scroll sync: ${pageArea.scrollHeight}/${pageArea.clientHeight}`);
|
|
550
|
+
if (sourceScroller.scrollHeight <= sourceScroller.clientHeight + 200) throw new Error(`Markdown source was not tall enough to test scroll sync: ${sourceScroller.scrollHeight}/${sourceScroller.clientHeight}`);
|
|
551
|
+
pageArea.scrollTop = Math.floor((pageArea.scrollHeight - pageArea.clientHeight) * 0.72);
|
|
552
|
+
pageArea.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
553
|
+
return "preview scrolled";
|
|
554
|
+
})()
|
|
555
|
+
""");
|
|
556
|
+
await Task.Delay(350);
|
|
557
|
+
await AssertControlStepAsync("compare source follows preview", """
|
|
558
|
+
(() => {
|
|
559
|
+
const sourceScroller = document.querySelector(".cm-scroller");
|
|
560
|
+
if (!sourceScroller || sourceScroller.scrollTop <= 10) throw new Error(`Markdown source did not follow preview scroll: ${sourceScroller?.scrollTop}`);
|
|
561
|
+
sourceScroller.scrollTop = Math.floor((sourceScroller.scrollHeight - sourceScroller.clientHeight) * 0.28);
|
|
562
|
+
sourceScroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
563
|
+
return "source scrolled";
|
|
564
|
+
})()
|
|
565
|
+
""");
|
|
566
|
+
await Task.Delay(350);
|
|
567
|
+
await AssertControlStepAsync("compare preview follows source", """
|
|
568
|
+
(() => {
|
|
569
|
+
const pageArea = document.querySelector(".page-area");
|
|
570
|
+
if (!pageArea || pageArea.scrollTop <= 10) throw new Error(`Preview did not follow source scroll: ${pageArea?.scrollTop}`);
|
|
571
|
+
return "preview follows source";
|
|
572
|
+
})()
|
|
573
|
+
""");
|
|
574
|
+
}
|
|
575
|
+
await ClickButtonAsync("Markdown");
|
|
576
|
+
await Task.Delay(250);
|
|
577
|
+
if (largeSmokeDocument)
|
|
578
|
+
{
|
|
579
|
+
await AssertControlStepAsync("large markdown rendered view visible", """
|
|
580
|
+
(() => {
|
|
581
|
+
const preview = document.querySelector(".readonly-preview .rendered-markdown");
|
|
582
|
+
if (!preview) throw new Error("Large rendered Markdown view was not visible.");
|
|
583
|
+
if (!(preview.textContent || "").includes("UI save-as edit marker")) throw new Error("Large Markdown mode did not render the edited Markdown marker.");
|
|
584
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_API?.getMarkdown().includes("UI save-as edit marker")) throw new Error("Large Markdown mode did not keep the Markdown buffer active.");
|
|
585
|
+
return "large markdown rendered view visible";
|
|
586
|
+
})()
|
|
587
|
+
""");
|
|
588
|
+
}
|
|
589
|
+
else
|
|
590
|
+
{
|
|
591
|
+
await AssertControlStepAsync("normal markdown editable and scrollable", """
|
|
592
|
+
(() => {
|
|
593
|
+
const rich = document.querySelector(".rich .ProseMirror");
|
|
594
|
+
const markdownPane = document.querySelector(".markdown-pane");
|
|
595
|
+
const pageArea = document.querySelector(".page-area");
|
|
596
|
+
if (!rich) throw new Error("Rich editor was not visible in Markdown mode.");
|
|
597
|
+
if (rich.getAttribute("contenteditable") !== "true") throw new Error("Markdown rich editor was not editable.");
|
|
598
|
+
if (markdownPane && getComputedStyle(markdownPane).display !== "none") throw new Error("Raw pane should be hidden in normal Markdown mode.");
|
|
599
|
+
if (!pageArea) throw new Error("Markdown page area was not visible.");
|
|
600
|
+
const marker = "Markdown editable smoke marker";
|
|
601
|
+
const edited = window.__REGEN_MDEDITOR_SMOKE_API.appendRichText(marker);
|
|
602
|
+
if (!edited.includes(marker)) throw new Error("Markdown edit did not update Markdown state.");
|
|
603
|
+
const filler = Array.from({ length: 140 }, (_, index) => `Markdown scroll smoke line ${index + 1}`).join("\\n\\n");
|
|
604
|
+
window.__REGEN_MDEDITOR_SMOKE_API.appendRichText(filler);
|
|
605
|
+
return "markdown edit inserted";
|
|
606
|
+
})()
|
|
607
|
+
""");
|
|
608
|
+
await Task.Delay(500);
|
|
609
|
+
await AssertControlStepAsync("normal markdown scrolls past one page", """
|
|
610
|
+
(() => {
|
|
611
|
+
const pageArea = document.querySelector(".page-area");
|
|
612
|
+
if (!pageArea) throw new Error("Markdown page area was not visible.");
|
|
613
|
+
const before = pageArea.scrollTop;
|
|
614
|
+
pageArea.scrollTop = pageArea.scrollHeight;
|
|
615
|
+
const rich = document.querySelector(".rich .ProseMirror");
|
|
616
|
+
const page = document.querySelector(".page");
|
|
617
|
+
const detail = `pageArea=${pageArea.scrollHeight}/${pageArea.clientHeight}/${pageArea.scrollTop}; page=${page?.scrollHeight}/${page?.clientHeight}; rich=${rich?.scrollHeight}/${rich?.clientHeight}`;
|
|
618
|
+
if (pageArea.scrollHeight <= pageArea.clientHeight + 80) throw new Error(`Markdown page area was not taller than one viewport: ${detail}`);
|
|
619
|
+
if (pageArea.scrollTop <= before) throw new Error(`Markdown page area did not scroll: ${detail}`);
|
|
620
|
+
return "normal markdown scrolls past one page";
|
|
621
|
+
})()
|
|
622
|
+
""");
|
|
623
|
+
}
|
|
624
|
+
await ClickButtonAsync("Raw");
|
|
625
|
+
await Task.Delay(250);
|
|
626
|
+
|
|
627
|
+
if (!largeSmokeDocument)
|
|
628
|
+
{
|
|
629
|
+
await ClickButtonAsync("Export Word...");
|
|
630
|
+
await WaitForBodyTextAsync("Exported Word ", "Export Word did not update status.");
|
|
631
|
+
await WaitForFileExistsAsync(wordOutput, "Export Word did not write the smoke DOCX output.");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
await ClickButtonAsync("Run Check");
|
|
635
|
+
await WaitForBodyTextAsync("Checked ", "Run Check did not update status.");
|
|
636
|
+
|
|
637
|
+
await AssertControlStepAsync("layout still usable", """
|
|
638
|
+
(() => {
|
|
639
|
+
const modeStrip = document.querySelector(".mode-strip");
|
|
640
|
+
if (!modeStrip) throw new Error("Mode strip missing.");
|
|
641
|
+
const canvas = document.querySelector(".canvas-wrap");
|
|
642
|
+
if (!canvas || canvas.getBoundingClientRect().width < 300) {
|
|
643
|
+
throw new Error("Editor canvas did not render with usable width.");
|
|
644
|
+
}
|
|
645
|
+
return [...document.querySelectorAll(".statusbar span")].map((node) => node.textContent).join(" | ");
|
|
646
|
+
})()
|
|
647
|
+
""");
|
|
648
|
+
|
|
649
|
+
if (!File.Exists(markdownOutput) || !File.ReadAllText(markdownOutput).Contains("UI save-as edit marker", StringComparison.Ordinal))
|
|
650
|
+
{
|
|
651
|
+
throw new InvalidOperationException("Save MD As did not write the smoke Markdown output.");
|
|
652
|
+
}
|
|
653
|
+
if (!largeSmokeDocument && (!File.Exists(wordOutput) || new FileInfo(wordOutput).Length == 0))
|
|
654
|
+
{
|
|
655
|
+
throw new InvalidOperationException("Export Word did not write the smoke DOCX output.");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private static async Task WaitForFileContainsAsync(string path, string expected, string failure)
|
|
660
|
+
{
|
|
661
|
+
var deadline = DateTimeOffset.UtcNow.AddSeconds(10);
|
|
662
|
+
while (DateTimeOffset.UtcNow < deadline)
|
|
663
|
+
{
|
|
664
|
+
if (File.Exists(path) && File.ReadAllText(path).Contains(expected, StringComparison.Ordinal)) return;
|
|
665
|
+
await Task.Delay(250);
|
|
666
|
+
}
|
|
667
|
+
throw new InvalidOperationException(failure);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private static async Task WaitForFileExistsAsync(string path, string failure)
|
|
671
|
+
{
|
|
672
|
+
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
|
673
|
+
while (DateTimeOffset.UtcNow < deadline)
|
|
674
|
+
{
|
|
675
|
+
if (File.Exists(path) && new FileInfo(path).Length > 0) return;
|
|
676
|
+
await Task.Delay(250);
|
|
677
|
+
}
|
|
678
|
+
throw new InvalidOperationException(failure);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private async Task ClickButtonAsync(string label)
|
|
682
|
+
{
|
|
683
|
+
await AssertControlStepAsync($"click {label}", $$"""
|
|
684
|
+
(() => {
|
|
685
|
+
const label = {{JsonSerializer.Serialize(label)}};
|
|
686
|
+
const button = [...document.querySelectorAll("button")]
|
|
687
|
+
.find((candidate) => candidate.textContent.trim() === label);
|
|
688
|
+
if (!button) throw new Error(`Missing button: ${label}`);
|
|
689
|
+
if (button.disabled) throw new Error(`Button is disabled: ${label}`);
|
|
690
|
+
setTimeout(() => button.click(), 0);
|
|
691
|
+
return `scheduled click ${label}`;
|
|
692
|
+
})()
|
|
693
|
+
""");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async Task WaitForBodyTextAsync(string expected, string failure)
|
|
697
|
+
{
|
|
698
|
+
var deadline = DateTimeOffset.UtcNow.AddSeconds(8);
|
|
699
|
+
while (DateTimeOffset.UtcNow < deadline)
|
|
700
|
+
{
|
|
701
|
+
var contains = await AssertControlStepAsync($"wait for {expected}", $$"""
|
|
702
|
+
(() => (document.body.innerText || "").includes({{JsonSerializer.Serialize(expected)}}))()
|
|
703
|
+
""");
|
|
704
|
+
if (contains == "true") return;
|
|
705
|
+
await Task.Delay(250);
|
|
706
|
+
}
|
|
707
|
+
throw new InvalidOperationException(failure);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private async Task<string> AssertControlStepAsync(string name, string script)
|
|
711
|
+
{
|
|
712
|
+
try
|
|
713
|
+
{
|
|
714
|
+
var wrapped = $$"""
|
|
715
|
+
(() => {
|
|
716
|
+
try {
|
|
717
|
+
const value = ({{script}});
|
|
718
|
+
return JSON.stringify({ ok: true, value: String(value) });
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return JSON.stringify({ ok: false, error: error && error.message ? error.message : String(error) });
|
|
721
|
+
}
|
|
722
|
+
})()
|
|
723
|
+
""";
|
|
724
|
+
var json = await ExecuteScriptWithTimeoutAsync(wrapped, TimeSpan.FromSeconds(5));
|
|
725
|
+
var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
|
|
726
|
+
var result = JsonSerializer.Deserialize<ControlStepProbe>(inner, jsonOptions);
|
|
727
|
+
if (result is null || !result.Ok)
|
|
728
|
+
{
|
|
729
|
+
throw new InvalidOperationException(result?.Error ?? "script returned no result");
|
|
730
|
+
}
|
|
731
|
+
var value = result.Value;
|
|
732
|
+
LogSmoke($"control {name}: {TrimForLog(value)}");
|
|
733
|
+
return value;
|
|
734
|
+
}
|
|
735
|
+
catch (Exception ex)
|
|
736
|
+
{
|
|
737
|
+
throw new InvalidOperationException($"Control step failed ({name}): {ex.Message}", ex);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private async Task<string> ExecuteScriptWithTimeoutAsync(string script, TimeSpan timeout)
|
|
742
|
+
{
|
|
743
|
+
var task = webView.CoreWebView2.ExecuteScriptAsync(script);
|
|
744
|
+
var completed = await Task.WhenAny(task, Task.Delay(timeout));
|
|
745
|
+
if (completed != task)
|
|
746
|
+
{
|
|
747
|
+
throw new TimeoutException("WebView script probe timed out.");
|
|
748
|
+
}
|
|
749
|
+
return await task;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
|
|
753
|
+
{
|
|
754
|
+
BridgeRequest? request = null;
|
|
755
|
+
try
|
|
756
|
+
{
|
|
757
|
+
request = JsonSerializer.Deserialize<BridgeRequest>(e.WebMessageAsJson, jsonOptions);
|
|
758
|
+
if (request is null) throw new InvalidOperationException("Invalid bridge request.");
|
|
759
|
+
var result = await DispatchAsync(request);
|
|
760
|
+
await RespondAsync(request.Id, true, result, null);
|
|
761
|
+
}
|
|
762
|
+
catch (Exception ex)
|
|
763
|
+
{
|
|
764
|
+
await RespondAsync(request?.Id ?? 0, false, null, ex.Message);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async Task<object?> DispatchAsync(BridgeRequest request)
|
|
769
|
+
{
|
|
770
|
+
var p = request.Params;
|
|
771
|
+
return request.Method switch
|
|
772
|
+
{
|
|
773
|
+
"startup" => bridge.Startup(),
|
|
774
|
+
"composeInfo" => bridge.ComposeInfo(),
|
|
775
|
+
"saveDroppedFile" => bridge.SaveDroppedFile(
|
|
776
|
+
p.GetStringProperty("fileName") ?? "drop.bin",
|
|
777
|
+
p.GetStringProperty("mimeType"),
|
|
778
|
+
p.GetStringProperty("data") ?? ""),
|
|
779
|
+
"sendToPane" => bridge.SendToPane(
|
|
780
|
+
p.GetStringProperty("content") ?? "",
|
|
781
|
+
p.GetStringProperty("paneId")),
|
|
782
|
+
"chooseOpen" => bridge.ChooseOpen(),
|
|
783
|
+
"chooseFolder" => bridge.ChooseFolder(),
|
|
784
|
+
"chooseImage" => await bridge.ChooseImageAsync(),
|
|
785
|
+
"open" => await bridge.OpenAsync(p.GetStringProperty("path")),
|
|
786
|
+
"save" => bridge.Save(p.GetStringProperty("path"), p.GetStringProperty("content") ?? ""),
|
|
787
|
+
"convertBatch" => bridge.ConvertBatch(
|
|
788
|
+
p.GetStringProperty("path"),
|
|
789
|
+
p.GetBooleanProperty("moveSources")),
|
|
790
|
+
"inlineImages" => await bridge.InlineImagesAsync(
|
|
791
|
+
p.GetStringProperty("content") ?? "",
|
|
792
|
+
p.GetStringProperty("basePath")),
|
|
793
|
+
"saveAs" => await bridge.SaveAsAsync(
|
|
794
|
+
p.GetStringProperty("suggestedPath"),
|
|
795
|
+
p.GetStringProperty("format") ?? "markdown",
|
|
796
|
+
p.GetStringProperty("content") ?? ""),
|
|
797
|
+
"saveAsDirect" => await bridge.SaveAsDirectAsync(
|
|
798
|
+
p.GetStringProperty("targetPath"),
|
|
799
|
+
p.GetStringProperty("format") ?? "markdown",
|
|
800
|
+
p.GetStringProperty("content") ?? ""),
|
|
801
|
+
_ => throw new InvalidOperationException($"Unknown bridge method: {request.Method}"),
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async Task RespondAsync(int id, bool ok, object? result, string? error)
|
|
806
|
+
{
|
|
807
|
+
var response = JsonSerializer.Serialize(new { id, ok, result, error }, jsonOptions);
|
|
808
|
+
webView.CoreWebView2.PostWebMessageAsJson(response);
|
|
809
|
+
await Task.CompletedTask;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private void LogSmoke(string message)
|
|
813
|
+
{
|
|
814
|
+
if (!smokeUi) return;
|
|
815
|
+
try
|
|
816
|
+
{
|
|
817
|
+
File.AppendAllText(smokeLogPath, $"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}");
|
|
818
|
+
}
|
|
819
|
+
catch
|
|
820
|
+
{
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private static string TrimForLog(string? text)
|
|
825
|
+
{
|
|
826
|
+
if (string.IsNullOrWhiteSpace(text)) return "";
|
|
827
|
+
var normalized = text.Replace("\r", " ").Replace("\n", " ");
|
|
828
|
+
return normalized.Length > 240 ? normalized[..240] : normalized;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
internal sealed record BridgeRequest(int Id, string Method, JsonElement Params);
|
|
833
|
+
internal sealed record SmokeProbe(string Title, string Text, double RootWidth, double RootHeight, string Error);
|
|
834
|
+
internal sealed record DomPaintProbe(int Count, int Colors, string Text);
|
|
835
|
+
internal sealed record ControlStepProbe(bool Ok, string Value, string Error);
|
|
836
|
+
|
|
837
|
+
internal static class JsonElementExtensions
|
|
838
|
+
{
|
|
839
|
+
public static string? GetStringProperty(this JsonElement element, string name)
|
|
840
|
+
{
|
|
841
|
+
if (element.ValueKind != JsonValueKind.Object) return null;
|
|
842
|
+
return element.TryGetProperty(name, out var property) && property.ValueKind != JsonValueKind.Null
|
|
843
|
+
? property.GetString()
|
|
844
|
+
: null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
public static bool GetBooleanProperty(this JsonElement element, string name)
|
|
848
|
+
{
|
|
849
|
+
if (element.ValueKind != JsonValueKind.Object) return false;
|
|
850
|
+
if (!element.TryGetProperty(name, out var property)) return false;
|
|
851
|
+
return property.ValueKind == JsonValueKind.True || (property.ValueKind == JsonValueKind.String && bool.TryParse(property.GetString(), out var value) && value);
|
|
852
|
+
}
|
|
853
|
+
}
|