regen.mde 0.2.2

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.
Files changed (60) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +295 -0
  3. package/bin/build-corpus-editor.js +81 -0
  4. package/bin/build-corpus.js +41 -0
  5. package/bin/postinstall.js +187 -0
  6. package/bin/regen-mdeditor-install.js +27 -0
  7. package/bin/regen-mdeditor-uninstall.js +19 -0
  8. package/bin/validate-katex.js +93 -0
  9. package/desktop/BuildCorpusEditor/BuildCorpusBridge.cs +270 -0
  10. package/desktop/BuildCorpusEditor/BuildCorpusEditor.csproj +22 -0
  11. package/desktop/BuildCorpusEditor/EditorForm.cs +540 -0
  12. package/desktop/BuildCorpusEditor/Program.cs +81 -0
  13. package/desktop/BuildCorpusEditor/app.manifest +16 -0
  14. package/dist/release/regen.mde-0.2.2-win-x64-setup.exe +0 -0
  15. package/dist/release/regen.mde-0.2.2-win-x64.zip +0 -0
  16. package/dist/windows-editor/BuildCorpusEditor.deps.json +83 -0
  17. package/dist/windows-editor/BuildCorpusEditor.dll +0 -0
  18. package/dist/windows-editor/BuildCorpusEditor.exe +0 -0
  19. package/dist/windows-editor/BuildCorpusEditor.pdb +0 -0
  20. package/dist/windows-editor/BuildCorpusEditor.runtimeconfig.json +19 -0
  21. package/dist/windows-editor/Microsoft.Web.WebView2.Core.dll +0 -0
  22. package/dist/windows-editor/Microsoft.Web.WebView2.Core.xml +6817 -0
  23. package/dist/windows-editor/Microsoft.Web.WebView2.WinForms.dll +0 -0
  24. package/dist/windows-editor/Microsoft.Web.WebView2.WinForms.xml +510 -0
  25. package/dist/windows-editor/Microsoft.Web.WebView2.Wpf.dll +0 -0
  26. package/dist/windows-editor/Microsoft.Web.WebView2.Wpf.xml +1902 -0
  27. package/dist/windows-editor/WebView2Loader.dll +0 -0
  28. package/dist/windows-editor/runtimes/win-x64/native/WebView2Loader.dll +0 -0
  29. package/dist/windows-editor/wwwroot/assets/index-DjJ6xmhy.js +326 -0
  30. package/dist/windows-editor/wwwroot/assets/index-_dwMNNsm.css +1 -0
  31. package/dist/windows-editor/wwwroot/index.html +22 -0
  32. package/editor-web/index.html +21 -0
  33. package/editor-web/src/main.jsx +399 -0
  34. package/editor-web/src/styles.css +602 -0
  35. package/editor-web/vite.config.js +13 -0
  36. package/examples/build-corpus.config.example.json +21 -0
  37. package/installer/install-regen-mde.ps1 +175 -0
  38. package/installer/regen-mde.nsi +81 -0
  39. package/package.json +86 -0
  40. package/pyproject.toml +33 -0
  41. package/requirements.txt +4 -0
  42. package/scripts/build-windows-editor.ps1 +47 -0
  43. package/scripts/package-windows-editor.ps1 +90 -0
  44. package/scripts/run-corpus.ps1 +28 -0
  45. package/scripts/run-editor-implementation-plane.ps1 +203 -0
  46. package/scripts/run-required-tests.ps1 +98 -0
  47. package/scripts/run-smoke.ps1 +28 -0
  48. package/src/build_corpus/__init__.py +3 -0
  49. package/src/build_corpus/docx_exporter.py +798 -0
  50. package/src/build_corpus/exporter.py +1195 -0
  51. package/src/build_corpus/ppt_exporter.py +532 -0
  52. package/src/build_corpus/templates/__init__.py +1 -0
  53. package/src/build_corpus/templates/md-to-word-template.dotx +0 -0
  54. package/src/build_corpus/validate_assets.py +46 -0
  55. package/tools/audit_corpus.py +203 -0
  56. package/tools/collect_microsoft_word_templates.py +228 -0
  57. package/tools/collect_online_docx_corpus.py +272 -0
  58. package/tools/collect_online_pptx_corpus.py +252 -0
  59. package/tools/compare_pptx_inputs_outputs.py +87 -0
  60. package/tools/roundtrip_docx_corpus.py +171 -0
