Skip to content

Commit b342173

Browse files
committed
test(mdviewer): add cursor/scroll sync tests, use real DOM interactions
Add 9 new tests for cursor sync toggle state persistence, content sync independence, bidirectional cursor sync, CM scroll sync, and edit→reader re-render with data-source-line refresh. Replace fabricated postMessages with actual DOM clicks and CM API calls for true integration testing. Remove all awaits(number) calls in favor of awaitsFor(condition). Add test writing guidelines to CLAUDE.md.
1 parent 70afe37 commit b342173

3 files changed

Lines changed: 283 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ Use `exec_js` to run JS in the Phoenix browser runtime. jQuery `$()` is global.
3434

3535
**Check logs:** `get_browser_console_logs` with `filter` regex (e.g. `"AI UI"`, `"error"`) and `tail` — includes both browser console and Node.js (PhNode) logs. Use `get_terminal_logs` for Electron process output (only available if Phoenix was launched via `start_phoenix`).
3636

37+
## Writing Tests
38+
- **Never use `awaits(number)`** (fixed-time waits) in tests — they cause flaky failures. Always use `awaitsFor(condition)` to wait for a specific condition to become true.
39+
- Use `editor.*` APIs (e.g. `editor.document.getText()`, `editor.getCursorPos()`, `editor.setSelection()`) instead of accessing `editor._codeMirror` directly.
40+
- Tests should be independent — no shared mutable state between `it()` blocks. Use `FILE_CLOSE` with `{ _forceClose: true }` to clean up.
41+
3742
## Running Tests via MCP
3843

3944
The test runner must be open as a separate Phoenix instance (it shows up as `phoenix-test-runner-*` in `get_phoenix_status`). Use `run_tests` to trigger test runs and `get_test_results` to poll for results. `take_screenshot` also works on the test runner.

src-mdviewer/to-create-tests.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# Markdown Viewer/Editor — Integration Tests To Create
22

33
## Cursor/Scroll Sync
4-
- [ ] Clicking in CM scrolls md viewer to corresponding element
4+
- [x] Clicking in CM scrolls md viewer to corresponding element
55
- [x] Clicking in md viewer scrolls CM to corresponding line (centered)
66
- [x] Cursor sync toggle button disables/enables bidirectional sync
7-
- [ ] Cursor sync toggle state preserved across toolbar re-renders (file switch, mode toggle)
8-
- [ ] Content sync still works when cursor sync is disabled
9-
- [ ] Cursor sync toggle works in both reader and edit mode
10-
- [ ] Disabling cursor sync in reader mode prevents CM scroll on click
11-
- [ ] Cursor sync works on newly edited elements after edit→reader switch
12-
- [ ] Edit→reader switch re-renders from CM content (data-source-line attrs refreshed)
7+
- [x] Cursor sync toggle state preserved across toolbar re-renders (file switch, mode toggle)
8+
- [x] Content sync still works when cursor sync is disabled
9+
- [x] Cursor sync toggle works in both reader and edit mode
10+
- [x] Disabling cursor sync in reader mode prevents CM scroll on click
11+
- [x] Cursor sync works on newly edited elements after edit→reader switch
12+
- [x] Edit→reader switch re-renders from CM content (data-source-line attrs refreshed)
1313
- [x] Switching MD files preserves current edit/reader mode
1414
- [x] Edit mode not reset when switching between MD files
1515

test/spec/md-editor-integ-test.js

