From 6f213920cd2406e1af076b226388fc79ac6b1557 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:34:51 +0800 Subject: [PATCH 1/2] test: group test files by feature Merge the ~200 per-fix test files into a few files per feature area. Source-reading cases move to .checks.* files, separate from behavior tests. No assertion changed: the same 1752 test names pass. --- scripts/anchorJumpUnderZoom.spec.ts | 100 - scripts/assetScopeDotDirs.test.ts | 16 - scripts/backgroundEditorPosition.test.ts | 121 - scripts/calloutMarker.test.ts | 50 - scripts/checkedReadMigration.spec.ts | 139 -- scripts/checkedReadMigration.test.ts | 124 - scripts/ciCacheBudget.test.ts | 56 - scripts/clipboardOwnershipX11.test.ts | 35 - scripts/codeFence.test.ts | 63 - scripts/coldStartDoesNotStealFocus.test.ts | 44 - scripts/copyLabelFlash.spec.ts | 33 - scripts/copyReferenceStyle.test.ts | 149 -- scripts/diagramCache.test.ts | 327 --- scripts/diagramThemeRefresh.spec.ts | 217 -- scripts/documentEncodingRoundTrip.spec.ts | 269 --- scripts/documentLoadFailure.spec.ts | 101 - scripts/documents.checks.spec.ts | 1071 +++++++++ scripts/documents.checks.test.ts | 1014 ++++++++ scripts/documents.load.spec.ts | 1416 +++++++++++ scripts/documents.save.spec.ts | 1473 ++++++++++++ scripts/documents.test.ts | 564 +++++ scripts/duplicateTabNames.test.ts | 52 - scripts/editing.checks.test.ts | 980 ++++++++ scripts/editing.spec.ts | 80 + scripts/editing.test.ts | 1549 ++++++++++++ scripts/editor.checks.spec.ts | 602 +++++ scripts/editor.checks.test.ts | 1652 +++++++++++++ ...pec.ts => editor.imageUndo.checks.spec.ts} | 0 scripts/editor.spec.ts | 184 ++ scripts/editor.test.ts | 1165 +++++++++ scripts/editor.vim.test.ts | 531 +++++ scripts/editorContextMenuI18n.test.ts | 208 -- scripts/editorLanguage.test.ts | 21 - scripts/editorLineEnding.test.ts | 95 - scripts/editorOptionWiring.test.ts | 809 ------- scripts/editorPositionFlush.test.ts | 108 - scripts/editorTheme.test.ts | 198 -- scripts/editorToolbar.test.ts | 577 ----- scripts/editorUndoStops.test.ts | 47 - scripts/editorWritesThroughTheStore.spec.ts | 169 -- scripts/explicitSaveCancelsAutoSave.spec.ts | 187 -- scripts/export.checks.test.ts | 1130 +++++++++ scripts/export.test.ts | 1275 ++++++++++ scripts/exportContentWidth.test.ts | 388 --- scripts/exportFoldParity.test.ts | 368 --- scripts/exportHtml.test.ts | 133 -- scripts/exportOpenPrompt.test.ts | 71 - scripts/exportPreviewAppearance.test.ts | 108 - scripts/exportRichContent.test.ts | 435 ---- scripts/exportSanitize.test.ts | 164 -- scripts/exportedThemeFidelity.test.ts | 202 -- scripts/externalChangeReload.spec.ts | 458 ---- scripts/fileAssociations.test.ts | 62 - scripts/fileDropRouting.test.ts | 124 - scripts/findSeed.test.ts | 38 - scripts/findText.spec.ts | 80 - scripts/findWidgetTooltipClip.test.ts | 50 - scripts/firstReadMeasuresItsOwnTab.spec.ts | 124 - scripts/focusParagraph.test.ts | 15 - scripts/foldKeys.test.ts | 140 -- scripts/foldLayout.test.ts | 111 - scripts/foldMeasurementBatching.test.ts | 231 -- scripts/foldStatePerDocument.spec.ts | 693 ------ scripts/folderSidebar.spec.ts | 178 -- scripts/fontFamily.test.ts | 80 - scripts/footnoteAnchorIds.test.ts | 28 - scripts/formatShortcutKeymap.test.ts | 273 --- scripts/frontMatter.test.ts | 123 - scripts/frontMatterFieldStateScope.test.ts | 86 - scripts/frontMatterProseBlock.test.ts | 170 -- scripts/headingInlineCode.test.ts | 37 - scripts/headingLinkCompletion.test.ts | 138 -- scripts/headingSymbols.test.ts | 50 - scripts/homeSentinelSnapshot.spec.ts | 99 - scripts/homeTabRender.spec.ts | 274 --- scripts/i18nCoverage.test.ts | 624 ----- scripts/imageEmbed.test.ts | 229 -- scripts/issue261EditorPdf.test.ts | 79 - scripts/jumpScrollMotion.test.ts | 19 - scripts/jumpToSelectedFragment.test.ts | 470 ---- scripts/katexContribFlavour.test.ts | 19 - scripts/largeFileLoadRevision.spec.ts | 167 -- scripts/layout.checks.spec.ts | 33 + scripts/layout.checks.test.ts | 348 +++ scripts/layout.scrollSync.test.ts | 2096 +++++++++++++++++ scripts/layout.spec.ts | 1024 ++++++++ scripts/layout.test.ts | 708 ++++++ scripts/lineCoordinates.test.ts | 167 -- scripts/lineSamplesCache.spec.ts | 66 - scripts/linkClickTarget.test.ts | 64 - scripts/linkHoverPreview.test.ts | 64 - scripts/listContinuation.test.ts | 673 ------ scripts/liveModeWatchedPath.test.ts | 45 - scripts/localFileLinks.test.ts | 338 --- scripts/localeLoading.spec.ts | 24 - scripts/lossyDecodeSaveGuard.test.ts | 255 -- scripts/lossySaveRefusalScope.spec.ts | 170 -- scripts/macosAppMenu.test.ts | 153 -- scripts/macosOpenDocumentPaths.test.ts | 32 - scripts/macosPdfExport.test.ts | 46 - scripts/markdownTokenColours.test.ts | 135 -- scripts/mathDelimiterContract.test.ts | 167 -- scripts/menuModalGuards.test.ts | 90 - scripts/mermaidDiagramLabels.test.ts | 317 --- scripts/mermaidPrintTheme.test.ts | 66 - scripts/monacoAltGraph.spec.ts | 132 -- scripts/monacoChordOwnership.spec.ts | 568 ----- scripts/monacoImePatch.test.ts | 45 - scripts/monacoStartupGraph.test.ts | 301 --- scripts/navigation.anchors.test.ts | 677 ++++++ scripts/navigation.checks.test.ts | 321 +++ scripts/navigation.spec.ts | 1442 ++++++++++++ scripts/navigation.test.ts | 153 ++ scripts/navigationHistory.test.ts | 154 -- scripts/nbspBlankLine.spec.ts | 71 - scripts/newTabLinkKeepsScrollHistory.spec.ts | 78 - scripts/newWindowClaim.spec.ts | 55 - scripts/nsisInstallerHooks.test.ts | 176 -- scripts/offMainThreadCommands.test.ts | 14 - scripts/oneWritePerTabInFlight.spec.ts | 328 --- scripts/openFileMode.test.ts | 138 -- scripts/openShortcutInPreview.test.ts | 70 - scripts/osTypeFallback.spec.ts | 70 - scripts/packageManagedUpdate.test.ts | 79 - scripts/paneSlide.test.ts | 28 - scripts/pasteUrlContext.test.ts | 235 -- scripts/pathIdentityCaseFolding.spec.ts | 346 --- scripts/pdfExportResult.test.ts | 87 - scripts/perWindowSession.spec.ts | 114 - scripts/pinnedTagFiles.spec.ts | 48 - scripts/pinnedTagSave.test.ts | 188 -- scripts/pinnedTagWindow.test.ts | 54 - scripts/platformSource.spec.ts | 134 -- scripts/preview.checks.spec.ts | 426 ++++ scripts/preview.checks.test.ts | 860 +++++++ scripts/preview.domShim.ts | 51 + scripts/preview.lazyLoad.spec.ts | 61 + scripts/preview.render.test.ts | 960 ++++++++ scripts/preview.renderedHtml.spec.ts | 94 + scripts/preview.richContent.test.ts | 808 +++++++ scripts/preview.spec.ts | 1324 +++++++++++ scripts/preview.test.ts | 695 ++++++ scripts/previewAnchorRestore.test.ts | 521 ---- scripts/previewAnnotations.spec.ts | 69 - scripts/previewCopy.test.ts | 92 - scripts/previewCopyHtml.spec.ts | 152 -- scripts/previewCursor.spec.ts | 95 - scripts/previewPatch.spec.ts | 340 --- scripts/previewPatchParity.spec.ts | 101 - scripts/previewSanitize.spec.ts | 144 -- scripts/previewSanitize.test.ts | 245 -- scripts/previewWidth.test.ts | 153 -- scripts/printOversizedMedia.test.ts | 100 - scripts/printsExportArticle.test.ts | 104 - scripts/readLandsInItsOwnTab.spec.ts | 279 --- scripts/recentFilesMultiWindow.test.ts | 278 --- scripts/recentFilesWriteRace.spec.ts | 136 -- scripts/release.checks.test.ts | 1469 ++++++++++++ scripts/release.spec.ts | 89 + scripts/release.test.ts | 163 ++ scripts/releaseWorkflow.test.ts | 619 ----- scripts/renameFileSession.spec.ts | 151 -- scripts/renderPipelineConvention.test.ts | 41 - scripts/renderProtocol.test.ts | 626 ----- scripts/renderProtocolDom.test.ts | 193 -- scripts/renderedHtmlField.spec.ts | 452 ---- scripts/reopenDirtyDocument.spec.ts | 268 --- scripts/richContentCache.test.ts | 185 -- scripts/richContentIdempotence.spec.ts | 92 - scripts/richContentLazyLoad.spec.ts | 59 - scripts/saveFromReadingMode.test.ts | 98 - scripts/saveImageAsAssetUrl.test.ts | 95 - scripts/scrollHistoryPerTab.spec.ts | 328 --- scripts/scrollSync.test.ts | 205 -- scripts/scrollSyncAcrossFolds.test.ts | 455 ---- scripts/scrollSyncBlockMapping.test.ts | 1512 ------------ scripts/scrollSyncInput.test.ts | 26 - scripts/semanticTokens.test.ts | 182 -- scripts/sessionRestoreResilience.spec.ts | 724 ------ scripts/settings.checks.spec.ts | 359 +++ scripts/settings.checks.test.ts | 645 +++++ scripts/settings.locale.spec.ts | 27 + scripts/settings.spec.ts | 993 ++++++++ scripts/settings.test.ts | 343 +++ scripts/settings.themeLoad.spec.ts | 93 + scripts/settingsFile.spec.ts | 114 - scripts/settingsPersistence.spec.ts | 1176 --------- scripts/settingsSemantics.test.ts | 132 -- scripts/shortcutRegistry.test.ts | 1285 ---------- scripts/shortcuts.checks.spec.ts | 134 ++ scripts/shortcuts.checks.test.ts | 1048 +++++++++ scripts/shortcuts.spec.ts | 437 ++++ scripts/shortcuts.test.ts | 924 ++++++++ scripts/shortcuts.toolbar.test.ts | 781 ++++++ scripts/silentSaveKeepsConflicts.spec.ts | 205 -- .../singleImplementationConvention.test.ts | 352 --- scripts/sourceTree.test.ts | 161 -- scripts/splitPanes.test.ts | 39 - scripts/splitScrollSyncSetting.spec.ts | 123 - scripts/staleThemeLoad.spec.ts | 91 - scripts/statusBarHeight.test.ts | 22 - scripts/tabAnchorAcrossPanes.spec.ts | 263 --- scripts/tabFileActions.test.ts | 30 - scripts/tabNavigationHistory.spec.ts | 100 - scripts/tabPathIdentity.spec.ts | 188 -- scripts/tabReadingPosition.spec.ts | 539 ----- scripts/tabReorder.test.ts | 32 - scripts/tabSwitchIsNotARender.test.ts | 98 - scripts/tabSwitchKeepsPreviewDom.spec.ts | 109 - scripts/tabTransfer.test.ts | 243 -- scripts/tabTransferHandoff.test.ts | 97 - scripts/tabTransferRaces.spec.ts | 216 -- scripts/tableEditing.test.ts | 734 ------ scripts/tabs.checks.spec.ts | 869 +++++++ scripts/tabs.checks.test.ts | 321 +++ scripts/tabs.spec.ts | 1300 ++++++++++ scripts/tabs.test.ts | 506 ++++ scripts/taskToggleLineEndings.spec.ts | 313 --- scripts/tauriCommandRegistration.test.ts | 67 - scripts/themeCssValidation.test.ts | 101 - scripts/titlebarToolbar.test.ts | 230 -- scripts/tocDocumentPasses.spec.ts | 168 -- scripts/tocFollowsEditor.test.ts | 228 -- scripts/tocJumpMotion.test.ts | 14 - scripts/tocOverlay.test.ts | 162 -- scripts/tocRenderSignal.spec.ts | 70 - scripts/truncatedBufferGuard.spec.ts | 500 ---- scripts/uncHostGuard.test.ts | 106 - scripts/undoHistoryPerTab.spec.ts | 857 ------- scripts/unsavedRecovery.test.ts | 252 -- scripts/untitledFirstSaveWrites.spec.ts | 131 -- scripts/untitledTitle.test.ts | 46 - scripts/updateDialogNotes.test.ts | 26 - scripts/updateSettlesBeforeInstall.spec.ts | 84 - scripts/versionSync.test.ts | 59 - scripts/viewModeWithoutSaving.spec.ts | 737 ------ scripts/viewerKeymap.spec.ts | 295 --- scripts/vimClipboardRegisters.test.ts | 117 - scripts/vimScrollCommands.test.ts | 456 ---- scripts/wikilinkFileTargets.test.ts | 129 - scripts/windowClosePerTab.test.ts | 187 -- scripts/windowGeometryWhileRunning.test.ts | 22 - scripts/windowOrganization.test.ts | 72 - scripts/windowStartupHandle.test.ts | 24 - scripts/windowStateRestore.spec.ts | 224 -- scripts/windowTagDismiss.test.ts | 204 -- scripts/windowTagPerWindow.spec.ts | 207 -- scripts/windowTagScope.test.ts | 363 --- scripts/windowTags.spec.ts | 94 - scripts/windowTitleCapability.test.ts | 15 - scripts/windows.checks.spec.ts | 276 +++ scripts/windows.checks.test.ts | 738 ++++++ scripts/windows.recentFiles.test.ts | 245 ++ scripts/windows.sessionRestore.spec.ts | 729 ++++++ scripts/windows.spec.ts | 988 ++++++++ scripts/windows.tagEditor.checks.test.ts | 575 +++++ scripts/windows.test.ts | 112 + scripts/windowsAreEqual.test.ts | 23 - scripts/wordCount.spec.ts | 63 - scripts/workflowSecurityAudit.test.ts | 11 - scripts/youtubeExternalFallback.test.ts | 123 - 261 files changed, 40931 insertions(+), 40087 deletions(-) delete mode 100644 scripts/anchorJumpUnderZoom.spec.ts delete mode 100644 scripts/assetScopeDotDirs.test.ts delete mode 100644 scripts/backgroundEditorPosition.test.ts delete mode 100644 scripts/calloutMarker.test.ts delete mode 100644 scripts/checkedReadMigration.spec.ts delete mode 100644 scripts/checkedReadMigration.test.ts delete mode 100644 scripts/ciCacheBudget.test.ts delete mode 100644 scripts/clipboardOwnershipX11.test.ts delete mode 100644 scripts/codeFence.test.ts delete mode 100644 scripts/coldStartDoesNotStealFocus.test.ts delete mode 100644 scripts/copyLabelFlash.spec.ts delete mode 100644 scripts/copyReferenceStyle.test.ts delete mode 100644 scripts/diagramCache.test.ts delete mode 100644 scripts/diagramThemeRefresh.spec.ts delete mode 100644 scripts/documentEncodingRoundTrip.spec.ts delete mode 100644 scripts/documentLoadFailure.spec.ts create mode 100644 scripts/documents.checks.spec.ts create mode 100644 scripts/documents.checks.test.ts create mode 100644 scripts/documents.load.spec.ts create mode 100644 scripts/documents.save.spec.ts create mode 100644 scripts/documents.test.ts delete mode 100644 scripts/duplicateTabNames.test.ts create mode 100644 scripts/editing.checks.test.ts create mode 100644 scripts/editing.spec.ts create mode 100644 scripts/editing.test.ts create mode 100644 scripts/editor.checks.spec.ts create mode 100644 scripts/editor.checks.test.ts rename scripts/{imageUndoKeepsFile.spec.ts => editor.imageUndo.checks.spec.ts} (100%) create mode 100644 scripts/editor.spec.ts create mode 100644 scripts/editor.test.ts create mode 100644 scripts/editor.vim.test.ts delete mode 100644 scripts/editorContextMenuI18n.test.ts delete mode 100644 scripts/editorLanguage.test.ts delete mode 100644 scripts/editorLineEnding.test.ts delete mode 100644 scripts/editorOptionWiring.test.ts delete mode 100644 scripts/editorPositionFlush.test.ts delete mode 100644 scripts/editorTheme.test.ts delete mode 100644 scripts/editorToolbar.test.ts delete mode 100644 scripts/editorUndoStops.test.ts delete mode 100644 scripts/editorWritesThroughTheStore.spec.ts delete mode 100644 scripts/explicitSaveCancelsAutoSave.spec.ts create mode 100644 scripts/export.checks.test.ts create mode 100644 scripts/export.test.ts delete mode 100644 scripts/exportContentWidth.test.ts delete mode 100644 scripts/exportFoldParity.test.ts delete mode 100644 scripts/exportHtml.test.ts delete mode 100644 scripts/exportOpenPrompt.test.ts delete mode 100644 scripts/exportPreviewAppearance.test.ts delete mode 100644 scripts/exportRichContent.test.ts delete mode 100644 scripts/exportSanitize.test.ts delete mode 100644 scripts/exportedThemeFidelity.test.ts delete mode 100644 scripts/externalChangeReload.spec.ts delete mode 100644 scripts/fileAssociations.test.ts delete mode 100644 scripts/fileDropRouting.test.ts delete mode 100644 scripts/findSeed.test.ts delete mode 100644 scripts/findText.spec.ts delete mode 100644 scripts/findWidgetTooltipClip.test.ts delete mode 100644 scripts/firstReadMeasuresItsOwnTab.spec.ts delete mode 100644 scripts/focusParagraph.test.ts delete mode 100644 scripts/foldKeys.test.ts delete mode 100644 scripts/foldLayout.test.ts delete mode 100644 scripts/foldMeasurementBatching.test.ts delete mode 100644 scripts/foldStatePerDocument.spec.ts delete mode 100644 scripts/folderSidebar.spec.ts delete mode 100644 scripts/fontFamily.test.ts delete mode 100644 scripts/footnoteAnchorIds.test.ts delete mode 100644 scripts/formatShortcutKeymap.test.ts delete mode 100644 scripts/frontMatter.test.ts delete mode 100644 scripts/frontMatterFieldStateScope.test.ts delete mode 100644 scripts/frontMatterProseBlock.test.ts delete mode 100644 scripts/headingInlineCode.test.ts delete mode 100644 scripts/headingLinkCompletion.test.ts delete mode 100644 scripts/headingSymbols.test.ts delete mode 100644 scripts/homeSentinelSnapshot.spec.ts delete mode 100644 scripts/homeTabRender.spec.ts delete mode 100644 scripts/i18nCoverage.test.ts delete mode 100644 scripts/imageEmbed.test.ts delete mode 100644 scripts/issue261EditorPdf.test.ts delete mode 100644 scripts/jumpScrollMotion.test.ts delete mode 100644 scripts/jumpToSelectedFragment.test.ts delete mode 100644 scripts/katexContribFlavour.test.ts delete mode 100644 scripts/largeFileLoadRevision.spec.ts create mode 100644 scripts/layout.checks.spec.ts create mode 100644 scripts/layout.checks.test.ts create mode 100644 scripts/layout.scrollSync.test.ts create mode 100644 scripts/layout.spec.ts create mode 100644 scripts/layout.test.ts delete mode 100644 scripts/lineCoordinates.test.ts delete mode 100644 scripts/lineSamplesCache.spec.ts delete mode 100644 scripts/linkClickTarget.test.ts delete mode 100644 scripts/linkHoverPreview.test.ts delete mode 100644 scripts/listContinuation.test.ts delete mode 100644 scripts/liveModeWatchedPath.test.ts delete mode 100644 scripts/localFileLinks.test.ts delete mode 100644 scripts/localeLoading.spec.ts delete mode 100644 scripts/lossyDecodeSaveGuard.test.ts delete mode 100644 scripts/lossySaveRefusalScope.spec.ts delete mode 100644 scripts/macosAppMenu.test.ts delete mode 100644 scripts/macosOpenDocumentPaths.test.ts delete mode 100644 scripts/macosPdfExport.test.ts delete mode 100644 scripts/markdownTokenColours.test.ts delete mode 100644 scripts/mathDelimiterContract.test.ts delete mode 100644 scripts/menuModalGuards.test.ts delete mode 100644 scripts/mermaidDiagramLabels.test.ts delete mode 100644 scripts/mermaidPrintTheme.test.ts delete mode 100644 scripts/monacoAltGraph.spec.ts delete mode 100644 scripts/monacoChordOwnership.spec.ts delete mode 100644 scripts/monacoImePatch.test.ts delete mode 100644 scripts/monacoStartupGraph.test.ts create mode 100644 scripts/navigation.anchors.test.ts create mode 100644 scripts/navigation.checks.test.ts create mode 100644 scripts/navigation.spec.ts create mode 100644 scripts/navigation.test.ts delete mode 100644 scripts/navigationHistory.test.ts delete mode 100644 scripts/nbspBlankLine.spec.ts delete mode 100644 scripts/newTabLinkKeepsScrollHistory.spec.ts delete mode 100644 scripts/newWindowClaim.spec.ts delete mode 100644 scripts/nsisInstallerHooks.test.ts delete mode 100644 scripts/offMainThreadCommands.test.ts delete mode 100644 scripts/oneWritePerTabInFlight.spec.ts delete mode 100644 scripts/openFileMode.test.ts delete mode 100644 scripts/openShortcutInPreview.test.ts delete mode 100644 scripts/osTypeFallback.spec.ts delete mode 100644 scripts/packageManagedUpdate.test.ts delete mode 100644 scripts/paneSlide.test.ts delete mode 100644 scripts/pasteUrlContext.test.ts delete mode 100644 scripts/pathIdentityCaseFolding.spec.ts delete mode 100644 scripts/pdfExportResult.test.ts delete mode 100644 scripts/perWindowSession.spec.ts delete mode 100644 scripts/pinnedTagFiles.spec.ts delete mode 100644 scripts/pinnedTagSave.test.ts delete mode 100644 scripts/pinnedTagWindow.test.ts delete mode 100644 scripts/platformSource.spec.ts create mode 100644 scripts/preview.checks.spec.ts create mode 100644 scripts/preview.checks.test.ts create mode 100644 scripts/preview.domShim.ts create mode 100644 scripts/preview.lazyLoad.spec.ts create mode 100644 scripts/preview.render.test.ts create mode 100644 scripts/preview.renderedHtml.spec.ts create mode 100644 scripts/preview.richContent.test.ts create mode 100644 scripts/preview.spec.ts create mode 100644 scripts/preview.test.ts delete mode 100644 scripts/previewAnchorRestore.test.ts delete mode 100644 scripts/previewAnnotations.spec.ts delete mode 100644 scripts/previewCopy.test.ts delete mode 100644 scripts/previewCopyHtml.spec.ts delete mode 100644 scripts/previewCursor.spec.ts delete mode 100644 scripts/previewPatch.spec.ts delete mode 100644 scripts/previewPatchParity.spec.ts delete mode 100644 scripts/previewSanitize.spec.ts delete mode 100644 scripts/previewSanitize.test.ts delete mode 100644 scripts/previewWidth.test.ts delete mode 100644 scripts/printOversizedMedia.test.ts delete mode 100644 scripts/printsExportArticle.test.ts delete mode 100644 scripts/readLandsInItsOwnTab.spec.ts delete mode 100644 scripts/recentFilesMultiWindow.test.ts delete mode 100644 scripts/recentFilesWriteRace.spec.ts create mode 100644 scripts/release.checks.test.ts create mode 100644 scripts/release.spec.ts create mode 100644 scripts/release.test.ts delete mode 100644 scripts/releaseWorkflow.test.ts delete mode 100644 scripts/renameFileSession.spec.ts delete mode 100644 scripts/renderPipelineConvention.test.ts delete mode 100644 scripts/renderProtocol.test.ts delete mode 100644 scripts/renderProtocolDom.test.ts delete mode 100644 scripts/renderedHtmlField.spec.ts delete mode 100644 scripts/reopenDirtyDocument.spec.ts delete mode 100644 scripts/richContentCache.test.ts delete mode 100644 scripts/richContentIdempotence.spec.ts delete mode 100644 scripts/richContentLazyLoad.spec.ts delete mode 100644 scripts/saveFromReadingMode.test.ts delete mode 100644 scripts/saveImageAsAssetUrl.test.ts delete mode 100644 scripts/scrollHistoryPerTab.spec.ts delete mode 100644 scripts/scrollSync.test.ts delete mode 100644 scripts/scrollSyncAcrossFolds.test.ts delete mode 100644 scripts/scrollSyncBlockMapping.test.ts delete mode 100644 scripts/scrollSyncInput.test.ts delete mode 100644 scripts/semanticTokens.test.ts delete mode 100644 scripts/sessionRestoreResilience.spec.ts create mode 100644 scripts/settings.checks.spec.ts create mode 100644 scripts/settings.checks.test.ts create mode 100644 scripts/settings.locale.spec.ts create mode 100644 scripts/settings.spec.ts create mode 100644 scripts/settings.test.ts create mode 100644 scripts/settings.themeLoad.spec.ts delete mode 100644 scripts/settingsFile.spec.ts delete mode 100644 scripts/settingsPersistence.spec.ts delete mode 100644 scripts/settingsSemantics.test.ts delete mode 100644 scripts/shortcutRegistry.test.ts create mode 100644 scripts/shortcuts.checks.spec.ts create mode 100644 scripts/shortcuts.checks.test.ts create mode 100644 scripts/shortcuts.spec.ts create mode 100644 scripts/shortcuts.test.ts create mode 100644 scripts/shortcuts.toolbar.test.ts delete mode 100644 scripts/silentSaveKeepsConflicts.spec.ts delete mode 100644 scripts/singleImplementationConvention.test.ts delete mode 100644 scripts/sourceTree.test.ts delete mode 100644 scripts/splitPanes.test.ts delete mode 100644 scripts/splitScrollSyncSetting.spec.ts delete mode 100644 scripts/staleThemeLoad.spec.ts delete mode 100644 scripts/statusBarHeight.test.ts delete mode 100644 scripts/tabAnchorAcrossPanes.spec.ts delete mode 100644 scripts/tabFileActions.test.ts delete mode 100644 scripts/tabNavigationHistory.spec.ts delete mode 100644 scripts/tabPathIdentity.spec.ts delete mode 100644 scripts/tabReadingPosition.spec.ts delete mode 100644 scripts/tabReorder.test.ts delete mode 100644 scripts/tabSwitchIsNotARender.test.ts delete mode 100644 scripts/tabSwitchKeepsPreviewDom.spec.ts delete mode 100644 scripts/tabTransfer.test.ts delete mode 100644 scripts/tabTransferHandoff.test.ts delete mode 100644 scripts/tabTransferRaces.spec.ts delete mode 100644 scripts/tableEditing.test.ts create mode 100644 scripts/tabs.checks.spec.ts create mode 100644 scripts/tabs.checks.test.ts create mode 100644 scripts/tabs.spec.ts create mode 100644 scripts/tabs.test.ts delete mode 100644 scripts/taskToggleLineEndings.spec.ts delete mode 100644 scripts/tauriCommandRegistration.test.ts delete mode 100644 scripts/themeCssValidation.test.ts delete mode 100644 scripts/titlebarToolbar.test.ts delete mode 100644 scripts/tocDocumentPasses.spec.ts delete mode 100644 scripts/tocFollowsEditor.test.ts delete mode 100644 scripts/tocJumpMotion.test.ts delete mode 100644 scripts/tocOverlay.test.ts delete mode 100644 scripts/tocRenderSignal.spec.ts delete mode 100644 scripts/truncatedBufferGuard.spec.ts delete mode 100644 scripts/uncHostGuard.test.ts delete mode 100644 scripts/undoHistoryPerTab.spec.ts delete mode 100644 scripts/unsavedRecovery.test.ts delete mode 100644 scripts/untitledFirstSaveWrites.spec.ts delete mode 100644 scripts/untitledTitle.test.ts delete mode 100644 scripts/updateDialogNotes.test.ts delete mode 100644 scripts/updateSettlesBeforeInstall.spec.ts delete mode 100644 scripts/versionSync.test.ts delete mode 100644 scripts/viewModeWithoutSaving.spec.ts delete mode 100644 scripts/viewerKeymap.spec.ts delete mode 100644 scripts/vimClipboardRegisters.test.ts delete mode 100644 scripts/vimScrollCommands.test.ts delete mode 100644 scripts/wikilinkFileTargets.test.ts delete mode 100644 scripts/windowClosePerTab.test.ts delete mode 100644 scripts/windowGeometryWhileRunning.test.ts delete mode 100644 scripts/windowOrganization.test.ts delete mode 100644 scripts/windowStartupHandle.test.ts delete mode 100644 scripts/windowStateRestore.spec.ts delete mode 100644 scripts/windowTagDismiss.test.ts delete mode 100644 scripts/windowTagPerWindow.spec.ts delete mode 100644 scripts/windowTagScope.test.ts delete mode 100644 scripts/windowTags.spec.ts delete mode 100644 scripts/windowTitleCapability.test.ts create mode 100644 scripts/windows.checks.spec.ts create mode 100644 scripts/windows.checks.test.ts create mode 100644 scripts/windows.recentFiles.test.ts create mode 100644 scripts/windows.sessionRestore.spec.ts create mode 100644 scripts/windows.spec.ts create mode 100644 scripts/windows.tagEditor.checks.test.ts create mode 100644 scripts/windows.test.ts delete mode 100644 scripts/windowsAreEqual.test.ts delete mode 100644 scripts/wordCount.spec.ts delete mode 100644 scripts/workflowSecurityAudit.test.ts delete mode 100644 scripts/youtubeExternalFallback.test.ts diff --git a/scripts/anchorJumpUnderZoom.spec.ts b/scripts/anchorJumpUnderZoom.spec.ts deleted file mode 100644 index 7658b477..00000000 --- a/scripts/anchorJumpUnderZoom.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { anchorScrollTop, previewZoomFactor, PREVIEW_ANCHOR_OFFSET } from '../src/lib/utils/previewAnchor.js'; - -/* - * #621: "Custom viewport zoom bogusly offsets anchor jumps (in preview mode)". - * At 150% a link scrolled past its heading, at 50% it stopped short, and at - * 100% it was right. - * - * The preview sets CSS `zoom` on `.markdown-container`, an ANCESTOR of the - * scrolling element. `getBoundingClientRect()` reports viewport pixels, which - * carry that zoom; `scrollTop` counts the scroller's own pixels, which do not. - * Both call sites added one straight to the other, so the error was exactly the - * zoom factor — invisible at 100%, and the wrong sign on either side of it. - * - * jsdom has no layout, so the elements here are stand-ins that report the - * geometry a browser would at a given zoom: a rect scaled by the factor, and an - * `offsetWidth` that is not. That is the whole relationship under test. - */ - -/** An element whose rect is `zoom`× its layout box, as a zoomed ancestor makes it. */ -function zoomedElement(layoutTop: number, layoutWidth: number, zoom: number) { - return { - offsetWidth: layoutWidth, - getBoundingClientRect: () => ({ top: layoutTop * zoom, width: layoutWidth * zoom }), - } as unknown as HTMLElement; -} - -function scroller(zoom: number, scrollTop: number) { - const el = zoomedElement(0, 800, zoom) as unknown as { scrollTop: number }; - el.scrollTop = scrollTop; - return el as unknown as HTMLElement; -} - -test('the zoom factor is measured off the container, not read from settings', () => { - assert.equal(previewZoomFactor(scroller(1, 0)), 1); - assert.equal(previewZoomFactor(scroller(1.5, 0)), 1.5); - assert.equal(previewZoomFactor(scroller(0.5, 0)), 0.5); -}); - -test('a container with no layout reports 1 rather than a division by zero', () => { - const detached = { - offsetWidth: 0, - scrollTop: 0, - getBoundingClientRect: () => ({ top: 0, width: 0 }), - } as unknown as HTMLElement; - assert.equal(previewZoomFactor(detached), 1); - assert.ok(Number.isFinite(anchorScrollTop(detached, detached))); -}); - -test('an anchor lands the same distance down the document at every zoom', () => { - // The heading sits 1000 layout pixels below the top of the scroller, which - // is already scrolled to 200. Wherever the zoom is, the answer is the same - // number of the scroller's own pixels. - const expected = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const zoom of [0.5, 1, 1.5, 2]) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - assert.equal( - anchorScrollTop(container, heading), - expected, - `zoom ${zoom} must land at ${expected}`, - ); - } -}); - -test('the pre-fix arithmetic is what overshot, and by exactly the zoom factor', () => { - // Kept as the shape of the defect: rect delta added straight to scrollTop. - // It is only correct at 100%, which is why the bug went unseen — and the - // error is the factor itself, matching the report's "way past" at 150% and - // "way before" at 50%. - const preFix = (container: HTMLElement, el: HTMLElement) => - el.getBoundingClientRect().top - - container.getBoundingClientRect().top + - container.scrollTop - - PREVIEW_ANCHOR_OFFSET; - - const correct = 200 + 1000 - PREVIEW_ANCHOR_OFFSET; - - for (const [zoom, sign] of [ - [1.5, 'past'], - [0.5, 'short'], - ] as const) { - const container = scroller(zoom, 200); - const heading = zoomedElement(1000, 400, zoom); - const old = preFix(container, heading); - assert.notEqual(old, correct, `at zoom ${zoom} the old form must be wrong (${sign})`); - // The whole error is the un-divided delta. - assert.equal(old - correct, 1000 * zoom - 1000); - assert.equal(anchorScrollTop(container, heading), correct); - } - - // …and identical at 100%, which is why nobody saw it. - const container = scroller(1, 200); - const heading = zoomedElement(1000, 400, 1); - assert.equal(preFix(container, heading), anchorScrollTop(container, heading)); -}); diff --git a/scripts/assetScopeDotDirs.test.ts b/scripts/assetScopeDotDirs.test.ts deleted file mode 100644 index 0a916cff..00000000 --- a/scripts/assetScopeDotDirs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -// `![](.res/pic.jpg)` rendered blank on Linux and macOS (#981). Tauri's fs -// scope defaults `requireLiteralLeadingDot` to true on Unix, so `**` does not -// match a path segment that starts with a dot and the `asset:` handler answers -// 403. Windows defaults to false, which is why it only showed up off Windows. -test('asset scope lets `**` match dot-directories on every platform', () => { - const conf = JSON.parse(readSource('src-tauri/tauri.conf.json')); - assert.deepEqual(conf.app.security.assetProtocol.scope, { - allow: ['**'], - requireLiteralLeadingDot: false, - }); -}); diff --git a/scripts/backgroundEditorPosition.test.ts b/scripts/backgroundEditorPosition.test.ts deleted file mode 100644 index aa324c67..00000000 --- a/scripts/backgroundEditorPosition.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { outgoingTabAnchorLine, type StoredTabPosition } from '../src/lib/utils/editorPosition.js'; -import { asRendererLine, EDITOR_ANCHOR_LINE_OFFSET } from '../src/lib/utils/lineCoordinates.js'; -import { readSource, offsetOf, sliceFrom } from './sourceTree.js'; - -// A tab left in EDIT-ONLY mode in the background is the one population neither -// writer of `Tab.anchorLine` covers: the preview writes only the active tab and -// is not on screen for this one, and the single editor has moved on to another -// model. What covers it in memory is `editorViewState`, which survives neither -// the window-state snapshot nor the cross-window payload -- so the anchor is -// recovered from it at those two gates. Everything below is about which tabs -// that applies to, because the tabs it must NOT apply to already have a better -// record than the view state. - -/** A view state as Monaco writes one, cut down to the field the derivation reads. */ -function viewStateAtLine(lineNumber: number) { - return { viewState: { firstPosition: { lineNumber, column: 1 }, firstPositionDeltaTop: 0 } }; -} - -function tab(over: Partial = {}): StoredTabPosition { - return { - id: 'background', - isEditing: true, - isSplit: false, - editorViewState: viewStateAtLine(40), - rawContent: 'body\n'.repeat(200), - anchorLine: asRendererLine(0), - ...over, - }; -} - -test('a background edit-only tab is recorded where its view state says it was', () => { - // This is the defect: nothing wrote `anchorLine` for this tab, so `0` is - // what would be persisted -- the top of the document. - assert.equal( - outgoingTabAnchorLine(tab(), 'some-other-tab'), - 40 + EDITOR_ANCHOR_LINE_OFFSET, - ); -}); - -test('the recovered anchor crosses out of the editor numbering, front matter and all', () => { - // `firstPosition.lineNumber` is a BUFFER line and the tab's anchor is a - // RENDERER line. The crossing has one home and this reuses it rather than - // making its own -- the defect shape `lineCoordinates.ts` exists to prevent. - const rawContent = '---\ntitle: t\n---\n\n' + 'body\n'.repeat(200); - assert.equal( - outgoingTabAnchorLine(tab({ rawContent, editorViewState: viewStateAtLine(40) }), null), - 40 + EDITOR_ANCHOR_LINE_OFFSET - 4, - ); -}); - -test('a tab that left edit mode keeps the position its preview wrote', () => { - // THE regression this change could introduce. Nothing clears - // `editorViewState` when a tab leaves edit mode, so a tab the reader edited, - // switched to reading mode and then scrolled carries a stale view state - // under a fresh `anchorLine`. Deriving there would replace a correct record - // with an older one, on the commoner path. - const stale = tab({ isEditing: false, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(stale, 'some-other-tab'), 120); -}); - -test('a split tab keeps the position its preview wrote', () => { - // A split tab has its preview on screen writing `anchorLine` on every scroll - // event, and the editor beside it is scrolled independently unless - // `isScrollSynced`. Its record is a real reading position; the view state is - // a second opinion about a different pane. - const split = tab({ isSplit: true, anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(split, 'some-other-tab'), 120); -}); - -test('the active tab keeps what the live editor just flushed onto it', () => { - // #735 flushes the active tab from the live editor immediately before both - // gates, and that reading is the better one AND a different one: Monaco's - // `saveViewState` records the first PARTIALLY visible line, while - // `getVisibleRanges()` -- what the flush reads -- starts at the first - // completely visible one. Deriving over the flush would move the active tab - // by a line at most scroll positions. - const active = tab({ id: 'live', anchorLine: asRendererLine(120) }); - assert.equal(outgoingTabAnchorLine(active, 'live'), 120); -}); - -test('a tab with no saved view state is left exactly as it is', () => { - // Restored from disk, or transferred in: `editorViewState` starts null on - // both routes and there is nothing to recover from. - const fresh = tab({ editorViewState: null, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(fresh, null), 7); -}); - -test('a view state written by an older Monaco is left exactly as it is', () => { - // `firstPosition` is what `reduceRestoreState` falls back from when it is - // missing, so the shape is real and reachable rather than defensive. - const legacy = tab({ editorViewState: { scrollTop: 900 }, anchorLine: asRendererLine(7) }); - assert.equal(outgoingTabAnchorLine(legacy, null), 7); -}); - -test('a percentage is not invented to go with the anchor', () => { - // The view state carries `scrollTop` but neither height, so no fraction of - // the scrollable range can be computed from it. `anchorLine` is the first - // entry of both restore cascades, so recovering it is what decides where the - // tab opens; writing a percentage nobody can justify is not. - const source = readSource('src/lib/utils/editorPosition.ts'); - const derivation = sliceFrom(source, 'export function outgoingTabAnchorLine'); - assert.doesNotMatch(derivation, /scrollPercentage/); -}); - -test('both gates that drop the view state ask for the anchor first', () => { - // The two moments a tab's position leaves memory. Neither carries - // `editorViewState`: `serializeState` omits it and `restoreState` seeds - // null, and `tabTransfer.ts` excludes it as a live Monaco object. - const store = readSource('src/lib/stores/tabs.svelte.ts'); - const serialize = sliceFrom(store, 'serializeState(extra: Record = {}): string {'); - assert.match(serialize, /anchorLine: outgoingTabAnchorLine\(t, this\.activeTabId\)/); - - const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const payload = sliceFrom(viewer, 'transferPayload: (tabId) => {'); - assert.match(payload, /anchorLine: outgoingTabAnchorLine\(tab, tabManager\.activeTabId\)/); - // After the spread, or the snapshot's own field would win. - assert.ok(offsetOf(payload, '...snapshotTab(tab)') < offsetOf(payload, 'outgoingTabAnchorLine')); -}); diff --git a/scripts/calloutMarker.test.ts b/scripts/calloutMarker.test.ts deleted file mode 100644 index a993d900..00000000 --- a/scripts/calloutMarker.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { installShimDom, parseHtml } from './renderProtocolDom.ts'; - -installShimDom(); - -const { processMarkdownHtml } = await import('../src/lib/utils/markdown.ts'); - -// Only the text a quote opens with can make it a callout, as in Obsidian and -// GitHub. The marker used to be looked for in every text node of the quote, so -// a `[!note]` shown in inline code, a code block or a nested quote turned the -// whole quote into a callout and the matched text was deleted. - -function render(html: string) { - return parseHtml(processMarkdownHtml(html, '/doc.md', new Set())); -} - -test('a marker in inline code is text, not a callout', () => { - const root = render('
\n

Use [!note] to make a callout

\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!note]'); -}); - -test('a marker at the top of a code block is text, not a callout', () => { - const root = render('
\n
[!warning] Title\n
\n
'); - assert.equal(root.querySelector('.markdown-alert'), null); - assert.equal(root.querySelector('code')?.textContent, '[!warning] Title\n'); -}); - -test('a callout in a nested quote leaves the outer quote alone', () => { - const root = render( - '
\n

Outer

\n
\n

[!tip] Inner

\n
\n
', - ); - const outer = root.querySelector('blockquote'); - assert.ok(outer, 'the outer quote is still a quote'); - assert.equal(outer.querySelector('p')?.textContent, 'Outer'); - const tip = outer.querySelector('.markdown-alert-tip'); - assert.ok(tip, 'the inner quote is the callout'); - assert.equal(tip.querySelector('.callout-title-inner')?.textContent, 'Inner'); -}); - -test('a quote that opens with the marker is still a callout, fold and title included', () => { - const root = render('
\n

[!note]+ My title
\nbody

\n
'); - const callout = root.querySelector('.markdown-alert-note'); - assert.ok(callout); - assert.ok(callout.classList.contains('callout-foldable')); - assert.equal(callout.querySelector('.callout-title-inner')?.textContent, 'My title'); - assert.match(callout.querySelector('.content-inner')?.textContent ?? '', /body/); -}); diff --git a/scripts/checkedReadMigration.spec.ts b/scripts/checkedReadMigration.spec.ts deleted file mode 100644 index 5e25bb82..00000000 --- a/scripts/checkedReadMigration.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * The behavioural half of `checkedReadMigration.test.ts`: `ensureFullContent`, - * driven for real. - * - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * These three tests are here rather than in the `.test.ts` because they run the - * store and the session, and both are runes modules. Under `node --test` they - * only ran at all because the file installed `$state`/`$derived`/`$effect` onto - * `globalThis` itself, and a hand-written rune is not the compiler's: no deep - * proxying, no `$derived` laziness, and `$effect` never re-runs. `tab.isTruncated` - * and `tab.hasReplacementChars` are read straight back out of the store here, - * so the shim was standing exactly where the assertions look. - * - * The six that stayed behind are the two never-migrate categories: that the - * unchecked command exists nowhere in `src` and is gone from the Rust crate, - * and the four `.svelte` source-shape assertions about the component call - * sites. Nothing this file could run would observe any of them. - */ - -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -const PARTIAL = 'first half'; -const FULL = 'first half and the rest'; - -/** Set per test. `get_os_type` is the settings store booting on import. */ -let handleInvoke: (cmd: string, args: Record) => unknown = (cmd) => { - if (cmd === 'get_os_type') return 'macos'; - throw new Error(`unexpected invoke: ${cmd}`); -}; -const errors: string[] = []; - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin. Only the Tauri backend, which jsdom cannot provide, is stubbed. -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => - Promise.resolve(handleInvoke(command.replace(/^plugin:[^|]*\|/, ''), args ?? {})), - transformCallback: (fn: unknown) => fn, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '

x

', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message) => errors.push(message), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** Open a >50KB file and leave the background full read pending forever. */ -async function openPartial() { - tabManager.closeAll(); - errors.length = 0; - handleInvoke = (cmd) => { - if (cmd === 'open_markdown_preview') return [PARTIAL, false, false]; - if (cmd === 'read_file_content_checked') return new Promise(() => {}); - throw new Error(`unexpected invoke: ${cmd}`); - }; - const session = makeSession(); - await session.loadMarkdown('/docs/big.md'); - const tab = tabManager.activeTab!; - assert.equal(tab.isTruncated, true, 'precondition: the buffer is partial'); - assert.equal(tab.hasReplacementChars, false, 'precondition: the preview decoded cleanly'); - return { session, tab }; -} - -test("completing a partial buffer carries the tail's own verdict", async () => { - // The case the old comment called safe: the preview covered the first 50KB - // and decoded cleanly, but a file can be valid UTF-8 up to there and not - // after. With the bare command the tab kept the preview's verdict and the - // next auto-save wrote U+FFFD over the file. - const { session, tab } = await openPartial(); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.rawContent, FULL); - assert.equal(tab.hasReplacementChars, true, 'the completed buffer must carry its own fidelity'); -}); - -test('completing a clean tail also clears a stale flag', async () => { - // The same mechanism in the other direction: a verdict that is decided on - // every read cannot go stale. - const { session, tab } = await openPartial(); - tabManager.setTabDecodedLossy(tab.id, true); - - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, false]; - throw new Error(`unexpected invoke: ${cmd}`); - }; - - assert.equal(await session.ensureFullContent(tab.id), true); - assert.equal(tab.hasReplacementChars, false); -}); - -test('the completed buffer is refused or accepted according to that verdict', async () => { - // End to end: the flag is not decoration, it decides the write. - const { session, tab } = await openPartial(); - handleInvoke = (cmd) => { - if (cmd === 'read_file_content_checked') return [FULL, true]; - if (cmd === 'save_file_content') return null; - throw new Error(`unexpected invoke: ${cmd}`); - }; - await session.ensureFullContent(tab.id); - - assert.equal(await session.saveContent(tab.id), false, 'a lossy buffer must not overwrite its file'); - assert.equal(session.isLossySaveRefused(tab.id), true); - assert.ok(errors.length > 0, 'and the user is told once'); -}); diff --git a/scripts/checkedReadMigration.test.ts b/scripts/checkedReadMigration.test.ts deleted file mode 100644 index 536bfd87..00000000 --- a/scripts/checkedReadMigration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { offsetOf, readRustBackend, readSource, readSourceFiles, sliceBetween } from './sourceTree.js'; - -/* - * #379 made every read that fills a WRITABLE buffer report whether the decode - * was lossy, so the tab can refuse to write U+FFFD back over a file that was - * merely in another encoding. Three reads kept the bare command: - * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and - * the split pane. They were safe — but only because each re-read a file whose - * tab `loadMarkdown` had already flagged. That is an invariant held by call - * sites agreeing with each other, and the failure mode when one stops agreeing - * is a destroyed document. It is now held by the code: every one of them reads - * the fidelity itself. - * - * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST - * - * Every assertion below reads source text, and each one is of a kind no run can - * reach: - * - * - that the unchecked command exists NOWHERE. An absence is not observable - * at run time: executing the checked read cannot prove there is no other - * caller of the unchecked one. The same claim then crosses into the Rust - * crate, where nothing but the spelling connects the two sides. - * - the two MarkdownViewer call sites, and the two auto-save ones. They are - * in a `.svelte` component, and mounting one here would put jsdom in for - * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — - * what these pin is the ORDER of statements inside a handler (flag the tab - * before the buffer is published; recognise the refusal before raising the - * generic toast), which is a property of the code as written. - * - * `ensureFullContent` is not here: it is a runes module driven for real, and - * running it under a hand-written `$state` was standing exactly where the - * assertions look. It is `checkedReadMigration.spec.ts` now. `lossyDecodeSaveGuard.test.ts` - * and `lossySaveRefusalScope.spec.ts` cover the store behaviour these source - * assertions deliberately stop short of. - * - * The second half of this file is the toast the guard's refusal used to - * trigger every 1.5 seconds. - */ - -// --- the two component call sites ------------------------------------------- - -const viewer = readSource('src/lib/MarkdownViewer.svelte'); - -test('the unchecked read command is gone, and nothing calls it', () => { - // This was a three-file allowlist — the files #379 migrated. That is the - // wrong shape for a rule that means "nobody, anywhere": a fourth file - // added the call and the test still passed. Two changes fix it. The scan - // below covers the whole tree, so a new file cannot slip under it. And the - // Rust command itself is deleted, which is what turns the rule from a - // convention into a fact: `read_file_content` is not registered any more, - // so invoking it fails loudly instead of quietly filling a buffer with - // mojibake nothing flagged. - const offenders = readSourceFiles('src') - .filter(({ text }) => /invoke\('read_file_content'/.test(text)) - .map(({ path }) => path); - assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); - - const rust = readRustBackend(); - assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); - assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); -}); - -test('entering the editor reads the fidelity and stores it', () => { - const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); - assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const read = offsetOf(toggle, 'read_file_content_checked'); - const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); - assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); -}); - -test('entering split view reads the fidelity and stores it', () => { - const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); - assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); - const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); - const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); - assert.ok(flag < store, 'flag the tab before the buffer is published'); -}); - -// --- the repeating toast ------------------------------------------------------ - -test('the session can tell a refusal from a failure', () => { - // `saveContent` returns false for both, which is why the auto-save timer - // could not tell them apart. The predicate is no longer set membership on - // its own: the set records what was SAID, and whether the refusal still - // stands is asked of the tab. `lossySaveRefusalScope.spec.ts` exercises - // both halves for real; this only pins that the tab is consulted at all, - // since a predicate that answers from memory alone is the defect. - const body = sliceBetween( - readSource('src/lib/sessions/documentSession.svelte.ts'), - 'function isLossySaveRefused(', - 'function updateLoading', - ); - assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); - assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); -}); - -test('a refused save does not add a generic toast to its own explanation', () => { - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); - const toast = offsetOf(body, "t('toast.autoSaveFailed'"); - assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); -}); - -test('a tab that can only be refused stops re-arming the timer', () => { - // Auto-save re-arms on every keystroke. Without this the guard was reached - // again every 1.5s for as long as the user kept typing — each time - // producing the console warning, the wasted round trip, and (before the - // test above) the toast. - const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); - assert.match(body, /decodedLossily: tab\.hasReplacementChars/); - assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); - // The FIRST attempt must still happen: it is what produces the explanation. - // `isLossySaveRefused` is false until the guard has spoken, so eligibility - // only drops afterwards — and "Save As" to a new file clears - // `hasReplacementChars`, which restores it. - assert.match( - readSource('src/lib/sessions/documentSession.svelte.ts'), - /lossySaveWarnedTabs\.delete\(tab\.id\)/, - ); -}); diff --git a/scripts/ciCacheBudget.test.ts b/scripts/ciCacheBudget.test.ts deleted file mode 100644 index 49615255..00000000 --- a/scripts/ciCacheBudget.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -const workflows = ['build.yml', 'test.yml', 'test_build.yml', 'publish-packages.yml'].map( - (name) => ({ name, text: readSource(`.github/workflows/${name}`) }), -); - -// GitHub gives a repository 10 GB of Actions cache and evicts -// least-recently-used over it. Both assertions below exist because that budget -// was measured being exceeded — 13.22 GB across 117 entries — and because the -// entries that overflow it are evicted in the order that hurts most: the -// largest first, which is exactly the cargo caches the budget is being spent -// on. Neither of these is a law of nature; both are trades with numbers behind -// them, recorded in the workflow comments. Change them by re-doing the -// measurement, not by deleting the assertion. - -test('a cargo cache is written on master and nowhere else', () => { - // Without `save-if` every pull request writes its own copy, and a copy is - // 1.0-1.2 GB per platform: one run of #572 wrote 4.45 GB. Two pull requests - // in flight overflow the budget on their own, and what gets evicted is the - // cargo caches — so the cache becomes a slower way of not caching. - // - // This is also why test_build.yml runs on push to master. The two are one - // decision: `save-if` makes master the only writer, so master has to run. - for (const { name, text } of workflows) { - const uses = [...text.matchAll(/uses: Swatinem\/rust-cache@v2\n([\s\S]{0,400}?)(?=\n\s*- name:|\n\n)/g)]; - for (const [, block] of uses) { - assert.match( - block, - /save-if: \$\{\{ github\.ref == 'refs\/heads\/master' \}\}/, - `${name} caches cargo output without restricting the save to master`, - ); - } - } -}); - -test('setup-node does not cache npm', () => { - // It was the largest consumer of the same budget: 113 entries, 8.76 GB, - // across 50 refs, most of them pull requests closed weeks earlier. Caches - // are scoped by ref, so one is written per branch per platform and nothing - // can bound it the way `save-if` bounds the cargo caches. - // - // What it bought, on runs that hit it: `npm ci` in 6s on Linux, 9s on - // macOS, 15-17s on Windows. It caches `~/.npm`, not `node_modules`, so - // `npm ci` still runs and still links every package — the saving is the - // registry fetch alone. - for (const { name, text } of workflows) { - assert.doesNotMatch( - text, - /^\s*cache: '?npm'?\s*$/m, - `${name} re-enables the npm cache; re-read the budget note in test.yml first`, - ); - } -}); diff --git a/scripts/clipboardOwnershipX11.test.ts b/scripts/clipboardOwnershipX11.test.ts deleted file mode 100644 index 79f2b30a..00000000 --- a/scripts/clipboardOwnershipX11.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource, sliceBetween } from './sourceTree.js'; - -// #729: on Debian XFCE, everything copied out of Markpad reached an empty -// clipboard, while pasting into it worked. X11 has no clipboard store — the -// copying process owns the CLIPBOARD selection and serves the bytes on -// request — and arboard tears that ownership down when the last `Clipboard` -// handle drops, which was the moment `clipboard_write_text` returned. -// -// A source-shape assertion because the proposition cannot be run: the failure -// needs an X server and a desktop without a clipboard manager, and CI has -// neither. It names the helper on purpose; nothing but the call connects the -// two, and the function exists for no other reason. -test('the copy command keeps X11 clipboard ownership past its own return', () => { - const body = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'pub fn clipboard_write_text', - '\n}', - ); - assert.match(body, /retain_clipboard_ownership\(\)/); -}); - -test('the retained handle is never dropped', () => { - // `retain_clipboard_ownership` holding a `Clipboard` in a local would be a - // no-op: the point is that the handle outlives the call, so the invisible - // window arboard serves selection requests from is never destroyed. - const helper = sliceBetween( - readSource('src-tauri/src/commands.rs'), - 'fn retain_clipboard_ownership', - '\n}', - ); - assert.match(helper, /std::mem::forget\(/); -}); diff --git a/scripts/codeFence.test.ts b/scripts/codeFence.test.ts deleted file mode 100644 index e4f56460..00000000 --- a/scripts/codeFence.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { isInFencedCode } from '../src/lib/utils/codeFence.js'; - -/** Every line of `text`, 1-based, answered in order. */ -function inside(text: string): boolean[] { - const lines = text.split('\n'); - const doc = { getLineContent: (line: number) => lines[line - 1] }; - return lines.map((_, index) => isInFencedCode(doc, index + 1)); -} - -test('the lines after an opening fence are inside it, up to and including the closing fence', () => { - assert.deepEqual(inside('a\n```\n1. x\n```\nb'), [false, false, true, true, false]); - assert.deepEqual(inside('~~~ts\n|a|\n~~~\n|a|'), [false, true, true, false]); -}); - -test('only the same character, at least as long, with nothing after it, closes', () => { - assert.deepEqual(inside('````\n```\nx\n````\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n~~~\nx\n```\ny'), [false, true, true, true, false]); - assert.deepEqual(inside('```\n``` ts\nx\n``` \ny'), [false, true, true, true, false]); -}); - -test('an unclosed fence runs to the end of the document', () => { - assert.deepEqual(inside('```\na\nb'), [false, true, true]); -}); - -test('a backtick run with a backtick after it is inline code, not a fence', () => { - assert.deepEqual(inside('```a```\n1. x'), [false, false]); - // A tilde fence's info string may say anything. - assert.deepEqual(inside('~~~ a`b\nx\n~~~'), [false, true, true]); -}); - -test('a fence nested in a list item or a quote counts; two backticks do not', () => { - assert.deepEqual(inside('1. a\n ```\n 2. b\n ```\n3. c'), [false, false, true, true, false]); - assert.deepEqual(inside('> ```\n> - x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('``\n- x'), [false, false]); -}); - -test('a fence opened on the list marker line counts, and so does its closing line', () => { - assert.deepEqual(inside('- item\n- ```js\n code\n ```\n- next\n - child'), [false, false, true, true, false, false]); - assert.deepEqual(inside('1. ```bash\n ls\n ```\n2. b'), [false, true, true, false]); - assert.deepEqual(inside('* [ ] ```\n x\n ```\n+ y'), [false, true, true, false]); - // Containers nest: a quote holding a list, a list holding a list. - assert.deepEqual(inside('> - ```\n> x\n> ```\n- y'), [false, true, true, false]); - assert.deepEqual(inside('> 1. - ```\n> x\n> ```\n- y'), [false, true, true, false]); -}); - -test('a list item with inline code spans is not a fence', () => { - assert.deepEqual(inside('- use ```x``` here\n- y'), [false, false]); - assert.deepEqual(inside('- ```x```\n- y'), [false, false]); -}); - -test('inside a fence a marker line is code, never the close', () => { - // A Markdown sample showing a fence on a list item, and a diff that deletes - // one: both lines are code, and taking them for the close inverted the rest. - assert.deepEqual(inside('```md\n- ```\n x\n```\n- item\n- item2'), [false, true, true, true, false, false]); - assert.deepEqual(inside('```diff\n- ```\n+ ```ts\n```\n- item'), [false, true, true, true, false]); -}); - -test('a task box opens a fence only after a list marker', () => { - assert.deepEqual(inside('[x] ```\na\nb'), [false, false, false]); -}); diff --git a/scripts/coldStartDoesNotStealFocus.test.ts b/scripts/coldStartDoesNotStealFocus.test.ts deleted file mode 100644 index 787bdb51..00000000 --- a/scripts/coldStartDoesNotStealFocus.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readRustBackend } from './sourceTree.js'; - -const backend = readRustBackend(); - -function showWindowSource(): string { - const match = /pub async fn show_window\([\s\S]*?\n\}/.exec(backend); - assert.ok(match, 'show_window is gone: this file no longer states anything'); - return match[0]; -} - -test('the launch window is shown without being activated on a cold start', () => { - const showWindow = showWindowSource(); - - // The defect (#702): the window is built hidden and revealed once the - // frontend mounts, seconds after launch, and the reveal called `set_focus` - // unconditionally — pulling the user out of whatever they had switched to. - // The label and the one-shot flag together are what make it the FIRST show - // of the LAUNCH window that stays quiet. - assert.match(showWindow, /window\.label\(\)\s*==\s*LAUNCH_LABEL/); - assert.match(showWindow, /MAIN_WINDOW_SHOWN\.swap\(true,/); - - // Not vacuous: the other callers — a detached tab window, the close walk - // needing its dialog visible — still activate, so this cannot pass by - // `set_focus` having been deleted outright. - assert.match(showWindow, /set_focus\(\)/); - - // And the quiet path returns before reaching it. - const guard = showWindow.indexOf('return;'); - const focus = showWindow.indexOf('set_focus()'); - assert.ok(guard > 0 && guard < focus, 'the cold-start path falls through to set_focus'); -}); - -test('no window builder opts out of focus', () => { - // `.focused(false)` on a window built hidden leaves WebView2 on Windows with - // no drop target, so every file dragged onto the window is refused (#768, - // tauri-apps/wry#1639). Comments are stripped first: the builder explains - // why the call is absent by naming it. - const code = backend.replace(/\/\/.*$/gm, ''); - const found = code.match(/\.focused\(false\)/g)?.length ?? 0; - assert.equal(found, 0, `found ${found} .focused(false) in the Rust backend`); -}); diff --git a/scripts/copyLabelFlash.spec.ts b/scripts/copyLabelFlash.spec.ts deleted file mode 100644 index b253125c..00000000 --- a/scripts/copyLabelFlash.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Clicking a code block's Copy label twice within 1.5 s left it reading - * "Copied!" for good: the second click saved "Copied!" as the label to restore. - */ - -import { afterEach, expect, test, vi } from 'vitest'; - -import { flashCopiedLabel } from '../src/lib/utils/richContent.js'; - -afterEach(() => { - vi.useRealTimers(); -}); - -test('a second copy inside the 1.5 s still restores the language name', () => { - vi.useFakeTimers(); - const label = document.createElement('button'); - label.textContent = 'typescript'; - - flashCopiedLabel(label); - vi.advanceTimersByTime(500); - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); - expect(label.classList.contains('copied')).toBe(false); - - // And the label still works after that. - flashCopiedLabel(label); - expect(label.innerHTML).toBe('Copied!'); - vi.advanceTimersByTime(1500); - expect(label.innerHTML).toBe('typescript'); -}); diff --git a/scripts/copyReferenceStyle.test.ts b/scripts/copyReferenceStyle.test.ts deleted file mode 100644 index 55f9bce1..00000000 --- a/scripts/copyReferenceStyle.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * "Copy Reference" writes the spelling the document is already written in. - * - * Markpad understands two ways to link a heading — `[[note#Setup]]`, which it - * rewrites before the parse, and `[Setup](note.md#setup)`, which every reader - * resolves — and this menu always produced the first. Which one is right - * depends on the person, not on the app: a vault full of `[[…]]` wants - * another one; a document written for GitHub wants a link that survives - * leaving Markpad. - * - * The document is the evidence, because a person's habit is already written - * down in the file they are working in. The inference has a floor: says - * nothing, or says both, and the answer is what the menu always produced. So - * it can be better than the old behaviour and never worse — which is the only - * reason inferring is defensible here at all. - * - * Known and accepted: a reference is usually pasted into a DIFFERENT document, - * so following this one is a guess about that one. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { readSource } from './sourceTree.js'; - -import { - DEFAULT_REFERENCE_STYLE, - headingReference, - preferredReferenceStyle, -} from '../src/lib/utils/headingReference.js'; - -const viewerSource = readSource(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url)); - -/* ------------------------------------------------------ reading the document */ - -test('a document that links one way gets another of the same', () => { - assert.equal( - preferredReferenceStyle('See [[Setup#Install]] for details.\n'), - 'wikilink', - ); - assert.equal( - preferredReferenceStyle('See [Install](setup.md#install) for details.\n'), - 'inline', - ); - // A same-document anchor counts as well — it is the same spelling. - assert.equal(preferredReferenceStyle('Back to [the top](#introduction).\n'), 'inline'); - // And a bare note link, which the renderer leaves literal, still says what - // the writer's habit is. - assert.equal(preferredReferenceStyle('See [[Setup]].\n'), 'wikilink'); -}); - -test('silence and disagreement both fall back to what the menu always did', () => { - assert.equal(DEFAULT_REFERENCE_STYLE, 'wikilink'); - assert.equal(preferredReferenceStyle(''), DEFAULT_REFERENCE_STYLE); - assert.equal(preferredReferenceStyle('# A document\n\nWith no links at all.\n'), DEFAULT_REFERENCE_STYLE); - assert.equal( - preferredReferenceStyle('Both [[Setup#Install]] and [Install](setup.md#install).\n'), - DEFAULT_REFERENCE_STYLE, - 'a document using both is not evidence of either', - ); - // An ordinary link with no anchor is not evidence: every document has those. - assert.equal(preferredReferenceStyle('See [the site](https://example.com).\n'), DEFAULT_REFERENCE_STYLE); -}); - -test('a document ABOUT markdown does not vote with its examples', () => { - // `samples/stress-test.md` and this project's own notes are full of - // both spellings inside fences. Counting those would make the syntax - // documentation the loudest voice in every vault. - const teaching = [ - '# Linking', - '', - '```markdown', - '[[Setup#Install]]', - '```', - '', - 'Prose with [an anchor](setup.md#install) in it.', - '', - ].join('\n'); - - assert.equal(preferredReferenceStyle(teaching), 'inline', 'the fenced example must not count'); - - const tildeFenced = '~~~md\n[[Setup#Install]]\n~~~\n\nSee [x](a.md#b).\n'; - assert.equal(preferredReferenceStyle(tildeFenced), 'inline', 'tilde fences too'); -}); - -/* ------------------------------------------------------------ writing it out */ - -test('each style is written the way its own resolver reads it', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: 'notes.md' as string | null }; - - // The wikilink drops the extension — `process_wikilinks` appends `.md` - // itself — and names the heading by its TEXT. - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '[[notes#Setup]]'); - // The CommonMark form keeps it, because that is how the frontend claims a - // link for local navigation, and names the heading by its rendered id. - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](notes.md#setup)'); -}); - -test('the wikilink drops only an extension the renderer would add back', () => { - // `process_wikilinks` appends `.md` to a target with no extension, so - // `[[note#Setup]]` reaches `note.md` and never `note.markdown`. - const reference = (fileName: string) => - headingReference({ text: 'Setup', slug: 'setup', fileName, style: 'wikilink' }); - assert.equal(reference('note.markdown'), '[[note.markdown#Setup]]'); - assert.equal(reference('notes.txt'), '[[notes.txt#Setup]]'); - // Left as `v1.2`, the target would read as having a `.2` extension. - assert.equal(reference('v1.2.md'), '[[v1.2.md#Setup]]'); - assert.equal(reference('v1.2 spec.md'), '[[v1.2 spec#Setup]]'); -}); - -test('an unsaved document has no file to name', () => { - const heading = { text: 'Setup', slug: 'setup', fileName: null }; - assert.equal(headingReference({ ...heading, style: 'wikilink' }), '#Setup'); - assert.equal(headingReference({ ...heading, style: 'inline' }), '[Setup](#setup)'); -}); - -test('the CommonMark form survives a filename with a space and a bracketed heading', () => { - // `[Setup](my notes.md#setup)` stops at the space; `[Notes [draft](…)` ends - // its label early. Neither can happen in the wikilink form, which is part - // of why this only applies to one of them. - assert.equal( - headingReference({ text: 'Setup', slug: 'setup', fileName: 'my notes.md', style: 'inline' }), - '[Setup]()', - ); - assert.equal( - headingReference({ text: 'Notes [draft', slug: 'notes-draft', fileName: null, style: 'inline' }), - '[Notes \\[draft](#notes-draft)', - ); -}); - -test('a repeated heading is addressable, because the slug comes from the render', () => { - // comrak numbers them; recomputing the id here would drift from that. - assert.equal( - headingReference({ text: 'Objectives', slug: 'objectives-1', fileName: 'n.md', style: 'inline' }), - '[Objectives](n.md#objectives-1)', - ); -}); - -/* ---------------------------------------------------------------- the wiring */ - -test('all three entries go through one helper, which reads the buffer', () => { - // The preview's heading menu, the outline's right-click, and the outline's - // context menu. All three built the string inline before, which is how they - // came to disagree about the filename in the first place. - assert.equal((viewerSource.match(/copyHeadingReference\(/g) ?? []).length, 4, 'one helper, three callers'); - assert.match(viewerSource, /style: preferredReferenceStyle\(tab\?\.rawContent \?\? ''\)/); - // The id is taken off the rendered element rather than recomputed. - assert.match(viewerSource, /heading\.id \|\| heading\.querySelector\('a\.anchor'\)\?\.id/); -}); diff --git a/scripts/diagramCache.test.ts b/scripts/diagramCache.test.ts deleted file mode 100644 index 455113ca..00000000 --- a/scripts/diagramCache.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** - * The preview re-renders the whole article on a 16ms debounce — in split mode, - * and in edit mode with the TOC open, that is about once per keystroke — and - * `{@html sanitizedHtml}` swaps the entire block, which puts every - * `
` back. So every diagram was redrawn from
- * scratch on every keystroke. Measured in a headless browser on a document with
- * 44 diagrams and 132 code blocks, one pass cost 2.2–2.5s, of which
- * `mermaid.render` was all but ~30ms.
- *
- * These tests pin the two halves of the fix that can go wrong quietly:
- *
- *   - The *cache*: what must miss (a changed character, a changed theme) and
- *     what the bounds are. A memo that never misses is a correctness bug and a
- *     memo that never evicts is a leak; neither shows up as a failure on screen.
- *   - The *re-identification*: Mermaid bakes the id it is handed into the
- *     diagram's `` +
-		`` +
-		`` +
-		extra +
-		`
${source.trim()}
` + - `` - ); -} - -function libraries(record: Recorder, extraFor: (source: string, id: string) => string = () => '') { - let theme = 'neutral'; - return { - hljs: { getLanguage: () => null }, - // The renderer refuses to run without these; the diagrams are what is - // under test, so they do nothing. - katex: { renderToString: () => '' }, - renderMathInElement() {}, - mermaid: { - initialize(config: { theme: string }) { - theme = config.theme; - record.themes.push(config.theme); - }, - async render(id: string, source: string) { - record.renders.push({ id, source, theme }); - return { svg: svgFor(id, source, theme, extraFor(source, id)) }; - }, - }, - }; -} - -function articleWith(...sources: string[]): string { - return sources - .map((source) => `
${source}
`) - .join('\n'); -} - -function newRecorder(): Recorder { - return { renders: [], themes: [] }; -} - -let idCounter = 0; -/** Unique per call, like the real one, and readable in a failure message. */ -function ids(): (index: number) => string { - const run = idCounter++; - return (index: number) => `mermaid-${run}-${index}`; -} - -async function render( - html: string, - options: { record: Recorder; theme?: string; extraFor?: (source: string, id: string) => string }, -): Promise { - const root = (globalThis as any).document.createElement('div'); - root.innerHTML = html; - await renderRichContent({ - roots: [root], - libraries: libraries(options.record, options.extraFor) as any, - mermaidTheme: options.theme ?? 'neutral', - idFactory: ids(), - }); - return root; -} - -function idsIn(html: string): string[] { - return [...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1]); -} - -// ------------------------------------------------------------------- tests - -test('a second pass over the same document draws nothing again', async () => { - resetDiagramCache(); - const article = articleWith('graph TD; A-->B;', 'graph LR; C-->D;'); - - const first = newRecorder(); - const before = await render(article, { record: first }); - assert.equal(first.renders.length, 2); - - const second = newRecorder(); - const after = await render(article, { record: second }); - assert.deepEqual(second.renders, [], 'the second pass must not call mermaid.render at all'); - - // Same drawing, not just the same count: identifiers aside, the reused SVG - // is what Mermaid produced. - const normalise = (html: string) => - html - .replace(/\sid="[^"]*"/g, ' id="X"') - .replace(/url\(#[^)]*\)/g, 'url(#X)') - .replace(/#mermaid-[\w-]*/g, '#X'); - assert.equal(normalise(after.innerHTML), normalise(before.innerHTML)); -}); - -test('changing one character of a diagram redraws it', async () => { - resetDiagramCache(); - const warm = newRecorder(); - await render(articleWith('graph TD; A-->B;'), { record: warm }); - - const typed = newRecorder(); - await render(articleWith('graph TD; A-->Bc;'), { record: typed }); - assert.deepEqual( - typed.renders.map((call) => call.source), - ['graph TD; A-->Bc;'], - ); -}); - -test('changing the theme redraws every diagram, and the old theme is still warm', async () => { - resetDiagramCache(); - const source = 'graph TD; Theme-->Switch;'; - - const light = newRecorder(); - const lightRoot = await render(articleWith(source), { record: light, theme: 'neutral' }); - assert.equal(light.renders.length, 1); - assert.match(lightRoot.innerHTML, /fill:#eee/); - - const dark = newRecorder(); - const darkRoot = await render(articleWith(source), { record: dark, theme: 'dark' }); - assert.equal(dark.renders.length, 1, 'a theme switch must not reuse the other theme’s colours'); - assert.match(darkRoot.innerHTML, /fill:#333/); - - // Switching back is a hit: both themes live in the cache at once, so a user - // toggling appearance does not pay twice. - const back = newRecorder(); - const backRoot = await render(articleWith(source), { record: back, theme: 'neutral' }); - assert.deepEqual(back.renders, []); - assert.match(backRoot.innerHTML, /fill:#eee/); -}); - -test('two copies of one diagram never share an id', async () => { - resetDiagramCache(); - const source = 'graph TD; Same-->Twice;'; - const record = newRecorder(); - - // Even on the very first pass the second copy is a cache hit, so this is the - // case the cache creates and the case it has to get right. - const root = await render(articleWith(source, source), { record }); - assert.equal(record.renders.length, 1); - - const all = idsIn(root.innerHTML); - assert.equal(all.length, 4, 'two diagrams, each with an svg id and a marker id'); - assert.equal(new Set(all).size, 4, `duplicate ids in the document: ${all.join(', ')}`); - - // The re-identification has to be complete, not just applied to the ``: - // each copy's style block and marker reference must point at its own id. - const [firstSvg, secondSvg] = root.innerHTML.split('#${svgId} .node rect`), `style block not re-pointed for ${svgId}`); - assert.ok(svg.includes(`url(#${svgId}_flowchart-pointEnd)`), `marker not re-pointed for ${svgId}`); - } -}); - -test('a reused diagram still carries its source for the PDF re-render', async () => { - resetDiagramCache(); - const source = 'graph TD; Print-->Me;'; - await render(articleWith(source), { record: newRecorder() }); - - const reused = newRecorder(); - const root = await render(articleWith(source), { record: reused }); - assert.deepEqual(reused.renders, []); - // `mermaidPrint` finds diagrams by this attribute; a cached container without - // it would silently print dark diagrams on paper. - assert.match(root.innerHTML, /class="mermaid-diagram" data-mermaid-source="graph TD; Print-->Me;"/); -}); - -test('ids Mermaid does not namespace are namespaced by the cache', async () => { - resetDiagramCache(); - // A sequence diagram's actor ids come from a module counter, not from the - // svg id — two copies of one diagram would otherwise both say `actor7`. - const source = 'sequenceDiagram; A->>B: hi'; - const record = newRecorder(); - const root = await render(articleWith(source, source), { - record, - extraFor: () => '', - }); - - assert.equal(record.renders.length, 1); - const all = idsIn(root.innerHTML); - assert.equal(new Set(all).size, all.length, `duplicate ids in the document: ${all.join(', ')}`); - // The drawn copy keeps exactly what Mermaid emitted — the cache changes - // nothing about a miss. The reused copy is the one that has to be moved out - // of the way, and it lands under its own svg id. - assert.equal(all.filter((id) => id === 'actor7').length, 1); - assert.equal(all.filter((id) => id !== 'actor7' && id.endsWith('-actor7')).length, 1); -}); - -test('a diagram whose stray id is referenced is not cached at all', async () => { - resetDiagramCache(); - // The cache renames stray declarations but does not rewrite references to - // them. If a future Mermaid points at one, the honest answer is to redraw. - const source = 'graph TD; Referenced-->Stray;'; - const record = newRecorder(); - await render(articleWith(source), { - record, - extraFor: () => '', - }); - - const again = newRecorder(); - await render(articleWith(source), { - record: again, - extraFor: () => '', - }); - assert.equal(again.renders.length, 1, 'an un-templatable diagram must be redrawn, not reused'); -}); - -test('the cache is bounded by entry count', async () => { - resetDiagramCache(); - // 128 entries is the cap. Fill past it and the first one must be gone. - const first = 'graph TD; N0-->X;'; - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i <= 128; i++) { - await render(articleWith(`graph TD; N${i}-->X;`), { record: newRecorder() }); - } - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1, 'the oldest entry must have been evicted'); - - // …and the most recent one is still there. - const kept = newRecorder(); - await render(articleWith('graph TD; N128-->X;'), { record: kept }); - assert.deepEqual(kept.renders, []); -}); - -test('the cache is bounded by size, not only by count', async () => { - resetDiagramCache(); - // Eight ~400k-character diagrams are only eight entries but 3.2M characters, - // which is over the budget: a handful of enormous diagrams must not sit in - // memory forever just because they are few. - const bulk = (n: number) => `graph TD; Big${n}["${'x'.repeat(400_000)}"];`; - const first = bulk(0); - await render(articleWith(first), { record: newRecorder() }); - for (let i = 1; i < 8; i++) await render(articleWith(bulk(i)), { record: newRecorder() }); - - const evicted = newRecorder(); - await render(articleWith(first), { record: evicted }); - assert.equal(evicted.renders.length, 1); -}); - -test('a diagram is redrawn rather than reused when its own text spells out the slot', async () => { - resetDiagramCache(); - // The template marks where the id goes with a literal token. A diagram whose - // text contains that token cannot be templated without rewriting the user's - // own text, so it is left uncached. - const source = 'graph TD; A["{{markpad-diagram-id}}"];'; - await render(articleWith(source), { record: newRecorder() }); - const again = newRecorder(); - await render(articleWith(source), { record: again }); - assert.equal(again.renders.length, 1); -}); - -// ------------------------------------------------- the templating, on its own - -test('templating refuses anything it cannot re-identify', () => { - // The id it was told about is not the one in the markup: nothing to lift out. - assert.equal(templateDiagramSvg('', 'mine'), null); - // A referenced stray id. - assert.equal( - templateDiagramSvg('', 'mine'), - null, - ); - - const template = templateDiagramSvg('', 'mine'); - assert.ok(template); - assert.equal( - fillDiagramTemplate(template as string, 'fresh'), - '', - ); -}); diff --git a/scripts/diagramThemeRefresh.spec.ts b/scripts/diagramThemeRefresh.spec.ts deleted file mode 100644 index 18e82083..00000000 --- a/scripts/diagramThemeRefresh.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Switching theme has to re-draw the diagrams, and until this file existed it - * did not — in any mode, for any theme. - * - * Mermaid bakes fills, strokes and label colours into the SVG as attributes, so - * a stylesheet cannot retarget them; `mermaidPrint.ts` says why at length, and - * re-rendering is the answer it already reaches for on the way to a PDF. The - * preview's own theme change was supposed to do the same thing through - * `renderRichContent`, and could not: that function finds diagrams by - * `pre code`, and a diagram that has been drawn once has no `
` any more —
- * it was replaced by the `.mermaid-diagram` container holding the SVG.
- *
- * That was invisible while the preview was `{@html sanitizedHtml}`, because
- * every render put the whole document back as source. the block patch (#632)
- * keeps enriched nodes across renders instead, and from then on a theme change
- * reached no diagram at all. The `
` is gone, `patch.inserted` is empty on
- * a re-render of the same document, and typing does not help either: the
- * diagram's markup is unchanged, so the patch keeps the node.
- *
- * The libraries are stood in for at the same boundary `exportRichContent` and
- * `richContentIdempotence` use. What is under test is Markpad's bookkeeping:
- * which containers it decides are stale, and what it does to them.
- */
-
-import { describe, expect, test } from 'vitest';
-
-import { patchPreview } from '../src/lib/utils/previewPatch.js';
-import { readDiagramSource } from '../src/lib/utils/mermaidPrint.js';
-import { renderRichContent, type RichContentLibraries } from '../src/lib/utils/richContent.js';
-import { resetDiagramCache } from '../src/lib/utils/diagramCache.js';
-
-const SOURCE = 'graph TD;A-->B;';
-
-/** Records every draw so "did it re-render" is a count, not an inference. */
-function libraries(draws: string[]): RichContentLibraries {
-	let theme = 'unset';
-	return {
-		hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) },
-		katex: { renderToString: (source: string) => source },
-		renderMathInElement: () => {},
-		mermaid: {
-			initialize: (config: { theme: string }) => {
-				theme = config.theme;
-			},
-			render: async (id: string, source: string) => {
-				draws.push(`${theme}:${source}`);
-				return { svg: `` };
-			},
-		},
-	} as any;
-}
-
-function article(html: string): HTMLElement {
-	const element = document.createElement('div');
-	element.innerHTML = html;
-	document.body.replaceChildren(element);
-	return element;
-}
-
-async function render(root: HTMLElement, mermaidTheme: string, draws: string[], seed = 0) {
-	await renderRichContent({
-		roots: [root],
-		libraries: libraries(draws),
-		mermaidTheme,
-		idFactory: (index) => `d${seed}-${index}`,
-	});
-}
-
-function drawnFor(root: ParentNode): string | null {
-	return root.querySelector('.mermaid-diagram svg')?.getAttribute('data-drawn-for') ?? null;
-}
-
-const PRE = `
${SOURCE}
`; - -describe('a theme change', () => { - test('re-draws a diagram that is already on screen', async () => { - resetDiagramCache(); - const draws: string[] = []; - const root = article(PRE); - - await render(root, 'dark', draws); - expect(drawnFor(root)).toBe('dark'); - - // The whole-article pass a theme change makes. There is no `
` left.
-		expect(root.querySelectorAll('pre code').length).toBe(0);
-		await render(root, 'neutral', draws, 1);
-
-		expect(drawnFor(root)).toBe('neutral');
-		expect(draws).toEqual([`dark:${SOURCE}`, `neutral:${SOURCE}`]);
-	});
-
-	test('leaves the source on the container, so the PDF path still works', async () => {
-		resetDiagramCache();
-		const root = article(PRE);
-		await render(root, 'dark', []);
-		await render(root, 'neutral', [], 1);
-
-		expect(readDiagramSource(root.querySelector('.mermaid-diagram')!)).toBe(SOURCE);
-	});
-});
-
-describe('a pass that is not a theme change', () => {
-	test('does not re-draw a diagram already in the theme being asked for', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const root = article(PRE);
-
-		await render(root, 'dark', draws);
-		// `syncPreviewForPrint` and a re-activated tab both re-run this over the
-		// whole host without the theme having moved.
-		await render(root, 'dark', draws, 1);
-		await render(root, 'dark', draws, 2);
-
-		expect(draws).toEqual([`dark:${SOURCE}`]);
-	});
-
-	test('does not reach a diagram outside the roots it was given', async () => {
-		resetDiagramCache();
-		const draws: string[] = [];
-		const host = article(`${PRE}

text

`); - await render(host, 'dark', draws); - - // The keystroke path: the patch replaces the paragraph and hands only - // that block to the enrichment. The diagram is not in it, and a theme it - // no longer matches must not drag it into the pass. - const patch = patchPreview(document.createElement('div'), '

x

', '', new Set()); - await renderRichContent({ - roots: patch.inserted, - libraries: libraries(draws), - mermaidTheme: 'neutral', - idFactory: (index) => `k${index}`, - }); - - expect(draws).toEqual([`dark:${SOURCE}`]); - expect(drawnFor(host)).toBe('dark'); - }); -}); - -/** - * Mermaid's config is one global: `initialize` writes it at once, and `render` - * reads it only when its turn in Mermaid's own queue comes. The stand-in keeps - * that shape: one global theme, read after the draw has yielded. - */ -function queuedLibraries(): RichContentLibraries { - let theme = 'unset'; - return { - hljs: { getLanguage: () => undefined, highlight: () => ({ value: '' }) }, - katex: { renderToString: (source: string) => source }, - renderMathInElement: () => {}, - mermaid: { - initialize: (config: { theme: string }) => { - theme = config.theme; - }, - render: async (id: string) => { - await new Promise((resolve) => setTimeout(resolve, 0)); - return { svg: `` }; - }, - }, - } as any; -} - -test('two passes in different themes each draw in their own', async () => { - resetDiagramCache(); - const mermaid = queuedLibraries(); - // A preview pass and a PDF export's pass over another copy, overlapping. - const preview = article(`${PRE}
graph LR;C-->D;
`); - const print = document.createElement('div'); - print.innerHTML = PRE; - - await Promise.all([ - renderRichContent({ roots: [preview], libraries: mermaid, mermaidTheme: 'dark', idFactory: (i) => `p${i}` }), - renderRichContent({ roots: [print], libraries: mermaid, mermaidTheme: 'neutral', idFactory: (i) => `x${i}` }), - ]); - - const themes = (root: ParentNode) => - [...root.querySelectorAll('.mermaid-diagram svg')].map((svg) => svg.getAttribute('data-drawn-for')); - expect(themes(preview)).toEqual(['dark', 'dark']); - expect(themes(print)).toEqual(['neutral']); -}); - -/** - * The wiring, which the tests above cannot reach: `renderRichContent` now knows - * how to re-draw a stale diagram, but the theme effect still has to call it, in - * every mode and once per theme change. - * - * A source assertion, because the caller is a Svelte effect that cannot be - * imported. It pins today's spelling of an internal call, so a rename breaks it - * without breaking anything real; what it is here for is the guard, which was - * `markdownBody && !isEditing`. That made the whole effect — the `dataset` - * writes, `monaco.editor.setTheme`, and for a `vscode:` theme an - * `invoke('read_vscode_theme')` and a re-parse — re-run on every mode toggle - * and on every tab switch that changed the mode, with nothing about the theme - * having moved. And it excluded from the re-colour every tab in edit mode, - * including a split tab entered from edit mode, whose preview is on screen. - */ -test('the theme effect re-colours in every mode, and does not re-run on a mode change', async () => { - const { readSource } = await import('./sourceTree.js'); - const viewer: string = readSource('src/lib/MarkdownViewer.svelte'); - - const effect = viewer.match( - /\t\$effect\(\(\) => \{\n\t\t\/\/ Persistence and cross-window sync[\s\S]*?\n\t\}\);/, - )?.[0]; - expect(effect, 'the theme effect must still be recognisable').toBeTruthy(); - - // The mode is not a reason to re-apply a theme, and this is the only read - // that ever made it one. - expect(effect).not.toMatch(/isEditing/); - expect(effect).toMatch( - /const recolourDiagrams = \(\) => untrack\(\(\) => \{ if \(markdownBody\) renderRichContent\(\); \}\);/, - ); - - // Once per branch: the `vscode:` branch does not know its own appearance - // until `parseAndApplyVscodeTheme` has published `dataset.themeType`, which - // is what `currentMermaidTheme` reads. - expect(effect!.match(/recolourDiagrams\(\);/g)).toHaveLength(2); - expect(effect).toMatch(/loadVscodeTheme\([\s\S]*?recolourDiagrams\(\);/); -}); diff --git a/scripts/documentEncodingRoundTrip.spec.ts b/scripts/documentEncodingRoundTrip.spec.ts deleted file mode 100644 index 1b7ff290..00000000 --- a/scripts/documentEncodingRoundTrip.spec.ts +++ /dev/null @@ -1,269 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { functionSource, readDictionarySource, readRustBackend, readSource, sliceBetween } from './sourceTree.js'; - -/* - * #372: a document in a legacy encoding (GBK, Big5, Shift-JIS, CP-1252 …) is - * detected and decoded on open, and a save writes it back as the SAME - * encoding — the behaviour Notepad, VS Code and Typora all have, and the one - * an app that calls itself "the Notepad equivalent for Markdown" is measured - * against. - * - * The decoding and encoding themselves are Rust and are tested there - * (`saving_an_unedited_legacy_document_reproduces_its_canonical_bytes` is the - * one that matters). What has to hold on this side is the wiring, because it - * is the wiring that decides which bytes reach the file: - * - * read → tab.encoding → save - * - * A break anywhere in that chain does not fail loudly. It writes the user's - * GBK document back as UTF-8, silently, on an auto-save 1.5s after the first - * keystroke — no error, no toast, and every other tool that opens the file - * afterwards sees mojibake. So the chain is driven here end to end rather - * than read for. - */ - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity, and jsdom -// supplies `window` and `localStorage`. Only the Tauri backend is stubbed. - -/** What the backend reports for the next read. Set per test. */ -let fileEncoding = 'UTF-8'; -let fileLossy = false; -const BODY = '# 中文标题'; - -/** Every `save_file_content` the session issued, in order. */ -let writes: Array<{ path: string; content: string; encoding: string }> = []; - -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (command: string, args: Record) => { - const cmd = command.replace(/^plugin:[^|]*\|/, ''); - if (cmd === 'get_os_type') return Promise.resolve('macos'); - if (cmd === 'canonicalize_path') return Promise.resolve(args.path); - if (cmd === 'open_markdown_preview') { - return Promise.resolve([BODY, true, fileLossy, fileEncoding]); - } - if (cmd === 'read_file_content_checked') return Promise.resolve([BODY, fileLossy, fileEncoding]); - if (cmd === 'save_file_content') { - writes.push(args as unknown as { path: string; content: string; encoding: string }); - return Promise.resolve(null); - } - return Promise.resolve(null); - }, -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); -const { buildTransferredTab, snapshotTab, validateTransferPayload } = await import( - '../src/lib/utils/tabTransfer.js' -); - -function makeSession() { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: () => {}, - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: () => {}, - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - }); -} - -/** A tab opened from a file the backend reports as `encoding`. */ -async function openedAs(encoding: string, lossy = false) { - tabManager.closeAll(); - writes = []; - fileEncoding = encoding; - fileLossy = lossy; - const session = makeSession(); - await session.loadMarkdown('/notes/legacy.md'); - return { session, tab: tabManager.activeTab! }; -} - -test('opening a legacy document records the encoding it was read in', async () => { - const { tab } = await openedAs('GBK'); - assert.equal(tab.encoding, 'GBK'); - assert.equal(tab.hasReplacementChars, false, 'a detected encoding decodes; it does not substitute'); -}); - -test('saving that document writes it back as itself, not as UTF-8', async () => { - // The whole issue in one assertion. Under the old behaviour this write - // carried the buffer and nothing else, so the file became UTF-8 — or, - // before the buffer was decoded at all, U+FFFD. - const { session, tab } = await openedAs('GBK'); - - tabManager.updateTabRawContent(tab.id, `${BODY}\n\nedited`); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['GBK']); - assert.equal(writes[0].path, '/notes/legacy.md'); -}); - -test('a UTF-8 document is still written as UTF-8', async () => { - // The fence. Everything above passes for a build that writes some - // arbitrary encoding to every file it touches. - const { session, tab } = await openedAs('UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('a byte order mark is a property of the file, so it is carried too', async () => { - // Windows-authored Markdown, and the case a plain "is it UTF-8?" test - // misses: the text decodes perfectly, and dropping the BOM on save still - // changes the file for every tool that reads it. - const { session, tab } = await openedAs('UTF-8-BOM'); - assert.equal(tab.encoding, 'UTF-8-BOM'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8-BOM']); -}); - -test('a re-read repoints the save at what the file is NOW', async () => { - // The user converted the file to UTF-8 in another program. The tab must - // stop writing GBK, and it learns that from the read rather than from - // remembering how it was first opened. - const { session, tab } = await openedAs('GBK'); - - fileEncoding = 'UTF-8'; - await session.loadMarkdown('/notes/legacy.md'); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); - - tabManager.updateTabRawContent(tab.id, 'edited'); - assert.equal(await session.saveContent(tab.id), true); - assert.deepEqual(writes.map((write) => write.encoding), ['UTF-8']); -}); - -test('an untitled buffer has an encoding before anything has been read', async () => { - // Required, not optional, on the Tab — so a buffer that never came from a - // file still answers, and answers the only thing it could be. - tabManager.closeAll(); - tabManager.addNewTab(); - assert.equal(tabManager.activeTab!.encoding, 'UTF-8'); -}); - -test('a moved tab keeps its encoding across the window boundary', async () => { - // The buffer is a decode of a file in this encoding; the destination - // window auto-saves it. A payload that dropped the field would rewrite the - // user's GBK document as UTF-8 the moment it landed. - const { tab } = await openedAs('Shift_JIS'); - - const payload = JSON.stringify(snapshotTab(tab)); - const validated = validateTransferPayload(payload); - assert.notEqual(validated, null, 'precondition: the payload validates'); - assert.equal(buildTransferredTab(validated!, [], 'Untitled').encoding, 'Shift_JIS'); -}); - -test('a payload without an encoding is rejected rather than defaulted', async () => { - // Same rule as `hasReplacementChars`: a missing field must not be read as - // "UTF-8, go ahead", because that is a write to the user's file. - const { tab } = await openedAs('Big5'); - const { encoding, ...withoutEncoding } = snapshotTab(tab); - void encoding; - - assert.equal(validateTransferPayload(JSON.stringify(withoutEncoding)), null); -}); - -// ---------------------------------------------------------- the status bar -// -// Detection without an indicator is worse than no detection: a GBK document -// now opens, reads correctly and saves back as GBK, and the slot that is -// supposed to name the encoding was still rendering the literal `UTF-8` it -// had rendered since before Markpad could open the file at all. The one place -// the user could have caught a misdetection was asserting the opposite. -// -// This is the same defect #540 fixed one slot to the left, where the line -// ending was hardcoded `CRLF`. - -test('the status bar names the encoding the document was decoded from', () => { - const editor = readSource('src/lib/components/Editor.svelte'); - const statusBar = sliceBetween(editor, '
', '
\n{/if}'); - - assert.match(statusBar, /
\{encoding\}<\/div>/, 'the slot shows the state'); - assert.doesNotMatch(editor, /editor\.status\.utf8/, 'the hardcoded UTF-8 string is gone'); - - // Off the tab, not off Monaco: the encoding belongs to the file the buffer - // was decoded from, and the store is what carries it through a save. - assert.match( - editor, - /let encoding = \$derived\(tabManager\.activeTab\?\.encoding \?\? 'UTF-8'\)/, - 'and it is read from the tab rather than kept as a synced copy', - ); -}); - -test('no locale still translates a hardcoded UTF-8 label', () => { - // A dead key reads like a supported feature to whoever finds it next. - assert.doesNotMatch(readDictionarySource(), /utf8:/); -}); - -// ------------------------------------------- saying why a save was refused -// -// Refusing is only half of it. The user pasted an emoji into a GBK document -// and got "auto-save failed, unsaved changes still in memory" — true, and no -// help at all: it names neither the character nor the encoding, and the way -// out (Save As, which writes UTF-8) is not in it. -// -// Rust sends a code and this side owns the wording, so the reason a save was -// refused reaches the user in the language the rest of the app is in. The -// generic English `Failed to save file: ` stays for genuine I/O -// failures, which the user cannot act on beyond reading the OS. - -test('the unmappable-character refusal crosses as a code, not as English prose', () => { - const rust = readRustBackend(); - assert.match(rust, /const UNMAPPABLE_CODE: &str = "ENCODING_UNMAPPABLE";/); - assert.match(rust, /return Err\(UNMAPPABLE_CODE\.to_owned\(\)\)/, 'a marker, carrying nothing'); - - // The sentence it replaced must not come back: a translated toast and an - // English one saying the same thing is worse than either alone. - assert.doesNotMatch(rust, /cannot represent every character it now contains/); -}); - -test('every locale can say which encoding refused the save', () => { - const i18n = readDictionarySource(); - const locales = i18n.match(/lossySaveBlocked:/g) ?? []; - const translated = i18n.match(/encodingUnmappable:/g) ?? []; - - assert.ok(locales.length >= 5, 'precondition: the sibling refusal is translated'); - assert.equal(translated.length, locales.length, 'both refusals reach the same languages'); - - // The encoding is named, not implied — "some characters cannot be saved" - // leaves the user with nothing to search for. - for (const line of i18n.split('\n').filter((l) => l.includes('encodingUnmappable:'))) { - assert.match(line, /\{\{encoding\}\}/, `no encoding placeholder: ${line.trim().slice(0, 60)}`); - } -}); - -test('the code is turned into the translation, and only that code is', () => { - const session = readSource('src/lib/sessions/documentSession.svelte.ts'); - const describe = functionSource(session, 'describeSaveFailure'); - - assert.match(describe, /toast\.encodingUnmappable/); - assert.match(describe, /replace\('\{\{encoding\}\}', tab\.encoding\)/, - 'the encoding comes off the tab, not back out of the error'); - assert.match(describe, /return \{ message: 'Failed to save file', detail: error \}/, - 'an I/O failure still reads as one, OS message and all'); - - // `onError` renders `${message}: ${detail}`, so returning the raw error as - // the detail printed the code the user was never meant to see: - // …请用"另存为"写入一份 UTF-8 副本: ENCODING_UNMAPPABLE:GBK - // The translated branch sends the document's path instead, which is what - // `toast.lossySaveBlocked` has always done. - assert.match(describe, /detail: tab\.path,/, 'the marker never reaches the toast'); -}); diff --git a/scripts/documentLoadFailure.spec.ts b/scripts/documentLoadFailure.spec.ts deleted file mode 100644 index 62157145..00000000 --- a/scripts/documentLoadFailure.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -// A read can fail on a file that is perfectly fine a moment later: a watcher -// fires while another process is replacing the file, a network volume blinks, -// a sync client swaps the inode. Closing the tab on that error throws away the -// buffer the user could still have saved somewhere else, and dropping the -// recent-file entry throws away the way back to it. -// -// This was asserted by matching documentSession for `/tabManager\.closeTab/` -// and for the exact `options.onError('Error loading file', error);` call. The -// first is satisfied by any file that happens not to contain those characters -// — including one that closes the tab through a differently spelled route — -// and the second pins a string literal. `createDocumentSession` takes its -// collaborators as plain callbacks, and eleven other files in this suite -// already build one, so the failure can be run instead of described. - -// The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the -// Svelte plugin, so the store and the session run under real reactivity. Only -// the Tauri backend, which jsdom cannot provide, is stubbed. -let handleInvoke: (cmd: string, args: any) => unknown = (cmd) => { - throw new Error(`unexpected invoke: ${cmd}`); -}; -(window as any).__TAURI_INTERNALS__ = { - metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, - invoke: (cmd: string, args: any) => Promise.resolve(handleInvoke(cmd, args)), -}; - -const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); - -const errors: { message: string; error: unknown }[] = []; -const deletedRecents: string[] = []; - -function makeSession(overrides: Record = {}) { - return createDocumentSession({ - setShowHome: () => {}, - currentFile: () => tabManager.activeTab?.path ?? '', - resetScrollHistory: () => {}, - renderMarkdown: async () => '', - afterLoad: async () => {}, - saveRecentFile: () => {}, - deleteRecentFile: (path: string) => void deletedRecents.push(path), - setLoadingTabs: () => {}, - measureInitialViewport: () => {}, - isScrolling: () => false, - onError: (message: string, error: unknown) => void errors.push({ message, error }), - onDiskChangedUnderSave: () => {}, - cancelPendingAutoSave: () => {}, - askClose: async () => 'discard' as const, - onCloseSaveNewerEdits: () => {}, - onCloseAutoSaveFailed: () => {}, - onPartialCopySaved: () => {}, - ...overrides, - }); -} - -/** Every read of `path` fails; anything else the load path asks for is absent too. */ -function readsFail() { - handleInvoke = () => { - throw new Error('No such file or directory (os error 2)'); - }; -} - -test('a read that fails keeps the open tab, its buffer and its recent entry', async () => { - tabManager.closeAll(); - errors.length = 0; - deletedRecents.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'the text the reader can still rescue'); - const before = tabManager.activeTab!; - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(tabManager.tabs.length, 1, 'the tab is not closed'); - assert.equal(tabManager.tabs[0].id, before.id, 'and it is the same tab, not a replacement'); - assert.equal( - tabManager.tabs[0].rawContent, - 'the text the reader can still rescue', - 'the buffer is not replaced by the failed read', - ); - assert.deepEqual(deletedRecents, [], 'the recent-file entry survives, so the file can be reopened'); -}); - -test('the failure is reported rather than swallowed', async () => { - tabManager.closeAll(); - errors.length = 0; - - const session = makeSession(); - tabManager.addTab('/notes/a.md', 'buffer'); - readsFail(); - - await session.loadMarkdown('/notes/a.md'); - - assert.equal(errors.length, 1, 'exactly one error reaches the caller'); - assert.equal(errors[0].message, 'Error loading file'); - assert.ok(errors[0].error instanceof Error, 'the cause is passed on, not flattened to a string'); -}); diff --git a/scripts/documents.checks.spec.ts b/scripts/documents.checks.spec.ts new file mode 100644 index 00000000..d1a5856b --- /dev/null +++ b/scripts/documents.checks.spec.ts @@ -0,0 +1,1071 @@ +import assert from 'node:assert/strict'; + +import ts from 'typescript'; +import { afterAll, beforeAll, describe, test } from 'vitest'; + +import { reviewDirtyTabs } from '../src/lib/sessions/closeReview.js'; +import { sourceAtPoint } from '../src/lib/utils/previewCursor.js'; +import { callbackBodies, functionSource, offsetOf, readSource, sliceBetween, sliceFrom } from './sourceTree.js'; + +// Each describe installs its own Tauri stub in `beforeAll`. This one only +// answers the settings store, which boots when the modules below are imported. +(window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), +}; + +const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); + +// An unanswered external-change conflict is a question only the user can +// answer. The viewer's saves that nobody asked for — leaving the editor or +// split view, and the exit's auto-save — used to go through the same wrapper as +// Cmd+S, which authorises the overwrite whenever the bar is up. So Ctrl+E or +// quitting wrote the buffer over the other program's change and took the bar +// down without asking. +// +// The functions are lifted out of MarkdownViewer.svelte and RUN against the +// real document session, so what is asserted is what reaches the disk. +describe('silent saves keep conflicts', () => { + const disk = new Map(); + + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string, args: any) => { + if (cmd === 'canonicalize_path') return Promise.resolve(args.path); + if (cmd === 'read_file_content_checked') return Promise.resolve([disk.get(args.path) ?? '', false, 'UTF-8']); + if (cmd === 'open_markdown_preview') return Promise.resolve([disk.get(args.path) ?? '', true, false, 'UTF-8']); + if (cmd === 'save_file_content') { + disk.set(args.path, args.content); + return Promise.resolve(null); + } + if (cmd === 'get_os_type') return Promise.resolve('macos'); + return Promise.resolve(null); + }, + }; + }); + + const LIFTED = [ + 'hasExternalChangeConflict', + 'noteExternalChangeConflict', + 'clearExternalChangeConflict', + 'resolveExternalChangeByKeepingBuffer', + 'saveContent', + 'saveSilently', + 'flushBeforeLeavingEditableMode', + 'settleForExit', + ]; + + function makeViewer() { + const closeQuestions: boolean[] = []; + const toasts: string[] = []; + const scope: Record = { + tabManager, + externalChangeConflicts: {}, + comparison: null, + settings: { autoSave: true, language: 'en', restoreStateOnReopen: false }, + t: (key: string) => key, + addToast: (message: string) => void toasts.push(message), + invoke: async () => null, + tick: async () => {}, + reviewDirtyTabs, + isCloseWalkActive: false, + pinFilesAtClose: null, + openFilePaths: () => [], + savePinnedTagIfNeeded: async () => {}, + persistWindowState: async () => {}, + }; + const session = createDocumentSession({ + setShowHome: () => {}, + currentFile: () => tabManager.activeTab?.path ?? '', + resetScrollHistory: () => {}, + renderMarkdown: async (raw: string) => raw, + afterLoad: async () => {}, + saveRecentFile: () => {}, + deleteRecentFile: () => {}, + setLoadingTabs: () => {}, + measureInitialViewport: () => {}, + isScrolling: () => false, + onError: () => {}, + onDiskChangedUnderSave: (id: string) => (fns.noteExternalChangeConflict as (id: string) => void)(id), + cancelPendingAutoSave: () => {}, + askClose: async (_title: string, diskMoved: boolean) => { + closeQuestions.push(diskMoved); + return 'cancel' as const; + }, + onCloseSaveNewerEdits: () => {}, + onCloseAutoSaveFailed: () => {}, + onPartialCopySaved: () => {}, + }); + scope.documentSession = session; + scope.canCloseTab = (id: string) => session.canCloseTab(id); + + const source = readSource('src/lib/MarkdownViewer.svelte'); + const declarations = LIFTED.map((name) => `const ${name} = ${functionSource(source, name)};`).join('\n'); + const js = ts.transpileModule(declarations, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + const fns = new Function('scope', `with (scope) { ${js}\nreturn { ${LIFTED.join(', ')} }; }`)( + new Proxy(scope, { has: (target, key) => key in target }), + ) as Record Promise>; + return { fns, scope, closeQuestions, toasts }; + } + + /** a.md was saved, another program then changed it, and the user typed once more. */ + function conflicted(viewer: ReturnType) { + tabManager.closeAll(); + disk.clear(); + disk.set('/notes/a.md', 'saved'); + tabManager.addTab('/notes/a.md', 'saved'); + const tab = tabManager.activeTab!; + tab.originalContent = 'saved'; + tab.isEditing = true; + disk.set('/notes/a.md', 'theirs'); + tabManager.updateTabRawContent(tab.id, 'saved!'); + (viewer.fns.noteExternalChangeConflict as unknown as (id: string) => void)(tab.id); + return tab; + } + + test('leaving the editor keeps the edit in the buffer and the bar up', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + await viewer.fns.flushBeforeLeavingEditableMode(tab); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), true, 'the bar came down'); + assert.deepEqual(viewer.toasts, [], 'the bar already says why nothing was written'); + }); + + test('closing the window asks about a conflicted tab instead of saving it', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.settleForExit(), false); + + assert.equal(disk.get('/notes/a.md'), 'theirs', 'the other program\'s change was overwritten'); + assert.equal(tab.rawContent, 'saved!'); + assert.deepEqual(viewer.closeQuestions, [true], 'the walk asked the changed-on-disk question'); + }); + + test('the exit still auto-saves the other dirty tabs', async () => { + const viewer = makeViewer(); + conflicted(viewer); + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + + await viewer.fns.settleForExit(); + + assert.equal(disk.get('/notes/b.md'), 'b edited'); + assert.equal(disk.get('/notes/a.md'), 'theirs'); + }); + + test('Cmd+S on a conflicted tab is still the answer "keep mine"', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + + assert.equal(await viewer.fns.saveContent(tab.id), true); + + assert.equal(disk.get('/notes/a.md'), 'saved!'); + assert.equal(viewer.fns.hasExternalChangeConflict(tab.id), false); + }); + + test('"Keep mine" from the comparison answers for the tab it was opened on', async () => { + const viewer = makeViewer(); + const a = conflicted(viewer); + // The user switched tabs with the comparison open: b.md is in front, edited, + // and another program has just written it too. + disk.set('/notes/b.md', 'b'); + tabManager.addTab('/notes/b.md', 'b'); + const b = tabManager.activeTab!; + b.originalContent = 'b'; + tabManager.updateTabRawContent(b.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + // What the overlay's button passes: the tab the comparison was opened on. + await viewer.fns.resolveExternalChangeByKeepingBuffer(a.id); + await viewer.fns.saveSilently(b.id); + + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'b.md was overwritten on an answer given about a.md'); + assert.equal(await viewer.fns.saveSilently(a.id), true, 'a.md did not get the answer'); + }); + + test('a conflict does not follow its tab onto another file', async () => { + const viewer = makeViewer(); + const tab = conflicted(viewer); + // A link in a.md: the close dialog is answered Don't Save, and b.md loads + // into the same tab. + disk.set('/notes/b.md', 'b'); + tab.rawContent = tab.originalContent; + await (viewer.scope.documentSession as { loadMarkdown: (path: string, options: object) => Promise }) + .loadMarkdown('/notes/b.md', { navigate: true }); + assert.equal(tab.path, '/notes/b.md'); + + // b.md is edited, and changed by another program before the watcher says so. + tabManager.updateTabRawContent(tab.id, 'b edited'); + disk.set('/notes/b.md', 'b theirs'); + + assert.equal(await viewer.fns.saveContent(tab.id), false); + assert.equal(disk.get('/notes/b.md'), 'b theirs', 'Cmd+S used the answer about a.md to overwrite b.md'); + }); +}); + +/* + * A keystroke reaches the document by ONE route, and the dirty flag is not a + * flag. + * + * THE DEFECT. Typing used to write the buffer twice. `MarkdownViewer` passed + * the editor `bind:value={tabManager.activeTab.rawContent}`, so Svelte's + * assignment put the new text on the tab; the same listener then called + * `tabManager.updateTabRawContent`, which put it there again and recomputed + * `isDirty` from it. Both writes carried the same text, so the app worked — and + * it worked *because* Svelte's assignment happened to run first. Delete the + * store call and the editor still edited, the preview still re-rendered, and + * nothing maintained `isDirty`: no dirty dot, no auto-save, no unsaved-changes + * prompt on close, and no type error anywhere to say so. + * + * WHAT IT IS NOW. `value` is a plain prop — a getter over the parent's + * `tabManager.activeTab.rawContent` — and the listener's call to the store is + * the only write. Remove it and the editor stops editing the document, which is + * a failure you can see; these tests are the ones that see it first. + * + * `isDirty` went the same way: a getter on the tab, exactly + * `rawContent !== originalContent`, so nothing can hold an opinion about + * "changed?" that its two buffers do not support. + * + * WHAT RUNS HERE. The component's own `onDidChangeModelContent` listener, + * lifted out of Editor.svelte and evaluated against the REAL `TabManager`. Only + * Monaco is a fake, and only the two methods the listener touches. + */ +describe('editor writes through the store', () => { + // ---------------------------------------------------------------- environment + // Svelte runes and the Tauri bridge, faked as the other lifting tests do, so + // `tabs.svelte.ts` imports under plain node. + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null), + }; + }); + + // ------------------------------------------------- the component, as written + + const EDITOR = 'src/lib/components/Editor.svelte'; + const source = readSource(EDITOR); + + const contentChangeBody = (() => { + const bodies = callbackBodies(source, 'editor.onDidChangeModelContent'); + assert.equal(bodies.length, 1, `expected exactly one model-content listener in ${EDITOR}, found ${bodies.length}`); + return bodies[0]; + })(); + + /** + * The lifted body is TypeScript and `new Function` only parses JavaScript, so + * it goes through `tsc` first, as undoHistoryPerTab.spec.ts and + * imageUndoKeepsFile.spec.ts already do. Types are erased, nothing else: the + * statements that run are the component's. + */ + const factorySource = ts.transpileModule( + `const __component = (tabManager, editor) => { + // The status bar readings. Stubbed rather than lifted because they are + // the listener's other errand and have their own tests; what is under + // test here is where the text goes. + const syncStatusFromModel = () => {}; + let emittedText = null; + return { contentChanged: () => ${contentChangeBody} }; + };`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText; + + /** + * The `value` prop, modelled as what Svelte compiles a dynamic prop to: a + * getter over the parent's expression, `value={tabManager.activeTab.rawContent}`. + * Modelling it as a local copy would be re-introducing the second buffer this + * whole change removed. + * + * A null-prototype object so `with` captures this one name and not + * `toString`, `constructor` and the rest of Object.prototype. + */ + const propsScope = Object.defineProperty(Object.create(null), 'value', { + get: () => tabManager.activeTab?.rawContent ?? '', + }); + + function editorShowing(text: string) { + let onScreen = text; + const editor = { getValue: () => onScreen }; + const factory = new Function( + '__props', + `with (__props) { ${factorySource}\nreturn __component; }`, + )(propsScope) as (tabManager: unknown, editor: unknown) => { contentChanged: () => void }; + const component = factory(tabManager, editor); + + return { + /** A keystroke: Monaco's buffer changes, then it fires the listener. */ + type: (next: string) => { + onScreen = next; + component.contentChanged(); + }, + }; + } + + function activeTab() { + const tab = tabManager.activeTab; + assert.ok(tab, 'precondition: a tab is active'); + return tab!; + } + + // ------------------------------------------------------------------ the tests + + test('a keystroke reaches the document, and the document knows it is unsaved', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/a.md', 'saved text'); + const editor = editorShowing('saved text'); + + assert.equal(activeTab().isDirty, false, 'precondition: a freshly opened tab is clean'); + + editor.type('saved text and more'); + + assert.equal( + activeTab().rawContent, + 'saved text and more', + 'the keystroke never reached the document: the editor is writing to something else, or to nothing', + ); + assert.equal( + activeTab().isDirty, + true, + 'the document has unsaved edits and does not say so — no dirty dot, no auto-save, no prompt on close', + ); + }); + + test('typing the saved text back makes the tab clean again, with nobody clearing a flag', () => { + // The point of deriving it. Undo, or retyping what was deleted, leaves a + // buffer that IS the saved text — and the only thing that has to happen for + // the tab to say so is that the two buffers agree. + tabManager.closeAll(); + tabManager.addTab('/notes/b.md', 'saved text'); + const editor = editorShowing('saved text'); + + editor.type('saved text!'); + assert.equal(activeTab().isDirty, true); + + editor.type('saved text'); + assert.equal(activeTab().isDirty, false, 'the buffer is the saved text and the tab still reads dirty'); + }); + + test('following a link does not leave the tab dirty against the file it just left', () => { + // `navigate` repoints the tab at another file before the load lands, so for + // that window `path` is the new file and `rawContent` is the old document. + // A tab left dirty there arms the auto-save effect, which writes the + // document the reader left into the file they opened. + tabManager.closeAll(); + tabManager.addTab('/notes/c.md', 'saved text'); + const editor = editorShowing('saved text'); + editor.type('unsaved edits'); + + tabManager.navigate(activeTab().id, '/notes/linked.md'); + + assert.equal(activeTab().path, '/notes/linked.md'); + assert.equal(activeTab().isDirty, false, 'the tab claims unsaved edits to a document it no longer holds'); + }); +}); + +// Issue #168, second report by @dayeggpi: "allow user to switch to rendered +// view without saving/creating file ... no way to see rendered view until file +// is saved". +// +// The cause was that reading mode was drawn from DISK: leaving the editor +// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — +// silently with auto-save on, through a modal otherwise — or the reader would +// have shown the pre-edit file. The modal was never protecting the buffer; the +// buffer survives a view toggle. It was protecting the screen from lying. +// +// These tests run the REAL `toggleEdit` / `toggleSplitView` / the real +// `renderTabPreviewFromRaw` out of MarkdownViewer.svelte against the real +// TabManager, with the disk, the renderer and the modal faked. `loadMarkdown` +// is faked to behave like the real one (dirty short-circuit included) and to +// serve a DIFFERENT text than the buffer, so any route that goes back to the +// file is visible in the rendered output rather than merely in the call log. +// +// The boundary tests at the bottom are the other half of the argument: closing +// a tab and closing the window still ask, because there the buffer really is +// about to disappear. +describe('view mode without saving', () => { + // ---------------------------------------------------------------- environment + + // The runes are the compiler's, not ours: vitest builds `.svelte.ts` through the + // Svelte plugin, so the store and the session run under real reactivity, and jsdom + // supplies `window` and `localStorage`. Only the Tauri backend is stubbed. + beforeAll(() => { + (window as any).__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, + invoke: () => Promise.reject(new Error('no invoke expected in these tests')), + }; + }); + + // `settings` is one store for the whole file: put back what this block changes. + let settingsBefore: { autoSave: typeof settings.autoSave }; + beforeAll(() => { + settingsBefore = { autoSave: settings.autoSave }; + }); + afterAll(() => { + Object.assign(settings, settingsBefore); + }); + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + // ------------------------------------------------------------ source plucking + + /** + * Slice one `async function (...) { ... }` out of the component. + * + * Brace counting, but string/template/comment aware — a naive count trips over + * the first `{` inside a comment or a template literal and silently returns a + * body that stops in the middle, which would make every assertion below + * meaningless in exactly the direction that hides bugs. + */ + function pluck(name: string, required = true): string { + const asyncStart = viewer.indexOf(`async function ${name}(`); + const syncStart = viewer.indexOf(`\tfunction ${name}(`); + const start = asyncStart !== -1 ? asyncStart : syncStart === -1 ? -1 : syncStart + 1; + if (start === -1) { + assert.ok(!required, `expected MarkdownViewer.svelte to define ${name}`); + return ''; + } + let i = offsetOf(viewer, '{', offsetOf(viewer, ')', start)); + let depth = 0; + for (; i < viewer.length; i++) { + const c = viewer[i]; + const next = viewer[i + 1]; + if (c === '/' && next === '/') { + i = viewer.indexOf('\n', i); + continue; + } + if (c === '/' && next === '*') { + i = viewer.indexOf('*/', i) + 1; + continue; + } + if (c === "'" || c === '"' || c === '`') { + const quote = c; + for (i++; i < viewer.length; i++) { + if (viewer[i] === '\\') i++; + else if (viewer[i] === quote) break; + } + continue; + } + if (c === '{') depth++; + else if (c === '}' && --depth === 0) return viewer.slice(start, i + 1); + } + assert.fail(`unbalanced braces while slicing ${name}`); + } + + type Harness = { + toggleEdit: () => Promise; + toggleSplitView: (tabId: string) => Promise; + }; + + type Fakes = { + disk: Map; + askCustomCalls: string[]; + saveCalls: string[]; + loadCalls: string[]; + renderedFrom: string[]; + toasts: string[]; + saveFails: boolean; + /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ + typeDuringSave: string; + }; + + /** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ + const rendered = (raw: string) => `${raw}`; + + function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record = {}): Harness { + const source = [ + pluck('renderTabPreviewFromRaw'), + // Absent on the pre-fix baseline, where both toggles inline their own + // save/modal flow. Optional so this file runs — and fails on behaviour + // rather than on a missing symbol — against either version. + pluck('flushBeforeLeavingEditableMode', false), + pluck('renderPreviewLeavingEditableMode', false), + pluck('toggleEdit'), + pluck('toggleSplitView'), + ].join('\n\n'); + + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, settings, t, addToast, askCustom, saveSilently, hasExternalChangeConflict, + cancelPendingAutoSave, renderMarkdownPreview, loadMarkdown, + documentSession, invoke, isEditing, liveMode, toggleLiveMode, + tick, renderRichContent, editorPane, markdownBody, + getPreviewScrollSyncPosition, restoreAfterLeavingEditor, + } = deps; + let previewPlacing = false; + ${js} + return { toggleEdit, toggleSplitView };`, + ); + + return factory({ + tabManager, + settings, + isEditing, + hasExternalChangeConflict: () => false, + t: (key: string) => key, + addToast: (message: string) => fakes.toasts.push(message), + askCustom: async (message: string) => { + fakes.askCustomCalls.push(message); + return 'save' as const; + }, + // Mirrors documentSession.saveContent for a tab that has a path. + saveSilently: async (tabId: string) => { + fakes.saveCalls.push(tabId); + const tab = tabManager.tabs.find((item) => item.id === tabId)!; + if (fakes.saveFails) return false; + const snapshot = tab.rawContent; + await Promise.resolve(); + if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; + fakes.disk.set(tab.path, snapshot); + tab.originalContent = snapshot; + return true; + }, + cancelPendingAutoSave: () => {}, + renderMarkdownPreview: async (raw: string) => { + fakes.renderedFrom.push(raw); + return rendered(raw); + }, + // The disk route, with the real function's dirty short-circuit. + loadMarkdown: async (path: string, options: any = {}) => { + fakes.loadCalls.push(path); + const activeId = tabManager.activeTabId!; + const receiving = tabManager.tabs.find((item) => item.id === activeId)!; + if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; + const content = fakes.disk.get(path) ?? ''; + fakes.renderedFrom.push(content); + tabManager.updateTabContent(activeId, rendered(content)); + tabManager.setTabRawContent(activeId, content); + }, + documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, + invoke: async () => { + throw new Error('no invoke expected while leaving an editable pane'); + }, + liveMode: false, + toggleLiveMode: () => {}, + tick: async () => {}, + // No panes unless a test asks: where the reader lands (#799) is not + // what the rest of this file is about. + editorPane: undefined, + markdownBody: undefined, + ...panes, + }); + } + + function freshFakes(): Fakes { + return { + disk: new Map(), + askCustomCalls: [], + saveCalls: [], + loadCalls: [], + renderedFrom: [], + toasts: [], + saveFails: false, + typeDuringSave: '', + }; + } + + const ON_DISK = '# saved heading\n'; + const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; + + /** + * A tab holding unsaved edits to a real file, in whichever editable pane the + * caller names, with the file on disk still carrying the pre-edit text. + */ + function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { + tabManager.closeAll(); + const fakes = freshFakes(); + fakes.disk.set(path, ON_DISK); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tab.isEditing = mode === 'edit'; + tabManager.setSplitEnabled(tab.id, mode === 'split'); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); + return { tab, fakes, harness: buildHarness(fakes, mode === 'edit') }; + } + + /** + * `autoSave` used to be half of a pair — the other, `confirmBeforeSave`, could + * veto it, and every decision in the app read `autoSave && !confirmBeforeSave`. + * The pair is now the one switch that expression always described, so the two + * old combinations that meant "do not write silently" are the single `false` + * here. + */ + function setSettings(autoSave: boolean) { + settings.autoSave = autoSave; + } + + // ------------------------------------------------------- leaving edit mode + + test('a dirty file switches to reading mode without a modal and without writing', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.askCustomCalls, [], 'no unsaved-changes modal on a view toggle'); + assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); + assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); + assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); + }); + + test('reading mode shows the buffer, not the file', async () => { + setSettings(false); + const { tab, harness, fakes } = dirtyTab('edit'); + + await harness.toggleEdit(); + + // The whole point of the bug: with a disk read here, this is `ON_DISK`. + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.deepEqual( + fakes.renderedFrom, + [IN_BUFFER], + 'the preview is rendered once, from the buffer', + ); + assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); + }); + + test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { + setSettings(true); + tabManager.closeAll(); + const fakes = freshFakes(); + tabManager.addTab(''); + const tab = tabManager.activeTab!; + tab.isEditing = true; + tabManager.updateTabRawContent(tab.id, '# untitled\n'); + const harness = buildHarness(fakes, true); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered('# untitled\n')); + }); + + test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { + // The auto-save effect requires `isEditing || isSplit`, so this is the last + // chance to honour "save automatically" for these edits. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); + assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); + assert.equal(tab.isDirty, false); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('a file that cannot be written no longer traps the user in the editor', async () => { + // Read-only path, or a buffer the lossy-decode guard refuses: saveContent + // returns false forever, and the old code returned early on that, so + // reading mode was unreachable for the life of the tab. + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.saveFails = true; + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); + assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); + assert.equal(tab.isEditing, false, 'but the view still switches'); + assert.equal(tab.content, rendered(IN_BUFFER)); + assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); + }); + + test('edits typed during the flush are shown, and reported as not yet on disk', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.typeDuringSave = 'typed while saving\n'; + + await harness.toggleEdit(); + + assert.equal(tab.isDirty, true); + assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); + assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); + assert.equal( + tab.content, + rendered(IN_BUFFER + 'typed while saving\n'), + 'the preview shows the newest text, which is exactly what is NOT on disk', + ); + }); + + // --------------------------------------------------- closing the split view + + test('closing split view on a dirty file neither asks nor writes', async () => { + setSettings(false); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.askCustomCalls, []); + assert.deepEqual(fakes.saveCalls, []); + assert.deepEqual(fakes.loadCalls, []); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isSplit, false); + assert.equal(tab.isDirty, true); + assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); + }); + + test('closing split view honours auto-save the same way leaving edit mode does', async () => { + setSettings(true); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.deepEqual(fakes.askCustomCalls, []); + assert.equal(tab.isSplit, false); + assert.equal(tab.content, rendered(IN_BUFFER)); + }); + + test('closing the window still reviews unsaved tabs', () => { + // The close handler is where the buffer is really at stake: it dies with + // the window. Source-level, because it is wired to a Tauri window event. + // Quit goes through the same handler now — `appExit` closes windows and + // decides nothing about their buffers (#390). + const exit = pluck('appExit'); + assert.doesNotMatch(exit, /askCustom/, 'quit asks nothing of its own; the close handler does the asking'); + + const closeHandler = sliceBetween(viewer, 'appWindow.onCloseRequested', 'onDragDropEvent'); + assert.match(closeHandler, /await settleForExit\(/, 'the close still runs the review'); + assert.match(pluck('settleForExit'), /canCloseTab,\n/, 'the walk still runs the per-tab dialog'); + }); + + test('the view toggles no longer re-read the file to leave an editable pane', () => { + // Belt and braces for the behaviour above: neither toggle may reach for + // the disk again. `renderTabPreviewFromRaw` is the shared "render THIS + // tab's buffer under its own path" helper (it also serves the PDF export). + const toggleEdit = pluck('toggleEdit'); + const leaveEditMode = toggleEdit.slice(0, offsetOf(toggleEdit, '// Switch to edit')); + assert.doesNotMatch(leaveEditMode, /loadMarkdown/); + assert.doesNotMatch(leaveEditMode, /askCustom/); + + const toggleSplit = pluck('toggleSplitView'); + const closeSplit = sliceFrom(toggleSplit, 'setSplitEnabled(tab.id, false)'); + assert.doesNotMatch(closeSplit, /loadMarkdown/); + assert.doesNotMatch(toggleSplit, /askCustom/); + }); + + // ------------------------------------------------ where the reader lands (#799) + + test('Ctrl+E hands each pane the line the other one was showing', async () => { + setSettings(false); + const { tab, fakes } = dirtyTab('edit'); + const fromEditor = { section: 'body', ratio: 0.4, line: 120 }; + const fromPreview = { section: 'body', ratio: 0.7, line: 300 }; + const synced: unknown[] = []; + const restored: unknown[] = []; + const panes = { + editorPane: { + scrollSyncPosition: () => fromEditor, + syncScrollToPosition: (position: unknown) => synced.push(position), + }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => fromPreview, + restoreAfterLeavingEditor: (_id: string, position: unknown) => restored.push(position), + }; + + await buildHarness(fakes, true, panes).toggleEdit(); + assert.equal(tab.isEditing, false); + assert.deepEqual(restored, [fromEditor], 'the preview was not sent to the editor\'s line'); + + await buildHarness(fakes, false, panes).toggleEdit(); + assert.equal(tab.isEditing, true); + assert.deepEqual(synced, [fromPreview], 'the editor was not sent to the preview\'s line'); + }); + + test('Ctrl+E brings the cursor on screen only when the outline follows it', async () => { + // An outline marking the cursor's heading while the editor shows another + // one reads as wrong, so with 'cursor' the handoff moves a cursor left off + // screen. With 'scroll' the outline never looks at the cursor, and moving + // it would only lose the reader's place. + const asked: unknown[] = []; + for (const follows of ['cursor', 'scroll'] as const) { + settings.tocFollows = follows; + const { tab, fakes } = dirtyTab('edit'); + tab.isEditing = false; + await buildHarness(fakes, false, { + editorPane: { syncScrollToPosition: (_position: unknown, options: unknown) => asked.push(options) }, + markdownBody: {}, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + }).toggleEdit(); + } + settings.tocFollows = 'scroll'; + assert.deepEqual(asked, [{ cursorIntoView: true }, { cursorIntoView: false }]); + }); + + // Scroll sync is split view's. The tab keeps `isScrollSynced` after the split + // closes, and out of split the other pane is still mounted at zero width, so + // syncing through it dragged the visible pane off its line on every Ctrl+E. + function buildSyncHarness(panes: Record) { + const source = [ + pluck('handleEditorScrollSync'), + pluck('splitScrollSyncOn', false), + pluck('syncEditorToPreviewScroll'), + pluck('restoreAfterLeavingEditor'), + pluck('followToc'), + pluck('handleEditorCursor'), + pluck('placePreviewCursor'), + ].join('\n\n'); + const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, tick, viewerPaneEl, markdownBody, editorPane, lineCoords, + tabAnchorForEditorTopLine, asBufferLine, scrollPreviewToSyncPosition, + getPreviewScrollSyncPosition, getPreviewScrollAnchor, settings, hasEditorPane, hasPreviewPane, isEditing, isSplit, + previewBlocks, sourceAtPoint, readRendererLine, + } = deps; + let tocActiveLine = null; + let previewPlacing = false; + const cursorByTab = {}; + const activeCursor = deps.activeCursor ?? null; + ${js} + return { + handleEditorScrollSync, syncEditorToPreviewScroll, restoreAfterLeavingEditor, handleEditorCursor, followToc, placePreviewCursor, + toc: () => tocActiveLine, + cursors: () => cursorByTab, + startPlacing: () => { previewPlacing = true; }, + };`, + ); + return factory({ + tabManager, + tick: async () => {}, + viewerPaneEl: undefined, + markdownBody: {}, + lineCoords: { toRendererLine: (line: number) => line - 3, toBufferLine: (line: number) => line + 3 }, + settings, + hasEditorPane: false, + hasPreviewPane: true, + isEditing: false, + isSplit: false, + tabAnchorForEditorTopLine: (_coords: unknown, line: number) => line, + asBufferLine: (line: number) => line, + getPreviewScrollSyncPosition: () => ({ section: 'body', ratio: 0.5, line: 40 }), + ...panes, + }); + } + + test('scroll sync left on in split view stays off once the split closes', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setSplitEnabled(tab.id, true); + if (!tab.isScrollSynced) tabManager.toggleScrollSync(tab.id); + const toPreview: unknown[] = []; + const toEditor: unknown[] = []; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: (position: unknown) => toPreview.push(position), + editorPane: { syncScrollToPosition: (position: unknown) => toEditor.push(position) }, + }); + const position = { section: 'body', ratio: 0.5, line: 40 }; + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'precondition: split view syncs the preview'); + assert.equal(toEditor.length, 1, 'precondition: split view syncs the editor'); + + tabManager.setSplitEnabled(tab.id, false); + tab.isEditing = true; + assert.equal(tab.isScrollSynced, true, 'the tab still remembers the choice'); + + harness.handleEditorScrollSync(position); + harness.syncEditorToPreviewScroll({}); + assert.equal(toPreview.length, 1, 'the hidden preview was scrolled from the editor'); + assert.equal(toEditor.length, 1, 'the editor was scrolled from the hidden preview'); + assert.equal(harness.toc(), 40, 'the outline still follows the editor'); + }); + + test('leaving the editor moves the outline to the line the preview lands on', async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + }); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + + assert.equal(tab.anchorLine, 57); + assert.equal(harness.toc(), 57, 'the outline kept the entry from before the switch'); + }); + + // #799: "the table of contents should follow the cursor". An editor setting, + // because only the editor has one: the preview alone always follows the scroll. + describeOutlineSource('cursor', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), 17, 'the cursor line, in the outline\'s numbering'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 17, 'scrolling the editor left it on the cursor'); + h.handleEditorCursor(30, 1); + assert.equal(h.toc(), 27); + }); + + describeOutlineSource('cursor', false, async (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'no editor on screen, so no cursor to follow'); + await h.restoreAfterLeavingEditor(tabManager.activeTab!.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(h.toc(), 57, 'the preview alone follows its scroll'); + }); + + describeOutlineSource('scroll', true, (h) => { + h.handleEditorCursor(20, 1); + assert.equal(h.toc(), null, 'the default ignores the cursor'); + h.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 60 }); + assert.equal(h.toc(), 60); + // The editor alone keeps the preview mounted at zero width, and its + // scroll events put the outline on a heading from that layout. + h.followToc('preview', 90); + assert.equal(h.toc(), 60, 'the hidden preview moved the outline'); + }); + + function describeOutlineSource( + follows: 'cursor' | 'scroll', + editorOnScreen: boolean, + run: (harness: ReturnType) => void | Promise, + ) { + test(`the outline follows the ${follows} setting with the editor ${editorOnScreen ? 'on' : 'off'} screen`, async () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = editorOnScreen; + settings.tocFollows = follows; + try { + await run(buildSyncHarness({ + hasEditorPane: editorOnScreen, + hasPreviewPane: !editorOnScreen, + isEditing: editorOnScreen, + scrollPreviewToSyncPosition: () => {}, + getPreviewScrollAnchor: () => 57, + })); + } finally { + settings.tocFollows = 'scroll'; + } + }); + } + + test('the preview does not move the outline after Ctrl+E until it is placed', async () => { + // Its scroll events before that report the old position, and the outline + // flashed the first headings until the preview was placed. + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const harness = buildSyncHarness({ scrollPreviewToSyncPosition: () => {}, getPreviewScrollAnchor: () => 57 }); + + harness.handleEditorScrollSync({ section: 'body', ratio: 0.5, line: 40 }); + harness.startPlacing(); + harness.followToc('preview', 2); + assert.equal(harness.toc(), 40, 'an unplaced preview put the outline on its own line'); + + await harness.restoreAfterLeavingEditor(tab.id, { section: 'body', ratio: 0.5, line: 58 }); + assert.equal(harness.toc(), 57, 'the placed preview decides'); + harness.followToc('preview', 60); + assert.equal(harness.toc(), 60, 'and keeps deciding once placed'); + }); + + test('reading with a cursor placed, the outline stays on it only while the preview draws it', () => { + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + tabManager.activeTab!.isEditing = false; + settings.tocFollows = 'cursor'; + try { + settings.previewCursor = true; + const drawn = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + drawn.followToc('cursor', 17); + drawn.followToc('preview', 90); + assert.equal(drawn.toc(), 17, 'the preview scroll moved the outline off the drawn cursor'); + + // Off, the editor's last cursor is still there for Ctrl+E, but the + // preview shows nothing, so an outline pinned to it would look stuck. + settings.previewCursor = false; + const hidden = buildSyncHarness({ activeCursor: { line: 20, column: 3 }, hasEditorPane: false }); + hidden.followToc('cursor', 17); + hidden.followToc('preview', 90); + assert.equal(hidden.toc(), 90, 'the outline stayed on a cursor the preview does not show'); + + const without = buildSyncHarness({ hasEditorPane: false }); + without.followToc('preview', 90); + assert.equal(without.toc(), 90, 'with no cursor yet, the outline follows the scroll'); + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + } + }); + + test('a click in the preview places the cursor only with its setting on, whatever the outline follows', () => { + // #799: the shared cursor is an editing aid in its own right, so Ctrl+E + // lands on the clicked character in either outline mode. The drawn caret + // is visual noise to some readers, so the whole aid is off by default. + const root = document.createElement('div'); + root.innerHTML = '

hello world

'; + const text = root.querySelector('p')!.firstChild!; + (document as any).caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(text, 6); + return range; + }; + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tab.isEditing = false; + const click = () => { + const harness = buildSyncHarness({ + previewBlocks: root, + sourceAtPoint, + readRendererLine: (line: number) => (line === 2 ? 'hello world' : ''), + activeCursor: { line: 5, column: 7 }, + }); + harness.placePreviewCursor({ detail: 1, clientX: 0, clientY: 0 } as MouseEvent); + return harness; + }; + try { + settings.tocFollows = 'cursor'; + const off = click(); + assert.deepEqual(off.cursors(), {}, 'off by default: the click leaves the cursor alone'); + assert.equal(off.toc(), null); + + settings.previewCursor = true; + for (const follows of ['scroll', 'cursor'] as const) { + settings.tocFollows = follows; + const harness = click(); + assert.deepEqual(harness.cursors()[tab.id], { line: 5, column: 7 }, `${follows}: the click placed the cursor on the "w"`); + assert.equal(harness.toc(), follows === 'cursor' ? 2 : null, `${follows}: the outline`); + } + } finally { + settings.tocFollows = 'scroll'; + settings.previewCursor = false; + delete (document as any).caretRangeFromPoint; + } + }); +}); diff --git a/scripts/documents.checks.test.ts b/scripts/documents.checks.test.ts new file mode 100644 index 00000000..b8bd165f --- /dev/null +++ b/scripts/documents.checks.test.ts @@ -0,0 +1,1014 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { describe, test } from 'node:test'; + +import { LIST_MARKER, TASK_BOX } from '../src/lib/utils/listSyntax.js'; +import { MARKDOWN_LINK_EXTENSIONS } from '../src/lib/utils/markdownLinks.js'; +import { DEFAULT_OPEN_FILE_MODE } from '../src/lib/utils/openFileMode.js'; +import { viewerCommandTable } from './keymapHarness.js'; +import { + callbackBodies, + filesMatching, + functionSource, + offsetOf, + readDictionarySource, + readRustBackend, + readSource, + readSourceFiles, + sliceBetween, + sliceFrom, +} from './sourceTree.js'; + +/* + * #379 made every read that fills a WRITABLE buffer report whether the decode + * was lossy, so the tab can refuse to write U+FFFD back over a file that was + * merely in another encoding. Three reads kept the bare command: + * `ensureFullContent`, and the two in MarkdownViewer that fill the editor and + * the split pane. They were safe — but only because each re-read a file whose + * tab `loadMarkdown` had already flagged. That is an invariant held by call + * sites agreeing with each other, and the failure mode when one stops agreeing + * is a destroyed document. It is now held by the code: every one of them reads + * the fidelity itself. + * + * WHAT IS LEFT IN THIS FILE, AND WHY IT IS HERE RATHER THAN UNDER VITEST + * + * Every assertion below reads source text, and each one is of a kind no run can + * reach: + * + * - that the unchecked command exists NOWHERE. An absence is not observable + * at run time: executing the checked read cannot prove there is no other + * caller of the unchecked one. The same claim then crosses into the Rust + * crate, where nothing but the spelling connects the two sides. + * - the two MarkdownViewer call sites, and the two auto-save ones. They are + * in a `.svelte` component, and mounting one here would put jsdom in for + * Monaco, mermaid and KaTeX to earn a weaker claim than the text does — + * what these pin is the ORDER of statements inside a handler (flag the tab + * before the buffer is published; recognise the refusal before raising the + * generic toast), which is a property of the code as written. + * + * `ensureFullContent` is not here: it is a runes module driven for real, and + * running it under a hand-written `$state` was standing exactly where the + * assertions look. It is `checkedReadMigration.spec.ts` now. `lossyDecodeSaveGuard.test.ts` + * and `lossySaveRefusalScope.spec.ts` cover the store behaviour these source + * assertions deliberately stop short of. + * + * The second half of this file is the toast the guard's refusal used to + * trigger every 1.5 seconds. + */ +describe('checked read migration', () => { + // --- the two component call sites ------------------------------------------- + + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('the unchecked read command is gone, and nothing calls it', () => { + // This was a three-file allowlist — the files #379 migrated. That is the + // wrong shape for a rule that means "nobody, anywhere": a fourth file + // added the call and the test still passed. Two changes fix it. The scan + // below covers the whole tree, so a new file cannot slip under it. And the + // Rust command itself is deleted, which is what turns the rule from a + // convention into a fact: `read_file_content` is not registered any more, + // so invoking it fails loudly instead of quietly filling a buffer with + // mojibake nothing flagged. + const offenders = readSourceFiles('src') + .filter(({ text }) => /invoke\('read_file_content'/.test(text)) + .map(({ path }) => path); + assert.deepEqual(offenders, [], 'read_file_content no longer exists; read_file_content_checked is the read'); + + const rust = readRustBackend(); + assert.doesNotMatch(rust, /\basync fn read_file_content\(/, 'the unchecked command must stay deleted'); + assert.doesNotMatch(rust, /^\s*read_file_content,\s*$/m, 'and must not be registered again'); + }); + + test('entering the editor reads the fidelity and stores it', () => { + const toggle = sliceBetween(viewer, 'async function toggleEdit', 'async function saveContent'); + assert.match(toggle, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const read = offsetOf(toggle, 'read_file_content_checked'); + const flag = offsetOf(toggle, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(toggle, 'setTabRawContent(tab.id, content)'); + assert.ok(read < flag && flag < store, 'flag the tab before the buffer is published'); + }); + + test('entering split view reads the fidelity and stores it', () => { + const enter = sliceBetween(viewer, 'async function toggleSplitView', '} else {'); + assert.match(enter, /\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked', \{ path: tab\.path \}\)\)/); + const flag = offsetOf(enter, 'setTabDecodedLossy(tab.id, lossy)'); + const store = offsetOf(enter, 'setTabRawContent(tab.id, content)'); + assert.ok(flag < store, 'flag the tab before the buffer is published'); + }); + + // --- the repeating toast ------------------------------------------------------ + + test('the session can tell a refusal from a failure', () => { + // `saveContent` returns false for both, which is why the auto-save timer + // could not tell them apart. The predicate is no longer set membership on + // its own: the set records what was SAID, and whether the refusal still + // stands is asked of the tab. `lossySaveRefusalScope.spec.ts` exercises + // both halves for real; this only pins that the tab is consulted at all, + // since a predicate that answers from memory alone is the defect. + const body = sliceBetween( + readSource('src/lib/sessions/documentSession.svelte.ts'), + 'function isLossySaveRefused(', + 'function updateLoading', + ); + assert.match(body, /lossySaveWarnedTabs\.has\(tabId\)/); + assert.match(body, /hasReplacementChars/, 'the live tab decides whether anything is still being refused'); + }); + + test('a refused save does not add a generic toast to its own explanation', () => { + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + const check = offsetOf(body, 'if (documentSession.isLossySaveRefused(s.id)) return;'); + const toast = offsetOf(body, "t('toast.autoSaveFailed'"); + assert.ok(check < toast, 'the refusal must be recognised before the generic toast is raised'); + }); + + test('a tab that can only be refused stops re-arming the timer', () => { + // Auto-save re-arms on every keystroke. Without this the guard was reached + // again every 1.5s for as long as the user kept typing — each time + // producing the console warning, the wasted round trip, and (before the + // test above) the toast. + const body = sliceBetween(viewer, 'Auto-save effect.', 'for (const id of ['); + assert.match(body, /decodedLossily: tab\.hasReplacementChars/); + assert.match(body, /const eligible = [^;]*!\(s\.decodedLossily && documentSession\.isLossySaveRefused\(s\.id\)\)/); + // The FIRST attempt must still happen: it is what produces the explanation. + // `isLossySaveRefused` is false until the guard has spoken, so eligibility + // only drops afterwards — and "Save As" to a new file clears + // `hasReplacementChars`, which restores it. + assert.match( + readSource('src/lib/sessions/documentSession.svelte.ts'), + /lossySaveWarnedTabs\.delete\(tab\.id\)/, + ); + }); +}); + +// Every read path decodes leniently (#371): a file it cannot read opens with +// U+FFFD substituted for the bytes it could not, instead of failing. U+FFFD is +// not reversible — the original bytes are gone from the buffer. Writing that +// buffer back over the source file (auto-save does it 1.5s after the first +// keystroke) destroys the document permanently. +// +// Detection (#372) removed the common cause rather than the guard: a legacy +// encoding (GBK, Big5, Shift-JIS, CP-1252 ...) is now read properly and +// written back as itself — `documentEncodingRoundTrip.spec.ts` drives that. +// What is left here is the file NO encoding can read, which is still a buffer +// that must never reach its own file. +// +// The guard: Rust reports the fidelity of every decode, the tab carries it — +// through window restore and cross-window moves — and `documentSession` +// refuses to write such a buffer over the file it came from. That is the +// single choke point every writer shares, including MarkdownViewer's +// auto-save timer and the close dialogs. "Save As" to a *different* path +// stays open: the new file is genuinely UTF-8, so writing it is correct, and +// it is the user's only way out. +describe('lossy decode save guard', () => { + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + const windowSession = readSource('src/lib/sessions/windowSession.svelte.ts'); + const tabs = readSource('src/lib/stores/tabs.svelte.ts'); + const rust = readRustBackend(); + + const loadMarkdown = () => sliceBetween(session, 'async function loadMarkdown', 'async function saveContent'); + const saveContent = () => sliceBetween(session, 'async function saveContent(', 'async function saveContentAs'); + const saveContentAs = () => sliceBetween(session, 'async function saveContentAs', 'async function toggleTaskCheckbox'); + const guard = () => sliceBetween(session, 'function refuseIfLossilyDecoded', 'function updateLoading'); + + test('one decoder, and it reports what it destroyed', () => { + // The fact is known exactly once — when the bytes are decoded — and was + // thrown away. Detection (#372) made the decode able to succeed where it + // used to substitute, but not able to always succeed, so the decoder still + // carries the verdict rather than growing a second path. + assert.match(rust, /struct DecodedText \{[^}]*\bcontent: String,[^}]*\blossy: bool/s); + assert.match(rust, /fn decode_text\(bytes: &\[u8\]\) -> DecodedText/); + assert.match(rust, /fn read_to_string_lossy\(path: &str\) -> std::io::Result/); + assert.equal(rust.match(/fn decode_text\(/g)?.length, 1, 'exactly one decoder'); + }); + + test('both read commands can report fidelity', () => { + assert.match(rust, /async fn open_markdown_preview\([^)]*\)\s*-> Result<\(String, bool, bool, String\), String>/s); + assert.match(rust, /async fn read_file_content_checked\(path: String\) -> Result<\(String, bool, String\), String>/); + assert.match(rust, /read_file_content_checked,/, 'the command must be registered'); + }); + + test('a preview cut inside a multi-byte character is not called lossy', () => { + // The preview stops at a fixed byte count, so a perfectly valid UTF-8 file + // is routinely cut mid-character. Reporting that as lossy would lock every + // large CJK/emoji document out of saving — a guard worse than the bug. + const preview = sliceBetween(rust, 'fn build_markdown_preview', '#[tauri::command]'); + const trim = offsetOf(preview, 'utf8_truncation_boundary'); + assert.ok(trim < offsetOf(preview, 'decode_text('), 'the split tail is dropped before fidelity is judged'); + }); + + test('the tab carries the fidelity of its buffer', () => { + // Required, not optional: a construction site that forgot it would default + // to "safe to overwrite", and the failure mode is a destroyed document. + assert.match(tabs, /hasReplacementChars: boolean;/); + assert.doesNotMatch(tabs, /hasReplacementChars\?: boolean;/); + assert.match(tabs, /setTabDecodedLossy\(id: string, lossy: boolean\)/); + assert.equal(tabs.match(/hasReplacementChars: false/g)?.length, 4); + }); + + test('every load decides the flag instead of leaving it stale', () => { + // A reload of a file the user has since converted to UTF-8 must clear it. + const body = loadMarkdown(); + assert.match(body, /as \[string, boolean, boolean, string\]/); + assert.match(body, /setTabDecodedLossy\(activeId, lossy\)/); + // The full load REPLACES the preview's buffer, so it brings its own + // verdict: a file can be valid UTF-8 for the first 50KB and not after. + assert.match(body, /read_file_content_checked/); + assert.match(body, /setTabDecodedLossy\(activeId, fullLossy\)/); + }); + + test('the editable-pane shortcut reports fidelity too', () => { + // #374 added a second way into loadMarkdown: a pane that can WRITE skips + // the preview and reads the whole file up front. That branch fills an + // editable buffer, so it is exactly the kind of caller that must not use + // the bare command — it would hand the editor unflagged mojibake. + const body = loadMarkdown(); + assert.match( + body, + /if \(initialIsEditing \|\| initialIsSplit\) \{\s*\[content, lossy, encoding\] = \(await invoke\('read_file_content_checked'/, + ); + // `ensureFullContent` was the one place the bare command survived, and only + // because it re-read a file whose tab loadMarkdown had already flagged — + // an invariant enforced by two call sites agreeing rather than by the code. + // It now reads the fidelity itself, so no writable buffer in this file is + // filled by a command that cannot report one. + const bare = session.match(/invoke\('read_file_content'/g)?.length ?? 0; + assert.equal(bare, 0, 'no writable buffer may be filled by the unchecked command'); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /invoke\('read_file_content_checked'/, + ); + assert.match( + sliceBetween(session, 'async function ensureFullContent', 'const lossySaveWarnedTabs'), + /setTabDecodedLossy\(tabId, lossy\)/, + ); + }); + + test('the flag is set before the buffer can reach a writer', () => { + const body = loadMarkdown(); + const set = offsetOf(body, 'setTabDecodedLossy(activeId, lossy)'); + const buffer = offsetOf(body, 'setTabRawContent(activeId, content'); + assert.ok(set < buffer, 'the flag must be set before the buffer it describes'); + assert.doesNotMatch(body.slice(set, buffer), /await /, 'and in the same synchronous block'); + }); + + test('window restore cannot launder the flag away', () => { + // Restore reads each file back into an editable buffer. With the bare + // command it produced mojibake that looked safe to save, so reopening the + // app was a way around the guard. It now reads through `loadMarkdown`, + // which sets the flag on every load, and reads nothing itself. + assert.doesNotMatch(windowSession, /invoke\('read_file/); + assert.match(sliceBetween(session, 'function readRestoredTab', 'function readActiveIfUnread'), /return loadMarkdown\(tab\.path,/); + assert.match(loadMarkdown(), /tabManager\.setTabDecodedLossy\(activeId, lossy\)/); + }); + + test('saveContent refuses to write a lossy buffer over its own file', () => { + const body = saveContent(); + const refusal = offsetOf(body, 'refuseIfLossilyDecoded'); + assert.match(body.slice(refusal, refusal + 120), /return false;/); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the guard protects the source file only', () => { + const body = guard(); + // An untitled buffer has no source file to destroy, and any other path is + // a new UTF-8 file — neither is blocked. + assert.match(body, /targetPath === ''/); + assert.match(body, /targetPath !== tab\.path/); + assert.match(body, /return true;/); + }); + + test('saveContent is the choke point auto-save and the close dialogs share', () => { + // Auto-save lives in MarkdownViewer.svelte's debounce effect and the close + // flow in canCloseTab; both reach saveContent, so guarding it covers them + // without touching either. + assert.match(session, /const success = await saveContent\(tabId\);/); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + assert.match(viewer, /saveSilently\(s\.id\)\.then\(/); + assert.match(functionSource(viewer, 'saveSilently'), /documentSession\.saveContent\(tabId\)/); + }); + + test('Save As to a new file stays open as the escape hatch', () => { + const body = saveContentAs(); + // Explicitly UTF-8, never the tab's own encoding: the copy exists to be + // readable when the original could not be read, or could not represent + // what the user typed into it. + assert.match(body, /invoke\('save_file_content', \{ path: selected, content: snapshot, encoding: 'UTF-8' \}\)/); + // Once written, the buffer matches a UTF-8 file of its own. + assert.match(body, /setTabDecodedLossy\(tab\.id, false\)/); + assert.match(body, /setTabEncoding\(tab\.id, 'UTF-8'\)/); + }); + + test('Save As onto the same file is still a destructive overwrite', () => { + const body = saveContentAs(); + // "The same file", not "the same string": the dialog can come back with + // another spelling of the source file — a different case, or accents + // composed differently — and on macOS and Windows that still lands on the + // same bytes. The guard is therefore handed the target's resolved identity + // as well as its path. See pathIdentityCaseFolding.spec.ts, which drives + // that refusal for real instead of reading for it. + const refusal = offsetOf(body, 'refuseIfLossilyDecoded(tab, selected'); + assert.ok( + refusal < offsetOf(body, "invoke('save_file_content'"), + 'the guard must run before save_file_content is invoked', + ); + }); + + test('the refusal tells the user what to do and is not repeated per keystroke', () => { + // Auto-save re-arms on every edit; an undeduplicated toast would fire + // every 1.5s. The message names "Save As" because that is the only exit. + const body = guard(); + assert.match(body, /toast\.lossySaveBlocked/); + assert.match(body, /lossySaveWarnedTabs\.has\(tab\.id\)/); + const i18n = readDictionarySource(); + assert.match(i18n, /lossySaveBlocked: 'Not saved: parts of this file could not be read in any encoding/); + assert.match(i18n, /use "Save As" to write a copy/); + }); +}); + +describe('save from reading mode', () => { + test('the browser save dialog is always suppressed', () => { + // preventDefault has to run for every mode, including the no-op case, or + // reading mode would surface the webview's own Save Page dialog. It is one + // unconditional call on the command rather than one per branch now, so this + // reads the six-line handler rather than the branch it used to sit in — and + // covers all twenty-two commands instead of Save alone. + const handler = functionSource(readSource('src/lib/MarkdownViewer.svelte'), 'handleKeyDown'); + assert.match( + handler, + /if \(!command\) return;\s*\n\s*e\.preventDefault\(\);/, + 'a command that runs must have had the keystroke prevented first', + ); + }); + + /* + * The three below are the residue of this seam, and they say so. + * + * What Ctrl+S DOES is a closure over `tabManager.activeTab` inside the + * component, which is the one thing that could not move: see the note on + * `viewerCommandTable` in `keymapHarness.ts`. They are still text assertions — + * but text anchored on a `case 'save':` label rather than on a chord condition, + * so the rename that reddened five of these in #647 no longer reaches them. + */ + const saveCommand = () => viewerCommandTable()['save']; + + test('an untitled buffer can be saved from reading mode', () => { + // `path === ''` is the untitled case; documentSession.saveContent opens + // the Save dialog for it, so no extra plumbing is needed here. + assert.match(saveCommand(), /saveTarget\.path === ''/); + }); + + test('a saved, unmodified document does not write on Ctrl+S', () => { + // Writing unconditionally would touch mtime and wake the file watcher, + // which in live mode reloads the tab -- a keystroke that reads as a no-op + // to the user should not move the document underneath them. + assert.match(saveCommand(), /saveTarget\.isDirty \|\| saveTarget\.path === ''/); + }); + + test('the HOME tab is not savable', () => { + // HOME carries the sentinel path 'HOME' and is never dirty, so the guard + // excludes it without naming it. This pins that reasoning: if the guard + // ever becomes unconditional, HOME would start opening a Save dialog. + assert.doesNotMatch(saveCommand(), /^\s*saveContent\(\);\s*$/m); + }); +}); + +describe('unsaved recovery', () => { + test('the close decision commands are spelled the same in the viewer and in Rust', () => { + // Cross-language contract: only the string connects invoke() to the command. + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const rust = readSource('src-tauri/src/window_runtime.rs'); + for (const name of ['keeps_unsaved_on_close', 'set_quitting']) { + assert.ok(viewer.includes(`invoke('${name}'`) || viewer.includes(`invoke('${name}'`), `viewer does not invoke ${name}`); + assert.match(rust, new RegExp(`pub fn ${name}\\(`), `Rust does not declare ${name}`); + } + }); +}); + +describe('live mode watched path', () => { + const runtime = readSource('src-tauri/src/window_runtime.rs'); + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + + test('a watcher notification is routed to its watched path', () => { + assert.match(runtime, /emit_to\(event_label\.as_str\(\), "file-changed", watched_path\.clone\(\)\)/); + // `async` because deciding what an event means now reads the file: the + // answer to "did somebody else write this" is the file itself, not a clock. + assert.match(viewer, /await appWindow\.listen\('file-changed', async \(event\) => \{/); + assert.match(viewer, /const changedPath = event\.payload as string;/); + // The listener no longer compares the payload with the active file itself: + // resolveExternalChange looks up the tab that OWNS the changed path (and + // refuses to reload it when it has unsaved edits). See + // externalChangeReload.spec.ts for the routing behaviour. + assert.match(viewer, /await documentSession\.resolveExternalChange\(changedPath\)/); + // A conflict is reported with Live Mode off too; Live Mode gates only the reload. + assert.match(viewer, /if \(liveMode\) loadMarkdown\(outcome\.path\);/); + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + assert.match(session, /tabManager\.tabs\.find\(\(tab\) => tab\.path === changedPath\)/); + }); + + test('the watcher follows the active file instead of retaining a previous tab watcher', () => { + assert.match(viewer, /if \(currentFile\) \{\n\t\t\tinvoke\('watch_file', \{ path: currentFile \}\)/); + + // The defect #296 fixed was `documentSession` re-issuing `watch_file` on + // every load, so the watcher followed whichever document loaded last rather + // than the active one. It used to be pinned as + // `/isLiveMode\(\)\) invoke\('watch_file'/` — the exact one-line spelling + // the defect happened to be written in, against a file where neither token + // occurs any more. Rewriting the same call as an ordinary `if (…) { … }` + // evaded it. What has to hold is who may call it: the effect above, alone. + const watchers = filesMatching(readSourceFiles('src'), /invoke\(\s*'watch_file'/); + assert.deepEqual(watchers, ['src/lib/MarkdownViewer.svelte'], 'one owner for the file watcher'); + + const watchingEffects = callbackBodies(viewer, '$effect').filter((body) => /'watch_file'/.test(body)); + assert.equal(watchingEffects.length, 1, 'and one effect inside it'); + assert.match(watchingEffects[0], /invoke\('unwatch_file'\)/, 'the same effect gives the watcher up'); + + const toggleLiveMode = sliceBetween(viewer, 'function toggleLiveMode', 'async function saveImageAs'); + assert.doesNotMatch(toggleLiveMode, /loadMarkdown\(/); + }); +}); + +describe('editor line ending', () => { + test('the status bar renders the measured label and no longer a translation key', () => { + const editor = readSource('src/lib/components/Editor.svelte'); + const statusBar = sliceBetween(editor, '
', '
\n{/if}'); + + assert.match(statusBar, /
\{lineEnding\}<\/div>/, 'the line-ending item shows the state'); + assert.doesNotMatch(editor, /editor\.status\.crlf/, 'the hardcoded CRLF string is gone'); + assert.match( + editor, + /lineEnding = lineEndingLabel\(model\)/, + 'and it is filled from the model, in the function that refreshes document-wide readings', + ); + + // One line-ending decision reaches this component. A second one — a `\r\n` + // test of its own over `getValue()` — is the defect this fix replaces, in a + // new place. + assert.doesNotMatch(editor, /includes\('\\r\\n'\)/, 'no hand-rolled line-ending detection in the editor'); + }); +}); + +describe('file associations', () => { + const config = JSON.parse(readSource('src-tauri/tauri.conf.json')) as { + bundle: { + fileAssociations: Array<{ ext: string[]; contentTypes?: string[]; mimeType?: string; name: string }>; + }; + }; + + // Every extension Markpad treats as a Markdown document is associated, except + // `.txt`. An extension added to either list fails here until the other follows. + test('installer associates every Markdown extension except txt', () => { + const extensions = config.bundle.fileAssociations.flatMap((association) => association.ext); + assert.deepEqual( + extensions, + MARKDOWN_LINK_EXTENSIONS.filter((ext) => ext !== 'txt'), + ); + }); + + // macOS ignores `ext` once an association has a content type, and its + // Markdown type (net.daringfireball.markdown, owned by the system) only tags + // .md and .markdown. Any other extension must sit in an association with no + // content type or MIME type, or it gets a dynamic UTI that no association + // claims and Markpad drops out of Finder's Open With. One name keeps a single + // Windows ProgID, which hooks.nsi registers under. + test('macOS claims extensions outside the system Markdown type by extension', () => { + for (const association of config.bundle.fileAssociations) { + if (association.contentTypes) { + assert.deepEqual(association.ext, ['md', 'markdown']); + } else { + assert.equal(association.mimeType, undefined); + } + assert.equal(association.name, 'Markdown File'); + } + }); + + // The other half of the same policy, kept in this file so neither side moves + // without the reader seeing both. Claiming `.txt` in the installer takes the + // Text Document association away from Notepad (#179), which is why the test + // above exists — but Markpad does open, render and save `.txt` like any other + // note, so the Open dialog has to offer them. + test('the Open dialog offers every extension Markpad treats as a document', () => { + const viewer = readSource('src/lib/MarkdownViewer.svelte'); + const links = readSource('src/lib/utils/markdownLinks.ts'); + + assert.match(links, /MARKDOWN_LINK_EXTENSIONS = \[[^\]]*'txt'/); + assert.match(viewer, /\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/); + // A literal list is how the two drifted apart in the first place. + assert.doesNotMatch(viewer, /extensions: \['md/); + }); + + test('the Save dialogs offer the same document extensions', () => { + // Both hard-coded `['md']`, so a `.txt` note saved under a new name had to + // go through "All Files". + const session = readSource('src/lib/sessions/documentSession.svelte.ts'); + + assert.equal(session.match(/\{ name: 'Markdown', extensions: MARKDOWN_LINK_EXTENSIONS \}/g)?.length, 2); + assert.doesNotMatch(session, /extensions: \['md/); + }); +}); + +/** + * #183: "I'd like it to remember that I had the split screen option turned on + * when opening a new session." + * + * Half of it already worked — a session restore replays each tab's saved + * `isSplit` — but every newly opened document was created `isSplit: false`, so + * the reporter pressed the split button again for each file. There was a + * preference for the neighbouring case and none for this one. + * + * It is ONE preference with three answers rather than a second checkbox beside + * the first, because `isEditing` and `isSplit` are independent flags on the tab: + * two booleans can say "editor AND split", which is not a state the reader can + * be in. That made the checkbox that already existed part of this change, and + * an existing install must not notice — hence the migration below. + * + * The two implementation details pinned here are the ones that are quietly + * expensive to get wrong: + * + * 1. Split must go through `setSplitEnabled`, not a bare `tab.isSplit = true`. + * A fresh tab is created `isScrollSynced: false`, and `setSplitEnabled` is + * what applies the remembered scroll-sync preference — assigning the flag + * directly opens split view with sync silently off. + * + * 2. Both must be applied BEFORE `initialIsSplit` is read. That variable is + * what sends the load down the full-read branch: `open_markdown_preview` + * returns only the first 50KB, and a split pane is an editor, so a document + * that opened split on a partial buffer would be one keystroke away from + * auto-saving the truncation back over the file. + */ +describe('open file mode', () => { + const sessionSource = readSource(new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url)); + const settingsSource = readSource(new URL('../src/lib/stores/settings.svelte.ts', import.meta.url)); + const settingsComponentSource = readSource(new URL('../src/lib/components/Settings.svelte', import.meta.url)); + const tabsSource = readSource(new URL('../src/lib/stores/tabs.svelte.ts', import.meta.url)); + + test('the old key is read, never written — a rollback still finds its setting', () => { + assert.match(settingsSource, /const LEGACY_START_IN_EDITOR_KEY = 'editor\.startInEditor'/); + assert.match(settingsSource, /read: \(s\) => s\.openFileMode/); + assert.match( + settingsSource, + /resolveOpenFileMode\(raw, readStoredKey\(LEGACY_START_IN_EDITOR_KEY\)\)/, + ); + // One `key:` per persisted entry, and this entry's is the new one: the + // legacy name must not appear as a key this store writes back. + assert.doesNotMatch(settingsSource, /key: 'editor\.startInEditor'/); + }); + + /* ------------------------------------------------------------- the wiring */ + + test('opening a document applies the choice, split through the same door the toggle uses', () => { + assert.match(sessionSource, /tab\.isEditing = settings\.openFileMode === 'editor'/); + assert.match( + sessionSource, + /if \(settings\.openFileMode === 'split'\) tabManager\.setSplitEnabled\(tab\.id, true\)/, + ); + // The premise of point 1 above: a fresh tab has sync off, and + // `setSplitEnabled` is the only place the preference is applied. + assert.match(tabsSource, /isScrollSynced: false/); + assert.match( + tabsSource, + /setSplitEnabled\(id: string, enabled: boolean\)[\s\S]{0,200}?tab\.isScrollSynced = this\.splitScrollSyncPreference/, + ); + }); + + test('the choice is applied before the load decides how much of the file to read', () => { + const applied = sessionSource.indexOf("if (settings.openFileMode === 'split')"); + const read = sessionSource.indexOf('const initialIsSplit = tab?.isSplit ?? false'); + const fullRead = sessionSource.indexOf('if (initialIsEditing || initialIsSplit)'); + + assert.ok(applied > 0 && read > 0 && fullRead > 0); + assert.ok( + applied < read && read < fullRead, + 'a document opening into an editable mode must take the full-read branch, ' + + 'or the editor is bound to the 50KB preview slice', + ); + }); + + test('it applies to a newly opened document only', () => { + // Not to a reload that is preserving the mode, and not to a file already + // open in a tab — reopening a document must not flip the mode the reader + // chose for it. The same guard the checkbox always had. + assert.match(sessionSource, /if \(tab && !loadOptions\.preserveEditState && !existing\) \{/); + }); + + test('a new empty document keeps its own preference', () => { + // `Ctrl+N` asks a different question — what an empty buffer opens as — and + // answers it with `newFileDefaultMode`. This change must not annex it. + assert.match(tabsSource, /isEditing: settings\.newFileDefaultMode/); + }); + + test('the setting is one control, defaulting to preview', () => { + assert.match(settingsSource, /openFileMode = \$state\(DEFAULT_OPEN_FILE_MODE\)/); + assert.equal(DEFAULT_OPEN_FILE_MODE, 'preview'); + assert.match(settingsComponentSource, / \u00a0\u00a0indented\n\n', + ); + + assert.equal(root.querySelector('.task-text')?.textContent, '\u00a0\u00a0indented'); + }); +}); + +describe('preview annotations', () => { + // A reader's session-only highlight: the selection goes to source points, + // and comes back as the same text however the preview was re-rendered since. + + const SOURCE = ['A paragraph with **bold text** here.', '', '中文段落里有**粗体**和测试。']; + + const HTML = '

A paragraph with bold text here.

\n

中文段落里有粗体和测试。

'; + + const readLine = (line: number) => SOURCE[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + function select(root: Element, from: string, to: string) { + const text = root.textContent!; + const start = text.indexOf(from); + const end = text.indexOf(to) + to.length; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + if (start >= seen && start < seen + node.data.length) range.setStart(node, start - seen); + if (end > seen && end <= seen + node.data.length) range.setEnd(node, end - seen); + seen += node.data.length; + } + return range; + } + + test('a selection comes back as the same text in a fresh render', () => { + const cases: Array<[string, string, string]> = [ + ['bold', 'text', 'bold text'], + ['with', 'here', 'with bold text here'], + ['text', '粗体', 'text here.\n中文段落里有粗体'], + ['粗', '测', '粗体和测'], + ]; + for (const [from, to, expected] of cases) { + const shown = render(); + const mark = annotationOf(shown, select(shown, from, to), readLine); + assert.ok(mark, `${from}…${to} mapped`); + assert.equal(rangeOf(render(), mark, readLine)?.toString(), expected); + } + }); + + const at = (line: number, column: number) => ({ line: asRendererLine(line), column }); + + const span = (a: number, b: number): Annotation => ({ start: at(1, a), end: at(1, b) }); + + test('overlap is half-open, and a point hits the mark around it', () => { + const marks = [span(3, 8), span(12, 20)]; + assert.deepEqual(overlapping(marks, span(7, 13)), marks); + assert.deepEqual(overlapping(marks, span(8, 12)), [], 'touching ends do not overlap'); + assert.deepEqual(hitAt(marks, at(1, 3)), [marks[0]]); + assert.deepEqual(hitAt(marks, at(1, 8)), [], 'the end is outside'); + assert.deepEqual(hitAt(marks, at(2, 1)), []); + }); + + test('occurrences are the exact copies of the selected text, up to the limit', () => { + const root = document.createElement('div'); + root.innerHTML = '

cat, Cat, cat

concatenate cat

'; + const found = occurrenceRanges(root, 'cat', 100); + assert.deepEqual(found.map((range) => range.toString()), ['cat', 'cat', 'cat', 'cat'], 'case-sensitive, inside words too'); + assert.equal(occurrenceRanges(root, 'cat', 2).length, 2); + }); +}); + +describe('preview copy flavours', () => { + // #674: what a preview selection looks like once it leaves the app. The + // clipboard carries no stylesheet, so anything the preview only renders + // correctly *because* of one has to be resolved here or it arrives wrong. + + const DOC = '/Users/me/notes/report.md'; + + /** The fragment a `Range.cloneContents()` would hand over, spelled as HTML. */ + function fragmentOf(html: string): DocumentFragment { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content; + } + + test('structure survives untouched', () => { + const html = copyableHtml( + fragmentOf('

Heading

bold and italic and a link

  • one
'), + DOC, + ); + assert.match(html, /

Heading<\/h2>/); + assert.match(html, /bold<\/strong>/); + assert.match(html, /italic<\/em>/); + assert.match(html, /a link<\/a>/); + assert.match(html, /
  • one<\/li>/); + }); + + test('a formula is carried once, not twice', () => { + // KaTeX renders both halves and hides the MathML in CSS. Pasted into a + // document with no KaTeX stylesheet, both are visible — the formula reads + // twice, once as MathML text and once as the glyphs. + const katex = + '' + + 'EE=mc^2' + + '' + + ''; + const html = copyableHtml(fragmentOf(katex), DOC); + assert.doesNotMatch(html, /katex-mathml/); + assert.doesNotMatch(html, /annotation/); + // The half that stays is the one the user was looking at, and the one + // `selection.toString()` already puts in the plain-text flavour. + assert.match(html, /katex-html/); + assert.match(html, /E=mc2/); + }); + + test('the fold chevrons do not travel', () => { + const html = copyableHtml( + fragmentOf('

    Section▾

    ▾Note
    '), + DOC, + ); + assert.doesNotMatch(html, /fold-icon/); + assert.match(html, /Section/); + assert.match(html, /Note/); + }); + + test('a local image becomes a URL the receiving app can open', () => { + // `asset://` (and `http://asset.localhost` on Windows) is this app's own + // scheme; anything else pasting it shows a broken image. + const posix = copyableHtml(fragmentOf(''), DOC); + assert.match(posix, /src="file:\/\/\/Users\/me\/notes\/img\/shot\.png"/); + + const windows = copyableHtml(fragmentOf(''), DOC); + assert.match(windows, /src="file:\/\/\/C:\/notes\/img\/shot\.png"/); + }); + + test('a document-relative image is resolved against the document', () => { + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/notes\/img\/diagram\.png"/); + }); + + test('a path with a space or a hash survives as a URL', () => { + // Both end a URL where they sit, so `notes #2.png` would otherwise resolve + // to `notes ` and the image would be missing rather than broken. + const html = copyableHtml(fragmentOf(''), DOC); + assert.match(html, /src="file:\/\/\/Users\/me\/my%20notes\/shot%20%232\.png"/); + }); + + test('a remote image and a data URI are left alone', () => { + const remote = copyableHtml(fragmentOf(''), DOC); + assert.match(remote, /src="https:\/\/example\.com\/a\.png"/); + + const inline = copyableHtml(fragmentOf(''), DOC); + assert.match(inline, /src="data:image\/png;base64,AAAA"/); + }); + + // ---------------------------------------------------------------- both flavours + + /** A selection over `html`, as the preview would hand one to the handler. */ + function selectionOver(html: string): Selection { + document.body.innerHTML = `
    ${html}
    `; + const range = document.createRange(); + range.selectNodeContents(document.getElementById('preview') as HTMLElement); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return selection; + } + + const KATEX_INLINE = + '' + + 'EE=mc^2' + + '' + + ''; + + test('a formula copies as its source, not as the glyphs', () => { + // What `katex/contrib/copy-tex` did before this module took the copy over. + // The rendered spans read `E=mc2`, which is not the formula and cannot be + // pasted back into a Markdown document. + const { text } = copyableFlavours(selectionOver(`

    Einstein: ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$E=mc\^2\$/); + assert.doesNotMatch(text, /E=mc2/); + }); + + test('a display formula keeps its display delimiters', () => { + const { text } = copyableFlavours(selectionOver(`

    ${KATEX_INLINE}

    `), ''); + assert.match(text, /\$\$E=mc\^2\$\$/); + }); + + test('a selection with no math takes its plain text from the selection itself', () => { + // `Selection.toString()` is the only source that knows what is rendered — + // `textContent` of the fragment would pick up hidden nodes and lose the line + // breaks between blocks. + const { text } = copyableFlavours(selectionOver('

    one

    two

    '), ''); + assert.match(text, /one/); + assert.match(text, /two/); + }); + + test('a soft break the preview hides is not pasted as a line break (#967)', () => { + const copy = (article: string) => { + document.body.innerHTML = `

    one
    \ntwo
    \nthree

    `; + const paragraph = document.querySelector('p') as HTMLElement; + const range = document.createRange(); + range.setStart(paragraph.firstChild as Text, 0); + range.setEnd(paragraph.lastChild as Text, 6); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + selection.addRange(range); + return copyableFlavours(selection, '').html; + }; + assert.match(copy('markdown-body'), /one
    \ntwo
    /); + assert.match(copy('markdown-body soft-breaks'), /one\ntwo
    \nthree/); + }); + + test('both flavours come back from one call', () => { + const { text, html } = copyableFlavours(selectionOver('

    bold

    '), ''); + assert.match(html, /bold<\/strong>/); + assert.match(text, /bold/); + }); +}); + +describe('preview cursor mapping', () => { + // #799's shared cursor: a click in the preview lands on a source character, + // and the editor's cursor is drawn back onto the rendered character. The HTML + // is `convert_markdown`'s real output for SOURCE, so the columns are comrak's + // UTF-8 byte offsets, which the Chinese lines exercise. + + const SOURCE = "# Title here\n\nA paragraph with **bold text** and a [link](https://example.com) here.\nSecond line with `code` and an escaped \\* star & more.\n\n中文段落里有**粗体**和测试。\n\n- item one\n- item **two**\n\n> quoted text\n\n```js\nlet x = 1;\n```\n\n| name | 值 |\n| --- | --- |\n| 甲 | beta |\n"; + + const HTML = "

    Title here

    \n

    A paragraph with bold text and a link here.
    \nSecond line with code and an escaped * star & more.

    \n

    中文段落里有粗体和测试。

    \n
      \n
    • item one
    • \n
    • item two
    • \n
    \n
    \n

    quoted text

    \n
    \n
    let x = 1;\n
    \n\n\n\n\n\n\n\n\n\n\n\n\n\n
    name值
    甲beta
    \n"; + + const sourceLines = SOURCE.split('\n'); + + const readLine = (line: number) => sourceLines[line - 1]; + + function render() { + const root = document.createElement('div'); + root.innerHTML = HTML; + return root; + } + + /** The text node and offset of the `nth` occurrence of `char` in rendered text under `selector`. */ + function charIn(root: Element, selector: string, char: string, nth = 0) { + const walker = document.createTreeWalker(root.querySelector(selector)!, NodeFilter.SHOW_TEXT); + let seen = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (node.data[offset] === char && seen++ === nth) return { node, offset }; + } + } + throw new Error(`no ${char} under ${selector}`); + } + + const at = (line: number, column: number): SourcePoint => ({ line: asRendererLine(line), column }); + + test('a click lands on the source character it rendered', () => { + const root = render(); + const cases: Array<[string, string, SourcePoint, number?]> = [ + ['h1', 'T', at(1, 3)], + ['p strong', 'b', at(3, 20)], + ['p a', 'l', at(3, 39)], + ['p code', 'c', at(4, 19)], + ['p', '*', at(4, 41)], + ['p', '&', at(4, 48)], + ['p:nth-of-type(2)', '粗', at(6, 9)], + ['p:nth-of-type(2)', '测', at(6, 14)], + ['li', 'o', at(8, 8)], + ['li:nth-of-type(2) strong', 't', at(9, 10)], + ['blockquote p', 'q', at(11, 3)], + ['pre', 'x', at(14, 5)], + ['td', '甲', at(19, 3)], + ['td:nth-of-type(2)', 'b', at(19, 7)], + ]; + for (const [selector, char, expected] of cases) { + assert.deepEqual(sourceAtPoint(root, charIn(root, selector, char), readLine), expected, `${char} in ${selector}`); + assert.equal(sourceLines[expected.line - 1][expected.column - 1], char, `${char} is the source character there`); + } + }); + + test('every rendered character goes to the source and back to itself', () => { + const root = render(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let checked = 0; + for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { + for (let offset = 0; offset < node.data.length; offset++) { + if (/\s/.test(node.data[offset]) || !node.parentElement?.closest('[data-sourcepos]')) continue; + const source: SourcePoint = sourceAtPoint(root, { node, offset }, readLine)!; + const back: DomPoint = pointAtSource(root, source, readLine)!; + assert.equal(back.node, node, `"${node.data[offset]}" came back in another node`); + assert.equal(back.offset, offset, `"${node.data[offset]}" came back at another offset`); + checked++; + } + } + assert.ok(checked > 130, `the fixture was walked (${checked})`); + }); + + test('a fenced block pairs its code, not its info string', () => { + // `js` on the fence would take the first two characters of `js = 1`. + const root = document.createElement('div'); + root.innerHTML = '
    js = 1;\n
    '; + const lines = ['```js', 'js = 1;', '```']; + const point = sourceAtPoint(root, charIn(root, 'pre', 's'), (line) => lines[line - 1]); + assert.deepEqual(point, at(2, 2)); + }); + + test('a point inside KaTeX lands just after the formula', () => { + const root = document.createElement('div'); + root.innerHTML = '

    sum x2x^2 done

    '; + const katex = root.querySelector('.katex')!; + const point = sourceAtPoint(root, { node: katex.firstChild!, offset: 1 }, () => 'sum $x^2$ done'); + assert.deepEqual(point, at(1, 10), 'on the space after `$x^2$`'); + }); +}); + +describe('preview patch', () => { + /** + * The block patch, from the renderer's split output to the live article. + * `previewPatchParity.spec.ts` holds it to the whole-document pipeline over a + * real document; these pin which nodes it keeps, which it replaces, and what + * it hands on, one edit at a time. + */ + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + const patch = (html: string, folds = new Set()) => patchPreview(host, html, '/docs/a.md', folds); + + const split = (...parts: string[]) => parts.join('\0\n'); + + const p = (line: number, text: string) => `

    ${text}

    `; + + const h = (level: number, line: number, text: string, id = text.toLowerCase()) => + `${text}`; + + const bodies = () => Array.from(host.querySelectorAll('p')) as HTMLElement[]; + + describe('the first render', () => { + test('inserts every block, folds each section under its heading, and hands all of it on', () => { + const result = patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.className)).toEqual(['foldable-header', 'foldable-content-wrapper']); + expect(host.querySelectorAll('.foldable-content-wrapper > .content-inner > p').length).toBe(2); + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1', 'P', 'P']); + expect(result.wrappers.length).toBe(1); + }); + + test('carries no whitespace between blocks, so children and childNodes stay one list', () => { + patch(split(p(1, 'a'), p(3, 'b')) + '\n'); + expect(host.childNodes.length).toBe(host.children.length); + }); + }); + + describe('raw HTML that breaks out of the batch a first render parses blocks in', () => { + test('is rendered block by block instead, with nothing attributed to the wrong block', () => { + const forged = '
    a

    b

    '; + const result = patch(split(p(1, 'alpha'), forged, p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alpha', 'a', 'b', 'beta']); + expect(result.inserted.length).toBe(4); + + patch(split(p(1, 'alphaX'), forged, p(5, 'beta'))); + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alphaX', 'a', 'b', 'beta']); + }); + }); + + describe('typing one character in the middle of a document', () => { + const before = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'), p(7, 'gamma')); + const after = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'betaX'), p(7, 'gamma')); + + test('replaces exactly that block, in place, and keeps every other node', () => { + patch(before); + const [alpha, beta, gamma] = bodies(); + const heading = host.querySelector('h1'); + const wrapper = host.querySelector('.foldable-content-wrapper'); + + const result = patch(after); + + const [alphaAfter, betaAfter, gammaAfter] = bodies(); + expect(alphaAfter).toBe(alpha); + expect(gammaAfter).toBe(gamma); + expect(betaAfter).not.toBe(beta); + expect(betaAfter.textContent).toBe('betaX'); + expect(host.querySelector('h1')).toBe(heading); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(result.inserted).toEqual([betaAfter]); + expect(result.wrappers).toEqual([]); + }); + }); + + describe('inserting a line above everything', () => { + test('keeps the blocks that only moved and gives them their new source ranges', () => { + patch(split(p(3, 'alpha'), `
      \n
    • item
    • \n
    `)); + const [alpha] = bodies(); + const item = host.querySelector('li')!; + + const result = patch(split(p(4, 'alpha'), `
      \n
    • item
    • \n
    `)); + + expect(result.inserted).toEqual([]); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('li')).toBe(item); + expect(alpha.getAttribute('data-sourcepos')).toBe('4:1-4:5'); + // The task checkbox toggle writes to the line it reads here. + expect(item.getAttribute('data-sourcepos')).toBe('6:1-6:9'); + }); + + test('moves the line anchors this side added by the same amount', () => { + const long = (line: number) => + `

    a
    \nb
    \nc

    `; + patch(long(1)); + const anchors = () => Array.from(host.querySelectorAll('.source-line-anchor')).map((a) => a.getAttribute('data-sourcepos')); + expect(anchors()).toEqual(['2:1-2:1', '3:1-3:1']); + + patch(long(4)); + expect(anchors()).toEqual(['5:1-5:1', '6:1-6:1']); + }); + }); + + describe('headings', () => { + test('typing in one replaces the heading and keeps its wrapper and everything in it', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'))); + const [alpha] = bodies(); + const wrapper = host.querySelector('.foldable-content-wrapper')!; + + const result = patch(split(h(1, 1, 'Titles'), p(3, 'alpha'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1']); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(host.querySelector('h1')!.getAttribute('data-fold-target')).toBe(wrapper.id); + }); + + test('renaming one of many identical ones replaces the renumbered headings and nothing else', () => { + const sections = (names: string[]) => { + const seen = new Map(); + return split( + ...names.flatMap((name, index) => { + const slug = name.toLowerCase(); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + return [h(2, index * 4 + 1, name, n ? `${slug}-${n}` : slug), p(index * 4 + 3, `body ${index}`)]; + }), + ); + }; + patch(sections(['Objectives', 'Objectives', 'Objectives'])); + const kept = bodies(); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + + const result = patch(sections(['Goals', 'Objectives', 'Objectives'])); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2', 'H2', 'H2']); + expect(bodies()).toEqual(kept); + bodies().forEach((body, index) => expect(body).toBe(kept[index])); + expect(Array.from(host.querySelectorAll('.foldable-content-wrapper'))).toEqual(wrappers); + expect(Array.from(host.querySelectorAll('h2')).map((e) => e.id)).toEqual(['goals', 'objectives', 'objectives-1']); + }); + + test('a new section regroups the blocks after it under the new heading, without rebuilding them', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), p(5, 'beta'))); + const [alpha, beta] = bodies(); + + const result = patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2']); + expect(result.wrappers.length).toBe(2); + const sections = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(sections[0].contains(alpha)).toBe(true); + expect(sections[1].contains(beta)).toBe(true); + }); + + test('a new subsection leaves the sections around it where they were', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Three'), p(11, 'gamma'))); + const [one, twoBody, three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + two.nextElementSibling!.classList.add('is-collapsed'); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(3, 9, 'Sub'), p(11, 'x'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[1]).toBe(twoBody); + expect(wrappers[wrappers.length - 1]).toBe(three); + // Only the new subsection is wrapped: the section around it ends where it did. + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['sub']); + // The section the reader closed is still closed, and now holds the new one. + expect(two.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + expect(two.nextElementSibling!.querySelector('#sub')).not.toBeNull(); + }); + + test('a new section of the same level re-wraps only the section it splits', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), p(9, 'b2'), h(2, 11, 'Three'), p(13, 'gamma'))); + const [one, , three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const kept = bodies(); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Mid'), p(11, 'b2'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['two', 'mid']); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[3]).toBe(three); + expect(bodies().every((body, index) => body === kept[index])).toBe(true); + expect(wrappers[2].textContent).toBe('b2'); + }); + + test('a new paragraph right under a heading goes into that heading\'s section, first', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(h(2, 1, 'One'), p(3, 'new'), p(5, 'alpha'))); + + const inner = host.querySelector('.content-inner')!; + expect(Array.from(inner.children).map((e) => e.textContent)).toEqual(['new', 'alpha']); + }); + + test('a new paragraph above the first heading stays outside every section', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(p(1, 'lead'), h(2, 3, 'One'), p(5, 'alpha'))); + + expect(host.firstElementChild!.textContent).toBe('lead'); + }); + }); + + describe('folds', () => { + const doc = split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta')); + + test('a fold the document is told to keep closed is closed on the first render', () => { + patch(doc, new Set(['two'])); + expect(host.querySelector('#two')!.classList.contains('is-collapsed')).toBe(true); + expect(host.querySelector('#two')!.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + }); + + test('a fold the reader closed stays closed through an edit somewhere else', () => { + patch(doc); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + + patch(doc.replace('>alpha<', '>alphaX<')); + + expect(host.querySelector('#two')).toBe(two); + expect(two.classList.contains('is-collapsed')).toBe(true); + }); + + test('callouts with the same title get keys of their own', () => { + const callout = (line: number) => + `
    \n

    [!note]- Same
    \nx

    \n
    `; + patch(split(callout(1), callout(4))); + expect(Array.from(host.querySelectorAll('.callout-foldable')).map((e) => e.getAttribute('data-fold-key'))).toEqual([ + 'callout:Same', + 'callout:Same~1', + ]); + }); + }); + + describe('blocks that renderRichContent replaced', () => { + const pre = (line: number) => `
    x\n
    `; + + /** What the code-block pass does: a new parent, recorded for the patch. */ + function shell(element: Element): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'code-block-shell'; + element.replaceWith(wrapper); + wrapper.appendChild(element); + replacedBy.set(element, wrapper); + return wrapper; + } + + test('are kept through an edit elsewhere, and removed whole when their block goes', () => { + patch(split(pre(1), p(5, 'alpha'))); + const wrapper = shell(host.querySelector('pre')!); + + patch(split(pre(1), p(5, 'alphaX'))); + expect(host.firstElementChild).toBe(wrapper); + + patch(split(p(1, 'alphaX'))); + expect(host.querySelector('.code-block-shell')).toBeNull(); + expect(host.children.length).toBe(1); + }); + + test('get their new source range through the node that replaced them', () => { + patch(split(pre(1))); + const original = host.querySelector('pre')!; + const diagram = document.createElement('div'); + diagram.className = 'mermaid-diagram'; + diagram.setAttribute('data-sourcepos', original.getAttribute('data-sourcepos')!); + original.replaceWith(diagram); + replacedBy.set(original, diagram); + + patch(split(pre(2))); + + expect(host.firstElementChild).toBe(diagram); + expect(diagram.getAttribute('data-sourcepos')).toBe('2:1-4:3'); + }); + }); + + /** + * The memos in `previewAnchor.ts` assume a node whose source lines changed is a + * new node. A kept node with rewritten ranges is not, so the patch has to drop + * them — shown by asking `previewAnchor` its own questions across a patch. + * jsdom has no layout, so the boxes are supplied. + */ + describe('the source ranges previewAnchor has already read', () => { + test('follow a range rewritten on a node that was kept', () => { + patch(split(p(1, 'alpha'), p(3, 'beta'))); + const [alpha, beta] = bodies(); + const boxes = new Map([ + [alpha, { top: 0, height: 100 }], + [beta, { top: 100, height: 100 }], + ]); + const measure = (node: AnchorNode): AnchorBox => boxes.get(node) ?? { top: NaN, height: NaN }; + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(100); + + patch(split(p(3, 'alpha'), p(5, 'beta'))); + + expect(bodies()).toEqual([alpha, beta]); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(0); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(5), measure)).toBe(100); + }); + + test('follow kept blocks inside a kept section', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + expect(findAnchorElement(host, 5)?.element).toBe(bodies()[1]); + + patch(split(h(1, 1, 'Title'), p(8, 'alpha'), p(10, 'beta'))); + + expect(findAnchorElement(host, 10)?.element).toBe(bodies()[1]); + expect(findAnchorElement(host, 8)?.element).toBe(bodies()[0]); + }); + }); + + describe('an article somebody else rewrote', () => { + test('is rebuilt rather than diffed against a stale record', () => { + patch(split(p(1, 'a'), p(3, 'b'))); + host.appendChild(document.createElement('p')); + + const result = patch(split(p(1, 'a'), p(3, 'b'))); + + expect(result.inserted.length).toBe(2); + expect(host.children.length).toBe(2); + }); + }); +}); + +describe('preview patch parity', () => { + /** + * The block patch against the whole-document pipeline it replaced, over the + * renderer's real output for samples/markdown-syntax.md (every construct the + * preview has: callouts, folds, footnotes, math, Mermaid, task lists, raw + * `
    ` around Markdown). `preview_block_fixture_is_current` in + * markdown.rs keeps the fixture in step with the renderer. + * + * Three properties: + * - a first render is the document the old pipeline produced; + * - after an edit, the patched article is what a first render of the edited + * document would be — the incremental path loses nothing; + * - undoing the edit puts the original document back. + */ + + const SPLIT = blocks.join('\0'); + + const PATH = '/docs/markdown-syntax.md'; + + /** `wrap-N` ids only have to be unique; the two paths hand them out in a different order. */ + const normalized = (host: Element) => host.innerHTML.replace(/wrap-\d+/g, 'wrap-N'); + + function oldPipeline(split: string): string { + const host = document.createElement('div'); + host.append(sanitizeMarkdownFragment(processMarkdownHtml(split.replaceAll('\0\n', ''), PATH, new Set()))); + // The old patch carried elements only, never the whitespace between them. + for (const node of Array.from(host.childNodes)) if (node.nodeType !== 1) node.remove(); + return normalized(host); + } + + function cold(split: string): string { + const host = document.createElement('div'); + patchPreview(host, split, PATH, new Set()); + return normalized(host); + } + + /** Every `data-sourcepos` line moved down by one: a line inserted at the top. */ + const enterAtTop = (split: string) => + split.replace(/data-sourcepos="(\d+):(\d+)-(\d+):(\d+)"/g, (_, a, b, c, d) => `data-sourcepos="${+a + 1}:${b}-${+c + 1}:${d}"`); + + const at = (split: string, index: number, edit: (block: string) => string) => { + const parts = split.split('\0'); + parts[index] = edit(parts[index]); + return parts.join('\0'); + }; + + const firstIndex = (pattern: RegExp) => blocks.findIndex((block) => pattern.test(block)); + + const middleParagraph = blocks.findIndex((block, index) => index > blocks.length / 2 && /^\n?

    /); + + const edits: Record string> = { + 'a keystroke in a paragraph': (split) => at(split, middleParagraph, (b) => b.replace('

    ', 'x

    ')), + 'a line inserted at the top': enterAtTop, + 'a renamed heading': (split) => at(split, firstH2, (b) => b.replace(/(

    ]*>)/, '$1Renamed ')), + 'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    new

    \n`), + 'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Added

    \n`), + 'a deleted heading': (split) => at(split, firstH2, () => ''), + 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\n

    Sub

    \n`), + 'a new top-level heading': (split) => at(split, firstH2, (b) => `

    Top

    \n\0${b}`), + 'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)

    /, '

    ')), + 'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('', 'C --> D\n
    ')), + 'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('', ' edited')), + }; + + let host: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); + }); + + test('the fixture has the constructs the edits below aim at', () => { + expect([middleParagraph, firstH2, mermaid, details].every((index) => index > 0)).toBe(true); + }); + + test('a first render is what the whole-document pipeline produced', () => { + expect(cold(SPLIT)).toBe(oldPipeline(SPLIT)); + }); + + describe.each(Object.entries(edits))('%s', (_, edit) => { + test('patches to what a first render of the edited document is', () => { + const edited = edit(SPLIT); + expect(edited).not.toBe(SPLIT); + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edited, PATH, new Set()); + expect(normalized(host)).toBe(cold(edited)); + }); + + test('and back to the original when it is undone', () => { + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edit(SPLIT), PATH, new Set()); + patchPreview(host, SPLIT, PATH, new Set()); + expect(normalized(host)).toBe(cold(SPLIT)); + }); + }); +}); + +/** + * What the shared sanitizer actually does to an author stylesheet — run, not + * described. + * + * `previewSanitize.test.ts` pins the WIRING: that the preview reaches the + * shared policy, that the policy forbids ` + * + * and the observable is the same one: the preview injects `tab.content` into + * the APPLICATION'S OWN document, so a stylesheet that survives the filter is + * not scoped to the article — it restyles Markpad. `.titlebar` is a real + * element of the app's chrome, and `getComputedStyle` is what answers whether + * the document author moved it. + * + * The `background-image` half is the beacon: in a browser the computed value + * being a URL is followed by a request for it (the comment recorded the entry + * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it + * — `img-src … https:`). jsdom does not fetch it. The computed value is the + * part that is a property of the sanitizer rather than of the network stack, + * and it is the part asserted here. + * + * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; + + /** + * A rendered document carrying it. + * + * The leading paragraph is not decoration. DOMPurify serializes its parse + * tree's ``, and the HTML parser puts a `'; + document.body.innerHTML = '
    '; + return { + titlebar: document.querySelector('.titlebar')!, + preview: document.querySelector('.markdown-body')!, + }; + } + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + test('the config the preview used to build lets a document restyle the application', () => { + const { titlebar, preview } = mountApp(); + assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); + + const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); + assert.match(sanitized, / - * - * and the observable is the same one: the preview injects `tab.content` into - * the APPLICATION'S OWN document, so a stylesheet that survives the filter is - * not scoped to the article — it restyles Markpad. `.titlebar` is a real - * element of the app's chrome, and `getComputedStyle` is what answers whether - * the document author moved it. - * - * The `background-image` half is the beacon: in a browser the computed value - * being a URL is followed by a request for it (the comment recorded the entry - * in `performance.getEntriesByType('resource')`, and the shipped CSP allows it - * — `img-src … https:`). jsdom does not fetch it. The computed value is the - * part that is a property of the sanitizer rather than of the network stack, - * and it is the part asserted here. - * - * WHY THE "BEFORE" ROW IS ASSERTED TOO: without it "the `'; - -/** - * A rendered document carrying it. - * - * The leading paragraph is not decoration. DOMPurify serializes its parse - * tree's ``, and the HTML parser puts a `'; - document.body.innerHTML = '
    '; - return { - titlebar: document.querySelector('.titlebar')!, - preview: document.querySelector('.markdown-body')!, - }; -} - -afterEach(() => { - document.head.innerHTML = ''; - document.body.innerHTML = ''; -}); - -test('the config the preview used to build lets a document restyle the application', () => { - const { titlebar, preview } = mountApp(); - assert.equal(getComputedStyle(titlebar).display, 'flex', 'precondition: the app decides where its chrome is'); - - const sanitized = DOMPurify.sanitize(RENDERED, VIEWER_LOCAL_CONFIG); - assert.match(sanitized, /'), + ); + + /** Every class the component's own stylesheet writes a rule for. */ + const styledClasses = new Set( + [...settingsStyle.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((match) => match[1]), + ); + + /** + * The control classes `.setting-item`'s row layout sizes. Each is sized on the + * assumption that it is a flex item of the row, so each must be a direct child + * of one. + */ + const ROW_SIZED_CONTROLS = ['select-wrapper', 'slider-container', 'toggle', 'custom-dropdown-wrapper']; + + type Row = { children: { tag: string; classes: string[] }[]; nested: string[]; label: string }; + + /** Every `.setting-item` in Settings.svelte, with its direct children. */ + function settingRows(): Row[] { + const ast = parseComponent(componentSource, 'src/lib/components/Settings.svelte'); + const rows: Row[] = []; + + const classesOf = (node: Record): string[] => { + const out: string[] = []; + for (const attribute of node.attributes ?? []) { + if (attribute.type === 'ClassDirective') out.push(attribute.name); + if (attribute.type !== 'Attribute' || attribute.name !== 'class') continue; + const parts = Array.isArray(attribute.value) ? attribute.value : [attribute.value]; + for (const part of parts) if (part.type === 'Text') out.push(...String(part.data).split(/\s+/).filter(Boolean)); + } + return out; + }; + + /** Elements below `node`, skipping `{#if}`/`{#each}` wrappers, to `depth`. */ + const elementsUnder = (node: unknown, depth: number, out: { node: Record; depth: number }[] = []) => { + if (Array.isArray(node)) { + for (const child of node) elementsUnder(child, depth, out); + return out; + } + if (!node || typeof node !== 'object') return out; + const current = node as Record; + if (current.type === 'RegularElement') { + out.push({ node: current, depth }); + elementsUnder(current.fragment, depth + 1, out); + return out; + } + // Control-flow blocks do not add a DOM level. + for (const key of ['fragment', 'nodes', 'consequent', 'alternate', 'body']) { + if (current[key]) elementsUnder(current[key], depth, out); + } + return out; + }; + + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (!node || typeof node !== 'object') return; + const current = node as Record; + + if (current.type === 'RegularElement' && classesOf(current).includes('setting-item')) { + const descendants = elementsUnder(current.fragment, 1); + const direct = descendants.filter((entry) => entry.depth === 1); + rows.push({ + label: direct[0] ? textOf(direct[0].node) : '(empty row)', + children: direct.map((entry) => ({ tag: entry.node.name, classes: classesOf(entry.node) })), + nested: descendants + .filter( + (entry) => + entry.depth > 1 && + classesOf(entry.node).some((name) => ROW_SIZED_CONTROLS.includes(name)) && + // `.number-input-wrapper` legitimately lives inside + // `.slider-container`, which is itself a direct child. + !parentIsSizedControl(descendants, entry), + ) + .map((entry) => `${entry.node.name}.${classesOf(entry.node).join('.')}`), + }); + } + + for (const [key, value] of Object.entries(current)) { + if (key === 'parent' || key === 'metadata') continue; + walk(value); + } + }; + + walk((ast as Record).fragment); + return rows; + } + + function parseComponent(text: string, filename: string): unknown { + return parse(text, { modern: true, filename }); + } + + function textOf(node: Record): string { + const first = (node.fragment?.nodes ?? []).find((child: any) => child.type === 'ExpressionTag' || child.type === 'Text'); + if (!first) return node.name; + return componentSource.slice(first.start, first.end).trim().slice(0, 48); + } + + /** True when `entry`'s nearest element ancestor is itself a sized control. */ + function parentIsSizedControl( + all: { node: Record; depth: number }[], + entry: { node: Record; depth: number }, + ): boolean { + const index = all.indexOf(entry); + for (let i = index - 1; i >= 0; i--) { + if (all[i].depth !== entry.depth - 1) continue; + const classes = (all[i].node.attributes ?? []) + .filter((a: any) => a.type === 'Attribute' && a.name === 'class' && Array.isArray(a.value)) + .flatMap((a: any) => a.value.filter((v: any) => v.type === 'Text').flatMap((v: any) => String(v.data).split(/\s+/))); + return classes.some((name: string) => ROW_SIZED_CONTROLS.includes(name)); + } + return false; + } + + const rows = settingRows(); + + test('every settings row was found', () => { + // Vacuous-pass guard: the two assertions below say nothing if the walk + // stopped finding rows. + assert.ok(rows.length > 25, `found ${rows.length} .setting-item rows`); + assert.ok(styledClasses.has('setting-item') && styledClasses.has('select-wrapper')); + }); + + test('a settings row leads with the label that names its control', () => { + const wrong = rows + .filter((row) => row.children[0]?.tag !== 'label') + .map((row) => `${row.label} leads with <${row.children[0]?.tag}>`); + assert.deepEqual(wrong, [], 'a row whose first child is not the