@@ -0,0 +1,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 readonly BuildCorpusBridge bridge;
11
+ private readonly bool smokeUi;
12
+ private readonly bool background;
13
+ private readonly string? initialPath;
14
+ private readonly string smokeLogPath = Path.Combine(Path.GetTempPath(), "regen-mde-smoke.log");
15
+ private readonly string smokeOutPath = Path.Combine(Path.GetTempPath(), "regen-mde-smoke-output");
16
+ private readonly WebView2 webView;
17
+ private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
18
+
19
+ public EditorForm(BuildCorpusBridge bridge, bool smokeUi = false, bool background = false)
20
+ {
21
+ this.bridge = bridge;
22
+ this.smokeUi = smokeUi;
23
+ this.background = background;
24
+ initialPath = bridge.Startup().InitialPath;
25
+ Text = "regen.mde";
26
+ Width = 1280;
27
+ Height = 860;
28
+ StartPosition = FormStartPosition.CenterScreen;
29
+ if (background)
30
+ {
31
+ FormBorderStyle = FormBorderStyle.None;
32
+ ShowInTaskbar = false;
33
+ StartPosition = FormStartPosition.Manual;
34
+ Location = new Point(-32000, -32000);
35
+ Size = new Size(1280, 860);
36
+ Opacity = 0;
37
+ }
38
+ if (smokeUi)
39
+ {
40
+ if (Directory.Exists(smokeOutPath)) Directory.Delete(smokeOutPath, recursive: true);
41
+ Directory.CreateDirectory(smokeOutPath);
42
+ File.WriteAllText(smokeLogPath, $"[{DateTimeOffset.Now:O}] regen.mde smoke started{Environment.NewLine}");
43
+ var watchdog = new System.Windows.Forms.Timer { Interval = 60000 };
44
+ watchdog.Tick += (_, _) =>
45
+ {
46
+ LogSmoke("watchdog timeout");
47
+ Environment.Exit(1);
48
+ };
49
+ watchdog.Start();
50
+ }
51
+
52
+ webView = new WebView2 { Dock = DockStyle.Fill };
53
+ Controls.Add(webView);
54
+ Load += OnLoad;
55
+ }
56
+
57
+ protected override bool ShowWithoutActivation => background;
58
+
59
+ protected override CreateParams CreateParams
60
+ {
61
+ get
62
+ {
63
+ const int wsExToolWindow = 0x00000080;
64
+ const int wsExNoActivate = 0x08000000;
65
+ var cp = base.CreateParams;
66
+ if (background)
67
+ {
68
+ cp.ExStyle |= wsExToolWindow | wsExNoActivate;
69
+ }
70
+ return cp;
71
+ }
72
+ }
73
+
74
+ private async void OnLoad(object? sender, EventArgs e)
75
+ {
76
+ try
77
+ {
78
+ LogSmoke("OnLoad");
79
+ var userDataFolder = Path.Combine(
80
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
81
+ "regen.mde",
82
+ "WebView2UserData");
83
+ Directory.CreateDirectory(userDataFolder);
84
+ var environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder);
85
+ await webView.EnsureCoreWebView2Async(environment);
86
+ webView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
87
+ webView.CoreWebView2.ProcessFailed += (_, args) =>
88
+ {
89
+ LogSmoke($"process failed: {args.ProcessFailedKind}");
90
+ if (smokeUi) Environment.Exit(1);
91
+ };
92
+ if (smokeUi)
93
+ {
94
+ await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
95
+ $"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'; }};");
96
+ webView.CoreWebView2.NavigationCompleted += OnSmokeNavigationCompleted;
97
+ }
98
+ var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
99
+ LogSmoke($"web root: {webRoot}");
100
+ webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
101
+ "build-corpus-editor.local",
102
+ webRoot,
103
+ CoreWebView2HostResourceAccessKind.Allow);
104
+ webView.CoreWebView2.Navigate("https://build-corpus-editor.local/index.html");
105
+ }
106
+ catch (Exception ex)
107
+ {
108
+ LogSmoke($"OnLoad failed: {ex}");
109
+ if (smokeUi) Environment.Exit(1);
110
+ throw;
111
+ }
112
+ }
113
+
114
+ private async void OnSmokeNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
115
+ {
116
+ try
117
+ {
118
+ LogSmoke($"navigation completed: success={e.IsSuccess} status={e.WebErrorStatus}");
119
+ if (!e.IsSuccess) Environment.Exit(1);
120
+ await WaitForRenderedContentAsync();
121
+ LogSmoke("smoke passed");
122
+ Environment.Exit(0);
123
+ }
124
+ catch (Exception ex)
125
+ {
126
+ LogSmoke($"smoke failed: {ex}");
127
+ Environment.Exit(1);
128
+ }
129
+ }
130
+
131
+ private async Task WaitForRenderedContentAsync()
132
+ {
133
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
134
+ var expected = ExpectedSmokeText();
135
+ while (DateTimeOffset.UtcNow < deadline)
136
+ {
137
+ var script = """
138
+ (() => {
139
+ const body = document.body;
140
+ const text = body ? body.innerText : "";
141
+ const root = document.getElementById("root");
142
+ const rect = root ? root.getBoundingClientRect() : { width: 0, height: 0 };
143
+ return JSON.stringify({
144
+ title: document.title,
145
+ text,
146
+ rootWidth: rect.width,
147
+ rootHeight: rect.height,
148
+ error: window.__REGEN_MDEDITOR_ERROR || ""
149
+ });
150
+ })()
151
+ """;
152
+ var json = await ExecuteScriptWithTimeoutAsync(script, TimeSpan.FromSeconds(3));
153
+ var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
154
+ var probe = JsonSerializer.Deserialize<SmokeProbe>(inner, jsonOptions);
155
+ LogSmoke($"probe title='{probe?.Title}' root={probe?.RootWidth}x{probe?.RootHeight} error='{probe?.Error}' text='{TrimForLog(probe?.Text)}'");
156
+ if (!string.IsNullOrWhiteSpace(probe?.Error))
157
+ {
158
+ throw new InvalidOperationException(probe.Error);
159
+ }
160
+ if (
161
+ probe is not null &&
162
+ probe.Title.Contains("regen.mde", StringComparison.OrdinalIgnoreCase) &&
163
+ probe.Text.Contains("regen.mde", StringComparison.OrdinalIgnoreCase) &&
164
+ probe.Text.Contains(expected, StringComparison.OrdinalIgnoreCase) &&
165
+ probe.RootWidth > 800 &&
166
+ probe.RootHeight > 500)
167
+ {
168
+ LogSmoke("render probe passed");
169
+ await AssertNonBlankScreenshotAsync();
170
+ LogSmoke("screenshot probe passed");
171
+ await AssertEditorControlsWorkAsync();
172
+ return;
173
+ }
174
+ await Task.Delay(250);
175
+ }
176
+ throw new TimeoutException("Rendered regen.mde UI did not become visible.");
177
+ }
178
+
179
+ private string ExpectedSmokeText()
180
+ {
181
+ if (string.IsNullOrWhiteSpace(initialPath)) return "Open";
182
+ var name = Path.GetFileNameWithoutExtension(initialPath);
183
+ return string.IsNullOrWhiteSpace(name) ? "Open" : name;
184
+ }
185
+
186
+ private async Task AssertNonBlankScreenshotAsync()
187
+ {
188
+ await using var stream = new MemoryStream();
189
+ LogSmoke("capturing screenshot");
190
+ var capture = webView.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, stream);
191
+ var completed = await Task.WhenAny(capture, Task.Delay(TimeSpan.FromSeconds(5)));
192
+ if (completed != capture)
193
+ {
194
+ LogSmoke("WebView screenshot capture timed out; trying DrawToBitmap fallback");
195
+ try
196
+ {
197
+ using var fallback = new Bitmap(Math.Max(1, webView.Width), Math.Max(1, webView.Height));
198
+ webView.DrawToBitmap(fallback, new Rectangle(0, 0, fallback.Width, fallback.Height));
199
+ AssertBitmapNonBlank(fallback);
200
+ }
201
+ catch (Exception ex)
202
+ {
203
+ LogSmoke($"DrawToBitmap fallback unavailable: {ex.Message}");
204
+ await AssertVisibleDomPaintAsync();
205
+ }
206
+ return;
207
+ }
208
+ await capture;
209
+ if (stream.Length < 4096) throw new InvalidOperationException("WebView screenshot was too small.");
210
+ stream.Position = 0;
211
+ using var bitmap = new Bitmap(stream);
212
+ AssertBitmapNonBlank(bitmap);
213
+ }
214
+
215
+ private static void AssertBitmapNonBlank(Bitmap bitmap)
216
+ {
217
+ var first = bitmap.GetPixel(0, 0).ToArgb();
218
+ var different = 0;
219
+ for (var x = 0; x < bitmap.Width; x += Math.Max(1, bitmap.Width / 12))
220
+ {
221
+ for (var y = 0; y < bitmap.Height; y += Math.Max(1, bitmap.Height / 12))
222
+ {
223
+ if (bitmap.GetPixel(x, y).ToArgb() != first) different++;
224
+ }
225
+ }
226
+ if (different < 5) throw new InvalidOperationException("WebView screenshot appears blank.");
227
+ }
228
+
229
+ private async Task AssertVisibleDomPaintAsync()
230
+ {
231
+ var script = """
232
+ (() => {
233
+ const visible = [...document.querySelectorAll("main, header, aside, section, article, button, textarea, .page, .canvas-wrap")]
234
+ .map((element) => {
235
+ const style = getComputedStyle(element);
236
+ const rect = element.getBoundingClientRect();
237
+ return {
238
+ tag: element.tagName,
239
+ text: (element.textContent || "").trim().slice(0, 80),
240
+ width: rect.width,
241
+ height: rect.height,
242
+ display: style.display,
243
+ visibility: style.visibility,
244
+ opacity: Number(style.opacity),
245
+ bg: style.backgroundColor,
246
+ color: style.color
247
+ };
248
+ })
249
+ .filter((item) =>
250
+ item.width > 8 &&
251
+ item.height > 8 &&
252
+ item.display !== "none" &&
253
+ item.visibility !== "hidden" &&
254
+ item.opacity > 0.05
255
+ );
256
+ const colors = new Set(visible.flatMap((item) => [item.bg, item.color]).filter(Boolean));
257
+ return JSON.stringify({
258
+ count: visible.length,
259
+ colors: colors.size,
260
+ text: document.body.innerText || ""
261
+ });
262
+ })()
263
+ """;
264
+ var json = await ExecuteScriptWithTimeoutAsync(script, TimeSpan.FromSeconds(3));
265
+ var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
266
+ var probe = JsonSerializer.Deserialize<DomPaintProbe>(inner, jsonOptions);
267
+ LogSmoke($"dom paint count={probe?.Count} colors={probe?.Colors}");
268
+ if (probe is null || probe.Count < 16 || probe.Colors < 4 || !probe.Text.Contains("regen.mde", StringComparison.OrdinalIgnoreCase))
269
+ {
270
+ throw new InvalidOperationException("DOM paint probe suggests the UI is blank or hidden.");
271
+ }
272
+ }
273
+
274
+ private async Task AssertEditorControlsWorkAsync()
275
+ {
276
+ var markdownOutput = Path.Combine(smokeOutPath, "ui-save-as-markdown.md");
277
+ var wordOutput = Path.Combine(smokeOutPath, "ui-save-as-word.docx");
278
+
279
+ await AssertControlStepAsync("required controls", """
280
+ (() => {
281
+ const requiredLabels = ["Open", "Save MD", "Save MD As...", "Export Word...", "Run Check", "Preview", "Compare", "Markdown", "Undo", "Redo", "Link", "Table", "Image"];
282
+ const text = document.body.innerText || "";
283
+ for (const label of requiredLabels) {
284
+ if (!text.includes(label)) throw new Error(`Missing control label: ${label}`);
285
+ }
286
+ return "controls present";
287
+ })()
288
+ """);
289
+
290
+ await ClickButtonAsync("Markdown");
291
+ await Task.Delay(250);
292
+ await AssertControlStepAsync("edit markdown", """
293
+ (() => {
294
+ const textarea = document.querySelector("textarea.source");
295
+ if (!textarea) throw new Error("Markdown textarea was not visible.");
296
+ if (!window.__REGEN_MDEDITOR_SMOKE_API) throw new Error("Smoke editor API was not registered.");
297
+ return window.__REGEN_MDEDITOR_SMOKE_API.appendMarkdown("\n\nUI functional edit marker");
298
+ })()
299
+ """);
300
+ await Task.Delay(250);
301
+ await AssertControlStepAsync("markdown marker present", """
302
+ (() => {
303
+ const textarea = document.querySelector("textarea.source");
304
+ if (!textarea || !textarea.value.includes("UI functional edit marker")) throw new Error("Edited Markdown marker was not visible in the textarea.");
305
+ return "marker present";
306
+ })()
307
+ """);
308
+
309
+ await ClickButtonAsync("Save MD");
310
+ await WaitForBodyTextAsync("Saved Markdown ", "Save MD did not update status.");
311
+
312
+ await AssertControlStepAsync("edit markdown for save as", """
313
+ (() => {
314
+ const textarea = document.querySelector("textarea.source");
315
+ if (!textarea) throw new Error("Markdown textarea was not visible.");
316
+ if (!window.__REGEN_MDEDITOR_SMOKE_API) throw new Error("Smoke editor API was not registered.");
317
+ return window.__REGEN_MDEDITOR_SMOKE_API.appendMarkdown("\n\nUI save-as edit marker");
318
+ })()
319
+ """);
320
+ await Task.Delay(250);
321
+ await AssertControlStepAsync("save-as marker present", """
322
+ (() => {
323
+ const textarea = document.querySelector("textarea.source");
324
+ if (!textarea || !textarea.value.includes("UI save-as edit marker")) throw new Error("Save-as Markdown marker was not visible in the textarea.");
325
+ return "marker present";
326
+ })()
327
+ """);
328
+
329
+ await ClickButtonAsync("Save MD As...");
330
+ await WaitForBodyTextAsync("Saved Markdown ", "Save MD As did not update status.");
331
+ await WaitForFileContainsAsync(markdownOutput, "UI save-as edit marker", "Save MD As did not write the smoke Markdown output.");
332
+
333
+ await ClickButtonAsync("Export Word...");
334
+ await WaitForBodyTextAsync("Exported Word ", "Export Word did not update status.");
335
+ await WaitForFileExistsAsync(wordOutput, "Export Word did not write the smoke DOCX output.");
336
+
337
+ await ClickButtonAsync("Compare");
338
+ await Task.Delay(250);
339
+ await ClickButtonAsync("Preview");
340
+ await Task.Delay(250);
341
+ await ClickButtonAsync("Markdown");
342
+ await Task.Delay(250);
343
+ await ClickButtonAsync("Run Check");
344
+ await WaitForBodyTextAsync("Checked ", "Run Check did not update status.");
345
+
346
+ await AssertControlStepAsync("layout still usable", """
347
+ (() => {
348
+ const modeStrip = document.querySelector(".mode-strip");
349
+ if (!modeStrip) throw new Error("Mode strip missing.");
350
+ const canvas = document.querySelector(".canvas-wrap");
351
+ if (!canvas || canvas.getBoundingClientRect().width < 300) {
352
+ throw new Error("Editor canvas did not render with usable width.");
353
+ }
354
+ return [...document.querySelectorAll(".statusbar span")].map((node) => node.textContent).join(" | ");
355
+ })()
356
+ """);
357
+
358
+ if (!File.Exists(markdownOutput) || !File.ReadAllText(markdownOutput).Contains("UI save-as edit marker", StringComparison.Ordinal))
359
+ {
360
+ throw new InvalidOperationException("Save MD As did not write the smoke Markdown output.");
361
+ }
362
+ if (!File.Exists(wordOutput) || new FileInfo(wordOutput).Length == 0)
363
+ {
364
+ throw new InvalidOperationException("Export Word did not write the smoke DOCX output.");
365
+ }
366
+ }
367
+
368
+ private static async Task WaitForFileContainsAsync(string path, string expected, string failure)
369
+ {
370
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(10);
371
+ while (DateTimeOffset.UtcNow < deadline)
372
+ {
373
+ if (File.Exists(path) && File.ReadAllText(path).Contains(expected, StringComparison.Ordinal)) return;
374
+ await Task.Delay(250);
375
+ }
376
+ throw new InvalidOperationException(failure);
377
+ }
378
+
379
+ private static async Task WaitForFileExistsAsync(string path, string failure)
380
+ {
381
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
382
+ while (DateTimeOffset.UtcNow < deadline)
383
+ {
384
+ if (File.Exists(path) && new FileInfo(path).Length > 0) return;
385
+ await Task.Delay(250);
386
+ }
387
+ throw new InvalidOperationException(failure);
388
+ }
389
+
390
+ private async Task ClickButtonAsync(string label)
391
+ {
392
+ await AssertControlStepAsync($"click {label}", $$"""
393
+ (() => {
394
+ const label = {{JsonSerializer.Serialize(label)}};
395
+ const button = [...document.querySelectorAll("button")]
396
+ .find((candidate) => candidate.textContent.trim() === label);
397
+ if (!button) throw new Error(`Missing button: ${label}`);
398
+ if (button.disabled) throw new Error(`Button is disabled: ${label}`);
399
+ setTimeout(() => button.click(), 0);
400
+ return `scheduled click ${label}`;
401
+ })()
402
+ """);
403
+ }
404
+
405
+ private async Task WaitForBodyTextAsync(string expected, string failure)
406
+ {
407
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(8);
408
+ while (DateTimeOffset.UtcNow < deadline)
409
+ {
410
+ var contains = await AssertControlStepAsync($"wait for {expected}", $$"""
411
+ (() => (document.body.innerText || "").includes({{JsonSerializer.Serialize(expected)}}))()
412
+ """);
413
+ if (contains == "true") return;
414
+ await Task.Delay(250);
415
+ }
416
+ throw new InvalidOperationException(failure);
417
+ }
418
+
419
+ private async Task<string> AssertControlStepAsync(string name, string script)
420
+ {
421
+ try
422
+ {
423
+ var wrapped = $$"""
424
+ (() => {
425
+ try {
426
+ const value = ({{script}});
427
+ return JSON.stringify({ ok: true, value: String(value) });
428
+ } catch (error) {
429
+ return JSON.stringify({ ok: false, error: error && error.message ? error.message : String(error) });
430
+ }
431
+ })()
432
+ """;
433
+ var json = await ExecuteScriptWithTimeoutAsync(wrapped, TimeSpan.FromSeconds(5));
434
+ var inner = JsonSerializer.Deserialize<string>(json) ?? "{}";
435
+ var result = JsonSerializer.Deserialize<ControlStepProbe>(inner, jsonOptions);
436
+ if (result is null || !result.Ok)
437
+ {
438
+ throw new InvalidOperationException(result?.Error ?? "script returned no result");
439
+ }
440
+ var value = result.Value;
441
+ LogSmoke($"control {name}: {TrimForLog(value)}");
442
+ return value;
443
+ }
444
+ catch (Exception ex)
445
+ {
446
+ throw new InvalidOperationException($"Control step failed ({name}): {ex.Message}", ex);
447
+ }
448
+ }
449
+
450
+ private async Task<string> ExecuteScriptWithTimeoutAsync(string script, TimeSpan timeout)
451
+ {
452
+ var task = webView.CoreWebView2.ExecuteScriptAsync(script);
453
+ var completed = await Task.WhenAny(task, Task.Delay(timeout));
454
+ if (completed != task)
455
+ {
456
+ throw new TimeoutException("WebView script probe timed out.");
457
+ }
458
+ return await task;
459
+ }
460
+
461
+ private async void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
462
+ {
463
+ BridgeRequest? request = null;
464
+ try
465
+ {
466
+ request = JsonSerializer.Deserialize<BridgeRequest>(e.WebMessageAsJson, jsonOptions);
467
+ if (request is null) throw new InvalidOperationException("Invalid bridge request.");
468
+ var result = await DispatchAsync(request);
469
+ await RespondAsync(request.Id, true, result, null);
470
+ }
471
+ catch (Exception ex)
472
+ {
473
+ await RespondAsync(request?.Id ?? 0, false, null, ex.Message);
474
+ }
475
+ }
476
+
477
+ private async Task<object?> DispatchAsync(BridgeRequest request)
478
+ {
479
+ var p = request.Params;
480
+ return request.Method switch
481
+ {
482
+ "startup" => bridge.Startup(),
483
+ "chooseOpen" => bridge.ChooseOpen(),
484
+ "chooseImage" => await bridge.ChooseImageAsync(),
485
+ "open" => await bridge.OpenAsync(p.GetStringProperty("path")),
486
+ "save" => bridge.Save(p.GetStringProperty("path"), p.GetStringProperty("content") ?? ""),
487
+ "saveAs" => await bridge.SaveAsAsync(
488
+ p.GetStringProperty("suggestedPath"),
489
+ p.GetStringProperty("format") ?? "markdown",
490
+ p.GetStringProperty("content") ?? ""),
491
+ "saveAsDirect" => await bridge.SaveAsDirectAsync(
492
+ p.GetStringProperty("targetPath"),
493
+ p.GetStringProperty("format") ?? "markdown",
494
+ p.GetStringProperty("content") ?? ""),
495
+ _ => throw new InvalidOperationException($"Unknown bridge method: {request.Method}"),
496
+ };
497
+ }
498
+
499
+ private async Task RespondAsync(int id, bool ok, object? result, string? error)
500
+ {
501
+ var response = JsonSerializer.Serialize(new { id, ok, result, error }, jsonOptions);
502
+ webView.CoreWebView2.PostWebMessageAsJson(response);
503
+ await Task.CompletedTask;
504
+ }
505
+
506
+ private void LogSmoke(string message)
507
+ {
508
+ if (!smokeUi) return;
509
+ try
510
+ {
511
+ File.AppendAllText(smokeLogPath, $"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}");
512
+ }
513
+ catch
514
+ {
515
+ }
516
+ }
517
+
518
+ private static string TrimForLog(string? text)
519
+ {
520
+ if (string.IsNullOrWhiteSpace(text)) return "";
521
+ var normalized = text.Replace("\r", " ").Replace("\n", " ");
522
+ return normalized.Length > 240 ? normalized[..240] : normalized;
523
+ }
524
+ }
525
+
526
+ internal sealed record BridgeRequest(int Id, string Method, JsonElement Params);
527
+ internal sealed record SmokeProbe(string Title, string Text, double RootWidth, double RootHeight, string Error);
528
+ internal sealed record DomPaintProbe(int Count, int Colors, string Text);
529
+ internal sealed record ControlStepProbe(bool Ok, string Value, string Error);
530
+
531
+ internal static class JsonElementExtensions
532
+ {
533
+ public static string? GetStringProperty(this JsonElement element, string name)
534
+ {
535
+ if (element.ValueKind != JsonValueKind.Object) return null;
536
+ return element.TryGetProperty(name, out var property) && property.ValueKind != JsonValueKind.Null
537
+ ? property.GetString()
538
+ : null;
539
+ }
540
+ }
@@ -0,0 +1,81 @@
1
+ using BuildCorpusEditor;
2
+
3
+ internal static class Program
4
+ {
5
+ [STAThread]
6
+ private static void Main(string[] args)
7
+ {
8
+ ApplicationConfiguration.Initialize();
9
+ Application.SetHighDpiMode(HighDpiMode.SystemAware);
10
+
11
+ var initialPath = args.FirstOrDefault(arg => !arg.StartsWith("--", StringComparison.Ordinal));
12
+ var selfTest = args.Any(arg => arg.Equals("--self-test", StringComparison.OrdinalIgnoreCase));
13
+ var documentSelfTest = args.Any(arg => arg.Equals("--document-self-test", StringComparison.OrdinalIgnoreCase));
14
+ var smokeUi = args.Any(arg => arg.Equals("--smoke-ui", StringComparison.OrdinalIgnoreCase));
15
+ var background = smokeUi || args.Any(arg => arg.Equals("--background", StringComparison.OrdinalIgnoreCase));
16
+ var outputDir = GetOptionValue(args, "--out");
17
+
18
+ if (selfTest)
19
+ {
20
+ Environment.Exit(RunBridgeSelfTest(initialPath));
21
+ return;
22
+ }
23
+
24
+ if (documentSelfTest)
25
+ {
26
+ Environment.Exit(RunDocumentSelfTest(initialPath, outputDir));
27
+ return;
28
+ }
29
+
30
+ var bridge = new BuildCorpusBridge(initialPath);
31
+ using var form = new EditorForm(bridge, smokeUi, background);
32
+ Application.Run(form);
33
+ }
34
+
35
+ private static int RunBridgeSelfTest(string? initialPath)
36
+ {
37
+ try
38
+ {
39
+ var bridge = new BuildCorpusBridge(initialPath);
40
+ var startup = bridge.Startup();
41
+ if (!string.IsNullOrWhiteSpace(startup.InitialPath))
42
+ {
43
+ var opened = bridge.OpenAsync(startup.InitialPath).GetAwaiter().GetResult();
44
+ if (string.IsNullOrWhiteSpace(opened.Content))
45
+ {
46
+ throw new InvalidOperationException("Self-test opened an empty document.");
47
+ }
48
+ }
49
+ return 0;
50
+ }
51
+ catch
52
+ {
53
+ return 1;
54
+ }
55
+ }
56
+
57
+ private static int RunDocumentSelfTest(string? initialPath, string? outputDir)
58
+ {
59
+ try
60
+ {
61
+ var bridge = new BuildCorpusBridge(initialPath);
62
+ var result = bridge.RunDocumentSelfTestAsync(initialPath, outputDir).GetAwaiter().GetResult();
63
+ Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result));
64
+ return 0;
65
+ }
66
+ catch (Exception ex)
67
+ {
68
+ Console.Error.WriteLine(ex.Message);
69
+ return 1;
70
+ }
71
+ }
72
+
73
+ private static string? GetOptionValue(string[] args, string name)
74
+ {
75
+ for (var i = 0; i < args.Length - 1; i++)
76
+ {
77
+ if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase)) return args[i + 1];
78
+ }
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
3
+ <assemblyIdentity version="1.0.0.0" name="LifeAI.BuildCorpusEditor.app"/>
4
+ <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
5
+ <security>
6
+ <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
7
+ <requestedExecutionLevel level="asInvoker" uiAccess="false" />
8
+ </requestedPrivileges>
9
+ </security>
10
+ </trustInfo>
11
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
12
+ <application>
13
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
14
+ </application>
15
+ </compatibility>
16
+ </assembly>