-
-
Notifications
You must be signed in to change notification settings - Fork 195
Expand file tree
/
Copy pathmd-editor-integ-test.js
More file actions
2068 lines (1726 loc) · 96.1 KB
/
Copy pathmd-editor-integ-test.js
File metadata and controls
2068 lines (1726 loc) · 96.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
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
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
127
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
164
165
166
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
334
335
336
337
338
339
340
341
342
343
344
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
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* GNU AGPL-3.0 License
*
* Copyright (c) 2021 - present core.ai . All rights reserved.
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
* for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
*
*/
/*global describe, beforeAll, beforeEach, afterAll, awaitsFor, it, awaitsForDone, expect, awaits*/
define(function (require, exports, module) {
const SpecRunnerUtils = require("spec/SpecRunnerUtils");
const testFolder = SpecRunnerUtils.getTestPath("/spec/LiveDevelopment-MultiBrowser-test-files");
const mdTestFolder = SpecRunnerUtils.getTestPath("/spec/LiveDevelopment-Markdown-test-files");
let testWindow, brackets, CommandManager, Commands, EditorManager, WorkspaceManager,
LiveDevMultiBrowser, NativeApp;
function _getMdPreviewIFrame() {
return testWindow.document.getElementById("panel-md-preview-frame");
}
function _getMdIFrameDoc() {
const mdIFrame = _getMdPreviewIFrame();
return mdIFrame && mdIFrame.contentDocument;
}
function _getMdIFrameWin() {
const mdIFrame = _getMdPreviewIFrame();
return mdIFrame && mdIFrame.contentWindow;
}
function _setMdEditMode(editMode) {
const mdIFrame = _getMdPreviewIFrame();
if (mdIFrame && mdIFrame.contentWindow) {
mdIFrame.contentWindow.postMessage({
type: "MDVIEWR_SET_EDIT_MODE",
editMode: editMode
}, "*");
}
}
async function _enterEditMode() {
_setMdEditMode(true);
// Also set directly via the iframe's window if accessible (no sandbox in tests)
const win = _getMdIFrameWin();
if (win && win.__setEditModeForTest) {
win.__setEditModeForTest(true);
}
await awaitsFor(() => {
const mdDoc = _getMdIFrameDoc();
if (!mdDoc) { return false; }
const content = mdDoc.getElementById("viewer-content");
return content && content.classList.contains("editing");
}, "edit mode to activate");
}
async function _enterReaderMode() {
_setMdEditMode(false);
const win = _getMdIFrameWin();
if (win && win.__setEditModeForTest) {
win.__setEditModeForTest(false);
}
await awaitsFor(() => {
const mdDoc = _getMdIFrameDoc();
if (!mdDoc) { return false; }
const content = mdDoc.getElementById("viewer-content");
return content && !content.classList.contains("editing");
}, "reader mode to activate");
}
async function _focusMdContent() {
const mdDoc = _getMdIFrameDoc();
const content = mdDoc.getElementById("viewer-content");
content.focus();
await awaitsFor(() => mdDoc.activeElement === content || content.contains(mdDoc.activeElement),
"md content to have focus");
}
function _isMac() {
return brackets.platform === "mac";
}
/**
* Dispatch a keyboard event on the md iframe's document (capture phase target).
* Uses Cmd on Mac and Ctrl on other platforms.
* Note: synthetic events are "untrusted" — execCommand won't fire from them.
* For formatting tests, use _execCommandInMdIframe() instead.
*/
function _dispatchKeyInMdIframe(key, options = {}) {
const mdDoc = _getMdIFrameDoc();
if (!mdDoc) {
return;
}
const mac = _isMac();
const event = new KeyboardEvent("keydown", {
key: key,
code: options.code || ("Key" + key.toUpperCase()),
keyCode: options.keyCode || key.toUpperCase().charCodeAt(0),
which: options.keyCode || key.toUpperCase().charCodeAt(0),
ctrlKey: mac ? false : (options.mod !== false),
metaKey: mac ? (options.mod !== false) : false,
shiftKey: !!options.shiftKey,
altKey: !!options.altKey,
bubbles: true,
cancelable: true
});
// Dispatch on document so capture-phase listeners in bridge.js fire
mdDoc.dispatchEvent(event);
}
/**
* Dispatch a keyboard event without modifier keys.
*/
function _dispatchPlainKeyInMdIframe(key, options = {}) {
const mdDoc = _getMdIFrameDoc();
if (!mdDoc) {
return;
}
const event = new KeyboardEvent("keydown", {
key: key,
code: options.code || key,
keyCode: options.keyCode || 0,
which: options.keyCode || 0,
ctrlKey: false,
metaKey: false,
shiftKey: !!options.shiftKey,
altKey: false,
bubbles: true,
cancelable: true
});
mdDoc.dispatchEvent(event);
}
/**
* Execute a formatting command directly in the md iframe's contenteditable.
* Used instead of synthetic keyboard events since browsers reject
* execCommand from untrusted KeyboardEvents.
* Also triggers content sync to CM via the mdviewer's own input handler.
*/
function _execCommandInMdIframe(command, value) {
const mdDoc = _getMdIFrameDoc();
const win = _getMdIFrameWin();
if (mdDoc) {
mdDoc.execCommand(command, false, value || null);
// Trigger content sync via the iframe's own helper (dispatches input
// event from within the iframe context so the editor picks it up)
if (win && win.__triggerContentSync) {
win.__triggerContentSync();
}
}
}
/**
* Select text in the md iframe content.
* @param {string} selector - CSS selector for the element
* @param {number} startOffset - start character offset
* @param {number} endOffset - end character offset
*/
function _selectTextInMdIframe(selector, startOffset, endOffset) {
const mdDoc = _getMdIFrameDoc();
const win = _getMdIFrameWin();
if (!mdDoc || !win) {
return;
}
const el = mdDoc.querySelector(selector);
if (!el || !el.firstChild || el.firstChild.nodeType !== Node.TEXT_NODE) {
return;
}
const textNode = el.firstChild;
const range = mdDoc.createRange();
range.setStart(textNode, Math.min(startOffset, textNode.textContent.length));
range.setEnd(textNode, Math.min(endOffset, textNode.textContent.length));
const sel = win.getSelection();
sel.removeAllRanges();
sel.addRange(range);
// Trigger selectionchange so editor updates toolbar state
mdDoc.dispatchEvent(new Event("selectionchange"));
}
/**
* Wait for the md preview iframe to be fully ready and synced with the given editor.
* Verifies: iframe visible, bridge initialized, content rendered, suppression cleared,
* and the viewer's loaded markdown matches the editor's content.
* @param {Object} editor - The active Editor instance whose content should be synced to the viewer.
*/
async function _waitForMdPreviewReady(editor) {
await awaitsFor(() => {
const mdIFrame = _getMdPreviewIFrame();
if (!mdIFrame || mdIFrame.style.display === "none") { return false; }
if (!mdIFrame.src || !mdIFrame.src.includes("mdViewer")) { return false; }
const win = mdIFrame.contentWindow;
if (!win || typeof win.__setEditModeForTest !== "function") { return false; }
if (win.__isSuppressingContentChange && win.__isSuppressingContentChange()) { return false; }
const content = mdIFrame.contentDocument && mdIFrame.contentDocument.getElementById("viewer-content");
if (!content || content.children.length === 0) { return false; }
const activeEditor = EditorManager.getActiveEditor();
if (!activeEditor) { return false; }
// Verify the viewer has synced with the editor's content.
// Re-read editor content each iteration — content sync from a previous
// test's DOM edit can modify the document asynchronously (debounced postMessage).
const expectedSrc = activeEditor.document.getText();
if (expectedSrc) {
const viewerSrc = win.__getCurrentContent && win.__getCurrentContent();
if (viewerSrc !== expectedSrc) { return false; }
}
return true;
}, "md preview synced with editor content", 5000);
}
describe("livepreview:Markdown Editor", function () {
if (Phoenix.browser.desktop.isFirefox ||
(Phoenix.isTestWindowPlaywright && !Phoenix.browser.desktop.isChromeBased)) {
it("Markdown editor tests are disabled in Firefox/non-Chrome playwright", function () {
// Firefox sandbox prevents service worker access from nested iframes.
// Non-Chrome playwright doesn't spawn virtual server needed for live preview.
});
return;
}
let testFilePath;
beforeAll(async function () {
if (!testWindow) {
const useWindowInsteadOfIframe = Phoenix.browser.desktop.isFirefox;
testWindow = await SpecRunnerUtils.createTestWindowAndRun({
forceReload: false, useWindowInsteadOfIframe
});
brackets = testWindow.brackets;
CommandManager = brackets.test.CommandManager;
Commands = brackets.test.Commands;
EditorManager = brackets.test.EditorManager;
WorkspaceManager = brackets.test.WorkspaceManager;
LiveDevMultiBrowser = brackets.test.LiveDevMultiBrowser;
NativeApp = brackets.test.NativeApp;
await SpecRunnerUtils.loadProjectInTestWindow(testFolder);
await SpecRunnerUtils.deletePathAsync(testFolder + "/.phcode.json", true);
// Ensure live preview panel is open
if (!WorkspaceManager.isPanelVisible("live-preview-panel")) {
await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
}
// Open an HTML file first to start live dev
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"open simple1.html");
LiveDevMultiBrowser.open();
await awaitsFor(() => {
return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
}, "live dev to open", 20000);
// Now open the test markdown file
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["test-shortcuts.md"]),
"open test-shortcuts.md");
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
// Reset iframe doc cache for predictable test state
const win = _getMdIFrameWin();
if (win && win.__resetCacheForTest) {
win.__resetCacheForTest();
}
// Re-open to get fresh render after cache reset
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"open simple1.html to reset");
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["test-shortcuts.md"]),
"reopen test-shortcuts.md");
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
testFilePath = testFolder + "/test-shortcuts.md";
}
}, 30000);
afterAll(async function () {
// Final cleanup for the entire Markdown Editor test suite
if (LiveDevMultiBrowser) {
LiveDevMultiBrowser.close();
}
if (CommandManager) {
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
"final close all files");
}
testWindow = null;
brackets = null;
CommandManager = null;
Commands = null;
EditorManager = null;
WorkspaceManager = null;
LiveDevMultiBrowser = null;
}, 30000);
const ORIGINAL_MD_CONTENT = "# Test Shortcuts\n\nThis is a test paragraph for keyboard shortcut testing.\n\n" +
"## Section Two\n\nAnother paragraph with some text to select and format.\n\n" +
"### Section Three\n\nFinal paragraph for testing.\n";
async function _resetFileContent() {
const editor = EditorManager.getActiveEditor();
if (editor && editor.document) {
editor.document.setText(ORIGINAL_MD_CONTENT);
await awaitsForDone(CommandManager.execute(Commands.FILE_SAVE), "save after reset");
await awaitsFor(() => !editor.document.isDirty, "document to be clean after reset save");
await awaitsFor(() => {
const mdDoc = _getMdIFrameDoc();
const content = mdDoc && mdDoc.getElementById("viewer-content");
return content && content.querySelector("h1") &&
content.querySelector("h1").textContent.includes("Test Shortcuts");
}, "viewer to sync with reset content");
}
}
describe("Keyboard Shortcut Forwarding", function () {
function _listenForShortcut(key) {
let received = false;
const mdIFrame = _getMdPreviewIFrame();
const parentWin = mdIFrame.contentWindow.parent;
const handler = function (event) {
if (event.data && event.data.type === "MDVIEWR_EVENT" &&
event.data.eventName === "mdviewrKeyboardShortcut" &&
event.data.key === key) {
received = true;
}
};
parentWin.addEventListener("message", handler);
return {
check: () => received,
cleanup: () => parentWin.removeEventListener("message", handler)
};
}
it("should Ctrl+S in edit mode trigger Phoenix save", async function () {
await _enterEditMode();
await _focusMdContent();
// Make a small edit in CM to dirty the document
const editor = EditorManager.getActiveEditor();
editor.replaceRange(" ", { line: 0, ch: 0 });
await awaitsFor(() => editor.document.isDirty, "document to become dirty");
// Dispatch Ctrl+S in the md iframe — should trigger save
_dispatchKeyInMdIframe("s");
await awaitsFor(() => !editor.document.isDirty, "document to be saved (dirty flag cleared)");
}, 10000);
it("should Ctrl+Shift+F in edit mode open Find in Files", async function () {
await _enterEditMode();
await _focusMdContent();
const listener = _listenForShortcut("f");
_dispatchKeyInMdIframe("f", { shiftKey: true });
await awaitsFor(() => listener.check(), "Ctrl+Shift+F shortcut to be forwarded");
listener.cleanup();
// Dismiss the Find in Files bar if it opened
await awaitsFor(() => {
return testWindow.$(".modal-bar").is(":visible") ||
testWindow.$("#search-result-container").is(":visible");
}, "search bar to appear", 3000).catch(() => {});
if (testWindow.$(".modal-bar").is(":visible")) {
testWindow.$(".modal-bar .close").click();
}
}, 10000);
it("should Ctrl+B in edit mode apply bold", async function () {
await _resetFileContent();
await _enterEditMode();
await _focusMdContent();
await awaitsFor(() => {
const win = _getMdIFrameWin();
return win && !win.__isSuppressingContentChange();
}, "suppression to clear");
_selectTextInMdIframe("#viewer-content p", 0, 4);
_execCommandInMdIframe("bold");
await awaitsFor(() => {
const content = _getMdIFrameDoc().getElementById("viewer-content");
return content.querySelector("b, strong") !== null;
}, "bold to be applied in viewer");
}, 10000);
it("should Ctrl+I in edit mode apply italic", async function () {
await _resetFileContent();
await _enterEditMode();
await _focusMdContent();
await awaitsFor(() => {
const win = _getMdIFrameWin();
return win && !win.__isSuppressingContentChange();
}, "suppression to clear");
_selectTextInMdIframe("#viewer-content h2", 0, 4);
_execCommandInMdIframe("italic");
await awaitsFor(() => {
const content = _getMdIFrameDoc().getElementById("viewer-content");
return content.querySelector("i, em") !== null;
}, "italic to be applied in viewer");
}, 10000);
it("should Ctrl+U in edit mode apply underline", async function () {
await _resetFileContent();
await _enterEditMode();
await _focusMdContent();
await awaitsFor(() => {
const win = _getMdIFrameWin();
return win && !win.__isSuppressingContentChange();
}, "suppression to clear");
_selectTextInMdIframe("#viewer-content h3", 0, 4);
_execCommandInMdIframe("underline");
await awaitsFor(() => {
const content = _getMdIFrameDoc().getElementById("viewer-content");
return content.querySelector("u") !== null;
}, "underline to be applied in viewer");
}, 10000);
// Bold button disabled in headings is verified manually — the test
// infrastructure has timing issues with selectionchange + rAF in
// cross-iframe context. See updateFormatState in embedded-toolbar.js
// and onSelectionState in format-bar.js for the implementation.
it("should Ctrl+Z in edit mode forward undo to Phoenix", async function () {
await _enterEditMode();
await _focusMdContent();
let undoReceived = false;
const mdIFrame = _getMdPreviewIFrame();
const parentWin = mdIFrame.contentWindow.parent;
const handler = function (event) {
if (event.data && event.data.type === "MDVIEWR_EVENT" &&
event.data.eventName === "mdviewrUndo") {
undoReceived = true;
}
};
parentWin.addEventListener("message", handler);
_dispatchKeyInMdIframe("z");
await awaitsFor(() => undoReceived, "Ctrl+Z undo to be forwarded");
parentWin.removeEventListener("message", handler);
}, 10000);
it("should Ctrl+Y in edit mode forward redo to Phoenix", async function () {
await _enterEditMode();
await _focusMdContent();
let redoReceived = false;
const mdIFrame = _getMdPreviewIFrame();
const parentWin = mdIFrame.contentWindow.parent;
const handler = function (event) {
if (event.data && event.data.type === "MDVIEWR_EVENT" &&
event.data.eventName === "mdviewrRedo") {
redoReceived = true;
}
};
parentWin.addEventListener("message", handler);
_dispatchKeyInMdIframe("y");
await awaitsFor(() => redoReceived, "Ctrl+Y redo to be forwarded");
parentWin.removeEventListener("message", handler);
}, 10000);
it("should Ctrl+Shift+Z in edit mode forward redo to Phoenix", async function () {
await _enterEditMode();
await _focusMdContent();
let redoReceived = false;
const mdIFrame = _getMdPreviewIFrame();
const parentWin = mdIFrame.contentWindow.parent;
const handler = function (event) {
if (event.data && event.data.type === "MDVIEWR_EVENT" &&
event.data.eventName === "mdviewrRedo") {
redoReceived = true;
}
};
parentWin.addEventListener("message", handler);
_dispatchKeyInMdIframe("z", { shiftKey: true });
await awaitsFor(() => redoReceived, "Ctrl+Shift+Z redo to be forwarded");
parentWin.removeEventListener("message", handler);
}, 10000);
it("should Ctrl+A in edit mode select all text natively", async function () {
await _enterEditMode();
await _focusMdContent();
_execCommandInMdIframe("selectAll");
await awaitsFor(() => {
const sel = _getMdIFrameWin().getSelection();
return sel.toString().length > 0;
}, "text to be selected");
}, 10000);
it("should Escape in edit mode send focus back to Phoenix editor", async function () {
await _enterEditMode();
await _focusMdContent();
_dispatchPlainKeyInMdIframe("Escape", { keyCode: 27, code: "Escape" });
await awaitsFor(() => {
const activeEl = testWindow.document.activeElement;
const editorHolder = testWindow.document.getElementById("editor-holder");
return editorHolder && editorHolder.contains(activeEl);
}, "editor to regain focus");
}, 10000);
it("should F-key shortcuts work in edit mode", async function () {
await _enterEditMode();
await _focusMdContent();
const listener = _listenForShortcut("F8");
_dispatchPlainKeyInMdIframe("F8", { keyCode: 119, code: "F8" });
await awaitsFor(() => listener.check(), "F8 shortcut to be forwarded in edit mode");
listener.cleanup();
}, 10000);
it("should F-key shortcuts work in reader mode", async function () {
await _enterReaderMode();
const listener = _listenForShortcut("F8");
_dispatchPlainKeyInMdIframe("F8", { keyCode: 119, code: "F8" });
await awaitsFor(() => listener.check(), "F8 shortcut to be forwarded in reader mode");
listener.cleanup();
}, 10000);
it("should Ctrl+Shift+X in edit mode apply strikethrough (not forwarded)", async function () {
await _enterEditMode();
_selectTextInMdIframe("#viewer-content p", 0, 4);
_execCommandInMdIframe("strikethrough");
await awaitsFor(() => {
const content = _getMdIFrameDoc().getElementById("viewer-content");
return content.querySelector("s, strike, del") !== null;
}, "strikethrough to be applied");
}, 10000);
it("should Ctrl+K in edit mode create a link (not forwarded)", async function () {
await _enterEditMode();
await _focusMdContent();
_selectTextInMdIframe("#viewer-content p", 0, 5);
_execCommandInMdIframe("createLink", "https://test.example.com");
await awaitsFor(() => {
const content = _getMdIFrameDoc().getElementById("viewer-content");
return content.querySelector("a[href='https://test.example.com']") !== null;
}, "link to be created");
}, 10000);
});
describe("Document Cache & File Switching", function () {
async function _switchToMdTestProject() {
await SpecRunnerUtils.loadProjectInTestWindow(mdTestFolder);
await SpecRunnerUtils.deletePathAsync(mdTestFolder + "/.phcode.json", true);
}
async function _openMdFileAndWaitForPreview(fileName) {
await awaitsForDone(SpecRunnerUtils.openProjectFiles([fileName]),
"open " + fileName);
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
}
function _getViewerScrollTop() {
const mdDoc = _getMdIFrameDoc();
const viewer = mdDoc && mdDoc.querySelector(".app-viewer");
return viewer ? viewer.scrollTop : 0;
}
function _setViewerScrollTop(scrollTop) {
const mdDoc = _getMdIFrameDoc();
const viewer = mdDoc && mdDoc.querySelector(".app-viewer");
if (viewer) {
viewer.scrollTop = scrollTop;
}
}
function _getViewerH1Text() {
const mdDoc = _getMdIFrameDoc();
const h1 = mdDoc && mdDoc.querySelector("#viewer-content h1");
return h1 ? h1.textContent : "";
}
async function _assertMdEditMode(shouldBeEditing) {
await awaitsFor(() => {
const mdDoc = _getMdIFrameDoc();
const content = mdDoc && mdDoc.getElementById("viewer-content");
if (!content) { return false; }
return shouldBeEditing
? content.classList.contains("editing")
: !content.classList.contains("editing");
}, shouldBeEditing ? "md viewer to be in edit mode" : "md viewer to be in reader mode");
}
beforeAll(async function () {
// Switch to the md test project for these tests
if (testWindow) {
_setMdEditMode(false);
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
"close all before project switch");
await _switchToMdTestProject();
// Start live dev
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]),
"open simple.html");
if (!WorkspaceManager.isPanelVisible("live-preview-panel")) {
await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
}
LiveDevMultiBrowser.open();
await awaitsFor(() => {
return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
}, "live dev to open", 20000);
}
}, 30000);
beforeEach(async function () {
// Reset scroll and close files between tests to prevent state leakage
_setViewerScrollTop(0);
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
"close all between cache tests");
}, 10000);
it("should switch between MD files with viewer showing correct content", async function () {
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 heading to appear");
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 heading to appear");
// Switch back to doc1 — should show doc1 content
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 heading to appear on switch back");
}, 15000);
// TODO: Scroll restore works in production but the test runner viewport is too
// small for reliable scroll position verification. Re-enable when viewport is larger.
// it("should preserve scroll position per-document on switch", ...)
it("should preserve edit/reader mode globally across file switches", async function () {
await _openMdFileAndWaitForPreview("doc1.md");
await _enterEditMode();
// Switch to another md file — should still be in edit mode
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 heading to appear");
await _assertMdEditMode(true);
// Switch to reader mode
await _enterReaderMode();
await _assertMdEditMode(false);
// Switch to doc1 — should still be in reader mode
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 heading to appear");
await _assertMdEditMode(false);
}, 15000);
it("should switch MD to HTML and back reusing persistent md iframe", async function () {
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 content to load");
// Set a verification code inside the md iframe to prove persistence
const verificationCode = "persist_" + Date.now();
_getMdIFrameWin().__test_verification = verificationCode;
// Switch to HTML file
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]),
"open simple.html");
await awaitsFor(() => {
const lpFrame = testWindow.document.getElementById("panel-live-preview-frame");
return lpFrame && lpFrame.src && !lpFrame.src.includes("mdViewer");
}, "HTML preview to load");
// Switch back to md file
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 content to load after switch back");
// Verify iframe was NOT reloaded — our JS variable should survive
const win = _getMdIFrameWin();
expect(win.__test_verification).toBe(verificationCode);
}, 15000);
it("should preserve edit mode across project switches", async function () {
await _openMdFileAndWaitForPreview("doc1.md");
await _enterEditMode();
// Switch to a different project
const otherProject = SpecRunnerUtils.getTestPath("/spec/LiveDevelopment-MultiBrowser-test-files");
await SpecRunnerUtils.loadProjectInTestWindow(otherProject);
// Open an HTML file and start live dev in the other project
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"open simple1.html in other project");
LiveDevMultiBrowser.open();
await awaitsFor(() => {
return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
}, "live dev active in other project", 20000);
// Now open an md file in the other project
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["readme.md"]),
"open readme.md in other project");
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
// Edit mode should be preserved
await _assertMdEditMode(true);
// Switch back to the md test project
await _switchToMdTestProject();
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]),
"reopen simple.html");
LiveDevMultiBrowser.open();
await awaitsFor(() => {
return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
}, "live dev to reopen", 20000);
}, 30000);
it("should closing and reopening live preview panel preserve md iframe, cache, and scroll", async function () {
await _openMdFileAndWaitForPreview("long.md");
await awaitsFor(() => _getViewerH1Text().includes("Long Document"),
"long doc content to load");
await _enterEditMode();
// Scroll down
_setViewerScrollTop(300);
await awaitsFor(() => _getViewerScrollTop() >= 290, "scroll to apply");
const scrollBefore = _getViewerScrollTop();
// Set verification code to check iframe persists
const verificationCode = "panel_persist_" + Date.now();
_getMdIFrameWin().__test_panel_persist = verificationCode;
// Close live preview panel
await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
await awaitsFor(() => !WorkspaceManager.isPanelVisible("live-preview-panel"),
"live preview panel to close");
// Reopen live preview panel
await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
await awaitsFor(() => WorkspaceManager.isPanelVisible("live-preview-panel"),
"live preview panel to reopen");
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
// Verify iframe persisted (JS variable survived)
const win = _getMdIFrameWin();
expect(win.__test_panel_persist).toBe(verificationCode);
// Verify content is still correct
await awaitsFor(() => _getViewerH1Text().includes("Long Document"),
"long doc content after panel reopen");
// Verify edit mode preserved
await _assertMdEditMode(true);
// Verify scroll position preserved (wider tolerance for CI).
// Wait longer as theme/content sync after reopen can cause transient reflows.
await awaitsFor(() => {
const scroll = _getViewerScrollTop();
return scroll > 10 && Math.abs(scroll - scrollBefore) < 150;
}, "scroll position to be preserved after panel reopen", 8000);
}, 20000);
it("should reload button re-render current file with fresh DOM preserving scroll and edit mode", async function () {
await _openMdFileAndWaitForPreview("long.md");
await awaitsFor(() => _getViewerH1Text().includes("Long Document"),
"long doc to load");
await _enterEditMode();
// Scroll down
_setViewerScrollTop(300);
await awaitsFor(() => _getViewerScrollTop() >= 290, "scroll to apply");
const scrollBefore = _getViewerScrollTop();
// Capture the current h1 DOM node
const h1Before = _getMdIFrameDoc().querySelector("#viewer-content h1");
expect(h1Before).not.toBeNull();
// Click reload button
testWindow.$("#reloadLivePreviewButton").click();
// Wait for re-render — the h1 should be a NEW DOM node (old one disposed)
await awaitsFor(() => {
const h1After = _getMdIFrameDoc().querySelector("#viewer-content h1");
return h1After && h1After !== h1Before &&
h1After.textContent.includes("Long Document");
}, "DOM to be recreated after reload");
// Verify edit mode preserved
await _assertMdEditMode(true);
// Verify scroll position approximately preserved
await awaitsFor(() => {
const scroll = _getViewerScrollTop();
return Math.abs(scroll - scrollBefore) < 100;
}, "scroll position to be approximately restored after reload");
}, 15000);
it("should working set changes sync to iframe and cache entries persist", async function () {
// Open multiple files to populate cache
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 to load");
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 to load");
// Both should be in cache
const win = _getMdIFrameWin();
await awaitsFor(() => {
const keys = win.__getCacheKeys();
return keys.some(k => k.endsWith("doc1.md")) &&
keys.some(k => k.endsWith("doc2.md"));
}, "both doc1 and doc2 to be in cache");
// Close doc2 from working set
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE),
"close doc2");
// doc1 should still be cached and displayable
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 still showing after doc2 closed");
await awaitsFor(() => {
const keys = win.__getCacheKeys();
return keys.some(k => k.endsWith("doc1.md"));
}, "doc1 still in cache after doc2 closed");
}, 15000);
it("should cache multiple files and retrieve them from cache", async function () {
// Open doc1, doc2, doc3 sequentially to populate cache
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 to load");
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 to load");
await _openMdFileAndWaitForPreview("doc3.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Three"),
"doc3 to load");
// All three should be in cache
const win = _getMdIFrameWin();
await awaitsFor(() => {
const keys = win.__getCacheKeys();
return keys.some(k => k.endsWith("doc1.md")) &&
keys.some(k => k.endsWith("doc2.md")) &&
keys.some(k => k.endsWith("doc3.md"));
}, "all three docs to be in cache");
// Switch back to doc1 — should load from cache
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 from cache");
// Switch to doc2 — from cache
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 from cache");
// Verify all still cached
const keys = win.__getCacheKeys();
expect(keys.some(k => k.endsWith("doc1.md"))).toBeTrue();
expect(keys.some(k => k.endsWith("doc2.md"))).toBeTrue();
expect(keys.some(k => k.endsWith("doc3.md"))).toBeTrue();
}, 15000);
it("should files removed from working set move to LRU cache (not evicted)", async function () {
const win = _getMdIFrameWin();
// Open doc1 and doc2 to put them in cache and working set
await _openMdFileAndWaitForPreview("doc1.md");
await awaitsFor(() => _getViewerH1Text().includes("Document One"),
"doc1 to load");
await _openMdFileAndWaitForPreview("doc2.md");
await awaitsFor(() => _getViewerH1Text().includes("Document Two"),
"doc2 to load");
// Both should be in cache and working set
await awaitsFor(() => {
const cacheKeys = win.__getCacheKeys();
const wsPaths = win.__getWorkingSetPaths();
return cacheKeys.some(k => k.endsWith("doc1.md")) &&
cacheKeys.some(k => k.endsWith("doc2.md")) &&
wsPaths.some(p => p.endsWith("doc1.md")) &&
wsPaths.some(p => p.endsWith("doc2.md"));
}, "doc1 and doc2 in cache and working set");
// Close doc2 from working set
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE),
"close doc2");
// doc2 should still be in cache (moved to LRU) but not in working set
await awaitsFor(() => {
const cacheKeys = win.__getCacheKeys();
const wsPaths = win.__getWorkingSetPaths();
return cacheKeys.some(k => k.endsWith("doc2.md")) &&
!wsPaths.some(p => p.endsWith("doc2.md"));
}, "doc2 in cache (LRU) but not in working set");
// doc1 should still be in both cache and working set
const cacheKeys = win.__getCacheKeys();
const wsPaths = win.__getWorkingSetPaths();
expect(cacheKeys.some(k => k.endsWith("doc1.md"))).toBeTrue();
expect(wsPaths.some(p => p.endsWith("doc1.md"))).toBeTrue();
}, 15000);
});
describe("Selection Sync (Bidirectional)", function () {
async function _openMdFile(fileName) {
await awaitsForDone(SpecRunnerUtils.openProjectFiles([fileName]),
"open " + fileName);
await _waitForMdPreviewReady(EditorManager.getActiveEditor());
}
beforeAll(async function () {
if (testWindow) {
// Ensure live dev is active
if (LiveDevMultiBrowser.status !== LiveDevMultiBrowser.STATUS_ACTIVE) {
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]),
"open simple.html for live dev");
LiveDevMultiBrowser.open();
await awaitsFor(() =>
LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE,
"live dev to open", 20000);
}
// Switch HTML→MD to force MarkdownSync deactivate/activate cycle,
// resetting all internal state (_syncingFromIframe, etc.)
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple.html"]),
"open simple.html to reset sync");
await _openMdFile("long.md");
// Ensure the CM editor is created by focusing it
await awaitsFor(() => {
const ed = EditorManager.getActiveEditor();
return ed && ed.document;
}, "editor for long.md to be created");
await _enterReaderMode();
}
}, 30000);
function _getCMCursorLine() {
const editor = EditorManager.getActiveEditor();
return editor ? editor.getCursorPos().line : -1;
}
function _hasViewerHighlight() {
const mdDoc = _getMdIFrameDoc();
return mdDoc && mdDoc.querySelector(".cm-selection-highlight") !== null;
}
it("should highlight viewer blocks when CM has selection", async function () {
// Wait for editor to be fully ready (masterEditor established)
await awaitsFor(() => {
const ed = EditorManager.getActiveEditor();
return ed && ed.document && ed.document._masterEditor;
}, "editor with masterEditor to be ready");
// Clear any existing highlights
const mdDoc = _getMdIFrameDoc();
mdDoc.querySelectorAll(".cm-selection-highlight").forEach(
el => el.classList.remove("cm-selection-highlight"));
expect(_hasViewerHighlight()).toBeFalse();
// Select text in CM — MarkdownSync's cursorActivity handler
// debounces and sends MDVIEWR_HIGHLIGHT_SELECTION to the iframe
const editor = EditorManager.getActiveEditor();
editor.setSelection({ line: 4, ch: 0 }, { line: 6, ch: 0 });
expect(editor.getSelectedText().length).toBeGreaterThan(0);