Lines changed: 271 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -970,21 +970,12 @@ define(function (require, exports, module) {
970970
el => el.classList.remove("cm-selection-highlight"));
971971
expect(_hasViewerHighlight()).toBeFalse();
972972

973-
// Select text in CM and dispatch highlight to iframe.
974-
// MarkdownSync sends postMessage as thers some race where the cursor isnt syncing it seems
973+
// Select text in CM — MarkdownSync's cursorActivity handler
974+
// debounces and sends MDVIEWR_HIGHLIGHT_SELECTION to the iframe
975975
const editor = EditorManager.getActiveEditor();
976976
editor.setSelection({ line: 4, ch: 0 }, { line: 6, ch: 0 });
977977
expect(editor.getSelectedText().length).toBeGreaterThan(0);
978978

979-
const win = _getMdIFrameWin();
980-
win.dispatchEvent(new MessageEvent("message", {
981-
data: {
982-
type: "MDVIEWR_HIGHLIGHT_SELECTION",
983-
fromLine: 5, toLine: 7,
984-
selectedText: editor.getSelectedText()
985-
}
986-
}));
987-
988979
await awaitsFor(() => _hasViewerHighlight(),
989980
"viewer to show selection highlight");
990981

@@ -994,18 +985,14 @@ define(function (require, exports, module) {
994985
}, 10000);
995986

996987
it("should clear viewer highlight when CM selection is cleared", async function () {
997-
// Create highlight
998-
const win = _getMdIFrameWin();
999-
win.dispatchEvent(new MessageEvent("message", {
1000-
data: { type: "MDVIEWR_HIGHLIGHT_SELECTION", fromLine: 5, toLine: 7, selectedText: "text" }
1001-
}));
988+
// Create highlight by selecting in CM
989+
const editor = EditorManager.getActiveEditor();
990+
editor.setSelection({ line: 4, ch: 0 }, { line: 6, ch: 0 });
1002991
await awaitsFor(() => _hasViewerHighlight(),
1003992
"highlight to appear");
1004993

1005-
// Clear
1006-
win.dispatchEvent(new MessageEvent("message", {
1007-
data: { type: "MDVIEWR_HIGHLIGHT_SELECTION", fromLine: null, toLine: null, selectedText: null }
1008-
}));
994+
// Clear selection in CM — should clear viewer highlight
995+
editor.setCursorPos(0, 0);
1009996

1010997
await awaitsFor(() => !_hasViewerHighlight(),
1011998
"viewer highlight to clear");
@@ -1096,6 +1083,270 @@ define(function (require, exports, module) {
10961083
return Math.abs(cmLine - (sourceLine - 1)) < 5;
10971084
}, "CM cursor to move near selected text's source line");
10981085
}, 10000);
1086+
1087+
it("should cursor sync toggle state preserve across file switch and mode toggle", async function () {
1088+
await _enterReaderMode();
1089+
1090+
// Disable cursor sync
1091+
const syncBtn = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1092+
expect(syncBtn).not.toBeNull();
1093+
syncBtn.click();
1094+
await awaitsFor(() => !syncBtn.classList.contains("active"),
1095+
"cursor sync button to become inactive");
1096+
expect(syncBtn.getAttribute("aria-pressed")).toBe("false");
1097+
1098+
// Switch to another file — toolbar re-renders
1099+
await _openMdFile("doc1.md");
1100+
const syncBtnAfterSwitch = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1101+
expect(syncBtnAfterSwitch).not.toBeNull();
1102+
expect(syncBtnAfterSwitch.classList.contains("active")).toBeFalse();
1103+
expect(syncBtnAfterSwitch.getAttribute("aria-pressed")).toBe("false");
1104+
1105+
// Toggle to edit mode — toolbar re-renders again
1106+
await _enterEditMode();
1107+
const syncBtnAfterMode = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1108+
expect(syncBtnAfterMode).not.toBeNull();
1109+
expect(syncBtnAfterMode.classList.contains("active")).toBeFalse();
1110+
expect(syncBtnAfterMode.getAttribute("aria-pressed")).toBe("false");
1111+
1112+
// Re-enable cursor sync and verify it persists across switch
1113+
syncBtnAfterMode.click();
1114+
await awaitsFor(() => syncBtnAfterMode.classList.contains("active"),
1115+
"cursor sync button to become active again");
1116+
1117+
await _openMdFile("long.md");
1118+
const syncBtnFinal = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1119+
expect(syncBtnFinal.classList.contains("active")).toBeTrue();
1120+
expect(syncBtnFinal.getAttribute("aria-pressed")).toBe("true");
1121+
1122+
// Force close doc1
1123+
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }),
1124+
"force close");
1125+
}, 15000);
1126+
1127+
it("should content sync still work when cursor sync is disabled", async function () {
1128+
await _openMdFile("long.md");
1129+
await _enterEditMode();
1130+
1131+
// Disable cursor sync
1132+
const syncBtn = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1133+
syncBtn.click();
1134+
await awaitsFor(() => !syncBtn.classList.contains("active"),
1135+
"cursor sync to be disabled");
1136+
1137+
// Edit content in CM — should still sync to viewer
1138+
const editor = EditorManager.getActiveEditor();
1139+
const originalText = editor.document.getText();
1140+
editor.document.setText("# Sync Test\n\nContent sync works even without cursor sync.\n");
1141+
1142+
await awaitsFor(() => {
1143+
const mdDoc = _getMdIFrameDoc();
1144+
const h1 = mdDoc && mdDoc.querySelector("#viewer-content h1");
1145+
return h1 && h1.textContent.includes("Sync Test");
1146+
}, "viewer to update with new content despite cursor sync off");
1147+
1148+
// Restore original content
1149+
editor.document.setText(originalText);
1150+
await awaitsFor(() => {
1151+
const mdDoc = _getMdIFrameDoc();
1152+
const h1 = mdDoc && mdDoc.querySelector("#viewer-content h1");
1153+
return h1 && !h1.textContent.includes("Sync Test");
1154+
}, "viewer to restore original content");
1155+
1156+
// Re-enable cursor sync
1157+
syncBtn.click();
1158+
await awaitsFor(() => syncBtn.classList.contains("active"),
1159+
"cursor sync to be re-enabled");
1160+
}, 10000);
1161+
1162+
it("should cursor sync toggle work in both reader and edit mode", async function () {
1163+
await _openMdFile("long.md");
1164+
1165+
// Test in reader mode
1166+
await _enterReaderMode();
1167+
let syncBtn = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1168+
expect(syncBtn).not.toBeNull();
1169+
expect(syncBtn.classList.contains("active")).toBeTrue();
1170+
1171+
syncBtn.click();
1172+
await awaitsFor(() => !syncBtn.classList.contains("active"),
1173+
"cursor sync to toggle off in reader mode");
1174+
expect(syncBtn.getAttribute("aria-pressed")).toBe("false");
1175+
1176+
syncBtn.click();
1177+
await awaitsFor(() => syncBtn.classList.contains("active"),
1178+
"cursor sync to toggle on in reader mode");
1179+
expect(syncBtn.getAttribute("aria-pressed")).toBe("true");
1180+
1181+
// Test in edit mode
1182+
await _enterEditMode();
1183+
syncBtn = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1184+
expect(syncBtn).not.toBeNull();
1185+
expect(syncBtn.classList.contains("active")).toBeTrue();
1186+
1187+
syncBtn.click();
1188+
await awaitsFor(() => !syncBtn.classList.contains("active"),
1189+
"cursor sync to toggle off in edit mode");
1190+
expect(syncBtn.getAttribute("aria-pressed")).toBe("false");
1191+
1192+
syncBtn.click();
1193+
await awaitsFor(() => syncBtn.classList.contains("active"),
1194+
"cursor sync to toggle on in edit mode");
1195+
expect(syncBtn.getAttribute("aria-pressed")).toBe("true");
1196+
}, 10000);
1197+
1198+
it("should disabling cursor sync in reader mode prevent CM cursor move on click", async function () {
1199+
await _openMdFile("long.md");
1200+
await _enterReaderMode();
1201+
1202+
// Set CM cursor to line 0 as baseline
1203+
const editor = EditorManager.getActiveEditor();
1204+
editor.setCursorPos(0, 0);
1205+
expect(_getCMCursorLine()).toBe(0);
1206+
1207+
// Disable cursor sync
1208+
const syncBtn = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1209+
syncBtn.click();
1210+
await awaitsFor(() => !syncBtn.classList.contains("active"),
1211+
"cursor sync to be disabled");
1212+
1213+
// Click a paragraph lower in the document
1214+
const mdDoc = _getMdIFrameDoc();
1215+
const paragraphs = mdDoc.querySelectorAll('#viewer-content p[data-source-line]');
1216+
let targetP = null;
1217+
for (const p of paragraphs) {
1218+
const srcLine = parseInt(p.getAttribute("data-source-line"), 10);
1219+
if (srcLine > 10) {
1220+
targetP = p;
1221+
break;
1222+
}
1223+
}
1224+
expect(targetP).not.toBeNull();
1225+
1226+
// Click directly on the element — bridge.js click handler sends
1227+
// embeddedIframeFocusEditor which MarkdownSync should ignore (sync off)
1228+
targetP.click();
1229+
1230+
// Cursor should still be at 0 — the click while sync was off had no effect.
1231+
// Re-enable cursor sync first (re-query btn in case toolbar re-rendered).
1232+
const syncBtnAfter = _getMdIFrameDoc().getElementById("emb-cursor-sync");
1233+
syncBtnAfter.click();
1234+
await awaitsFor(() => syncBtnAfter.classList.contains("active"),
1235+
"cursor sync to be re-enabled");
1236+
expect(_getCMCursorLine()).toBe(0);
1237+
}, 10000);
1238+
1239+
it("should changing CM cursor position scroll md viewer accordingly", async function () {
1240+
await _openMdFile("long.md");
1241+
await _enterReaderMode();
1242+
1243+
const mdDoc = _getMdIFrameDoc();
1244+
const viewer = mdDoc.querySelector(".app-viewer");
1245+
const editor = EditorManager.getActiveEditor();
1246+
1247+
// Set cursor to line 0 — viewer should scroll to top
1248+
editor.setCursorPos(0, 0);
1249+
await awaitsFor(() => viewer.scrollTop < 50,
1250+
"viewer to scroll near top when CM cursor at line 0");
1251+
const topScroll = viewer.scrollTop;
1252+
1253+
// Set cursor to last line — viewer should scroll down
1254+
const lastLine = editor.lineCount() - 1;
1255+
editor.setCursorPos(lastLine, 0);
1256+
await awaitsFor(() => viewer.scrollTop > topScroll + 100,
1257+
"viewer to scroll down when CM cursor moves to last line");
1258+
}, 10000);
1259+
1260+
it("should edit to reader switch re-render with fresh data-source-line attrs", async function () {
1261+
await _openMdFile("long.md");
1262+
await _enterEditMode();
1263+
await _focusMdContent();
1264+
1265+
// Add a new heading in edit mode via CM
1266+
const editor = EditorManager.getActiveEditor();
1267+
const originalText = editor.document.getText();
1268+
editor.document.setText("# Original Heading\n\n## Added In Edit\n\nSome new paragraph.\n\n" + originalText);
1269+
1270+
await awaitsFor(() => {
1271+
const mdDoc = _getMdIFrameDoc();
1272+
const h2 = mdDoc && mdDoc.querySelector('#viewer-content h2');
1273+
return h2 && h2.textContent.includes("Added In Edit");
1274+
}, "new heading to appear in viewer");
1275+
1276+
// Switch to reader mode — should re-render from CM content
1277+
await _enterReaderMode();
1278+
1279+
// Verify data-source-line attributes are present and refreshed
1280+
const mdDoc = _getMdIFrameDoc();
1281+
const elements = mdDoc.querySelectorAll('#viewer-content [data-source-line]');
1282+
expect(elements.length).toBeGreaterThan(0);
1283+
1284+
// The new heading should have a data-source-line attribute
1285+
const addedH2 = mdDoc.querySelector('#viewer-content h2');
1286+
expect(addedH2).not.toBeNull();
1287+
expect(addedH2.textContent).toContain("Added In Edit");
1288+
expect(addedH2.hasAttribute("data-source-line")).toBeTrue();
1289+
1290+
// Restore original content
1291+
editor.document.setText(originalText);
1292+
await awaitsFor(() => {
1293+
const h2 = _getMdIFrameDoc().querySelector('#viewer-content h2');
1294+
return !h2 || !h2.textContent.includes("Added In Edit");
1295+
}, "viewer to restore after content reset");
1296+
}, 15000);
1297+
1298+
it("should cursor sync work on newly edited elements after edit to reader switch", async function () {
1299+
await _openMdFile("long.md");
1300+
await _enterEditMode();
1301+
await _focusMdContent();
1302+
1303+
// Add a distinctive paragraph at the top
1304+
const editor = EditorManager.getActiveEditor();
1305+
const originalText = editor.document.getText();
1306+
const newContent = "# Top Heading\n\nNewly added paragraph for sync test.\n\n" + originalText;
1307+
editor.document.setText(newContent);
1308+
1309+
await awaitsFor(() => {
1310+
const mdDoc = _getMdIFrameDoc();
1311+
const p = mdDoc && mdDoc.querySelector('#viewer-content p');
1312+
return p && p.textContent.includes("Newly added paragraph");
1313+
}, "new paragraph to render");
1314+
1315+
// Switch to reader mode
1316+
await _enterReaderMode();
1317+
1318+
// Find the new paragraph and verify it has a source line for sync
1319+
const mdDoc = _getMdIFrameDoc();
1320+
const paragraphs = mdDoc.querySelectorAll('#viewer-content p[data-source-line]');
1321+
let newP = null;
1322+
for (const p of paragraphs) {
1323+
if (p.textContent.includes("Newly added paragraph")) {
1324+
newP = p;
1325+
break;
1326+
}
1327+
}
1328+
expect(newP).not.toBeNull();
1329+
const sourceLine = parseInt(newP.getAttribute("data-source-line"), 10);
1330+
expect(sourceLine).toBeGreaterThan(0);
1331+
1332+
// Move CM cursor far away so we can verify the click actually moves it
1333+
const editor2 = EditorManager.getActiveEditor();
1334+
const farLine = editor2.lineCount() - 1;
1335+
editor2.setCursorPos(farLine, 0);
1336+
expect(Math.abs(_getCMCursorLine() - (sourceLine - 1))).toBeGreaterThan(3);
1337+
1338+
// Click the new paragraph directly — bridge.js click handler
1339+
// sends embeddedIframeFocusEditor, MarkdownSync scrolls CM
1340+
newP.click();
1341+
1342+
await awaitsFor(() => {
1343+
const cmLine = _getCMCursorLine();
1344+
return Math.abs(cmLine - (sourceLine - 1)) < 3;
1345+
}, "CM cursor to move to newly edited element's source line");
1346+
1347+
// Restore
1348+
editor.document.setText(originalText);
1349+
}, 15000);
10991350
});
11001351

11011352
describe("Toolbar & UI", function () {

0 commit comments

Comments
 (0)