shadow-claw 1.52.0 → 1.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.well-known/mcp/server-card.json +1 -1
  2. package/.well-known/mcp.json +1 -1
  3. package/README.md +9 -9
  4. package/dist/cli/build/build.js +1 -1
  5. package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
  6. package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
  7. package/dist/cli/cli.js +1 -1
  8. package/dist/lib/components/index.js +1 -1
  9. package/dist/lib/components/shadow-claw-pages.js +1 -1
  10. package/dist/lib/index.js +1 -1
  11. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
  12. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
  13. package/dist/public/.well-known/mcp/server-card.json +1 -1
  14. package/dist/public/.well-known/mcp.json +1 -1
  15. package/dist/public/AGENTS.md +1 -1
  16. package/dist/public/README.md +9 -9
  17. package/dist/public/docs/README.md +1 -1
  18. package/dist/public/docs/architecture/orchestrator.md +2 -2
  19. package/dist/public/docs/architecture/overview.md +3 -3
  20. package/dist/public/docs/architecture/worker-protocol.md +29 -29
  21. package/dist/public/docs/example/article/index.html +1 -1
  22. package/dist/public/docs/example/article.html +1 -1
  23. package/dist/public/docs/publishing/index.html +1 -1
  24. package/dist/public/docs/publishing.html +1 -1
  25. package/dist/public/docs/skill-creator/index.html +1 -1
  26. package/dist/public/docs/skill-creator.html +1 -1
  27. package/dist/public/docs/subsystems/local-models.md +1 -1
  28. package/dist/public/docs/subsystems/providers.md +11 -4
  29. package/dist/public/e2e/README.md +2 -2
  30. package/dist/public/index.html +1 -1
  31. package/dist/public/index.js +1 -1
  32. package/dist/public/llms.txt +1 -1
  33. package/dist/public/main/index.html +1 -1
  34. package/dist/public/main/memory/index.html +1 -1
  35. package/dist/public/main/memory.html +1 -1
  36. package/dist/public/main.html +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
  39. package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/build/build.test.ts +95 -0
  42. package/src/cli/build/build.ts +5 -1
  43. package/src/cli/commands/agent.test.ts +134 -0
  44. package/src/cli/commands/backup.test.ts +85 -0
  45. package/src/cli/commands/clients.test.ts +32 -0
  46. package/src/cli/commands/mcp.test.ts +260 -1
  47. package/src/cli/commands/send-file.test.ts +73 -0
  48. package/src/cli/commands/send.test.ts +58 -0
  49. package/src/cli/commands/tasks.test.ts +78 -0
  50. package/src/cli/commands/webrtc-listen.test.ts +74 -0
  51. package/src/cli/tools/assert-version-bump.test.ts +30 -1
  52. package/src/cli/tools/file-search-replace.test.ts +33 -1
  53. package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
  54. package/src/cli/tools/touch-nojekyll.test.ts +12 -0
  55. package/src/cli/tools/version.test.ts +109 -5
  56. package/src/cli/utils/agent-core.test.ts +40 -0
  57. package/src/cli/utils/control-client.test.ts +239 -2
  58. package/src/cli/utils/load-workspace-config.test.ts +63 -0
  59. package/src/cli/utils/local-models.test.ts +98 -0
  60. package/src/cli/utils/progress-bar.test.ts +41 -2
  61. package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
  62. package/src/cli/utils/resolve-project-root.test.ts +17 -0
  63. package/src/cli/utils/stdin.test.ts +38 -0
  64. package/src/cli/utils/stdio.test.ts +51 -1
  65. package/src/cli/utils/suppress-warnings.test.ts +9 -0
  66. package/src/cli/utils/webrtc-control-client.test.ts +569 -0
  67. package/src/common/utils/frontmatter.test.ts +117 -0
  68. package/src/components/common/help/providers.test.ts +67 -0
  69. package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
  70. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
  71. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
  72. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
  73. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
  74. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
  75. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
  76. package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
  77. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
  78. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
  79. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
  80. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
  81. package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
  82. package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
  83. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
  84. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
  85. package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
  86. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
  87. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
  88. package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
  89. package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
  90. package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
  91. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
  92. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
  93. package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
  94. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
  95. package/src/server/logger.test.ts +57 -0
  96. package/src/server/peer.test.ts +47 -0
  97. package/src/server/routes/gemini.test.ts +244 -0
  98. package/src/server/server-peer.test.ts +245 -11
  99. package/src/server/services/llamafile-manager.test.ts +668 -0
  100. package/src/share-target/pending-shares.test.ts +90 -0
  101. package/src/shell/fs.test.ts +125 -0
  102. package/src/shell/shell.test.ts +8 -0
  103. package/src/subsystems/git/credentials.test.ts +260 -0
  104. package/src/subsystems/git/git.test.ts +64 -0
  105. package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
  106. package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
  107. package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
  108. package/src/worker/tools/email/email.test.ts +358 -0
  109. package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
  110. package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
  111. package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
  112. package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
@@ -71,7 +71,16 @@ jest.unstable_mockModule("../../storage/moveGroupEntry.js", () => ({
71
71
  }));
72
72
 
73
73
  jest.unstable_mockModule("../../core/effect.js", () => ({
74
- effect: jest.fn(() => () => {}),
74
+ effect: jest.fn((cb: any) => {
75
+ try {
76
+ const cleanup = cb();
77
+ return () => {
78
+ if (typeof cleanup === "function") cleanup();
79
+ };
80
+ } catch {
81
+ return () => {};
82
+ }
83
+ }),
75
84
  }));
76
85
 
77
86
  jest.unstable_mockModule("../../ui/toast.js", () => ({
@@ -127,6 +136,12 @@ const { copyGroupEntry } = await import("../../storage/copyGroupEntry.js");
127
136
  const { moveGroupEntry } = await import("../../storage/moveGroupEntry.js");
128
137
  const { showSuccess, showWarning, showError } =
129
138
  await import("../../ui/toast.js");
139
+ const { downloadGroupDirectoryAsZip } =
140
+ await import("../../storage/downloadGroupDirectoryAsZip.js");
141
+ const { downloadGroupFile } =
142
+ await import("../../storage/downloadGroupFile.js");
143
+ const { deleteGroupDirectory } =
144
+ await import("../../storage/deleteGroupDirectory.js");
130
145
  const { orchestratorStore } = await import("../../stores/orchestrator.js");
131
146
  const { filesUiStore } = await import("../../stores/files-ui.js");
132
147
 
@@ -1607,4 +1622,386 @@ describe("shadow-claw-files", () => {
1607
1622
  document.body.removeChild(component);
1608
1623
  });
1609
1624
  });
1625
+
1626
+ describe("drag and drop handling", () => {
1627
+ it("handles drag enter, over, leave, and drop events", async () => {
1628
+ const component = new ShadowClawFiles();
1629
+ document.body.appendChild(component);
1630
+ await component.render();
1631
+
1632
+ const content = component.shadowRoot?.querySelector(
1633
+ ".files__content",
1634
+ ) as HTMLElement;
1635
+
1636
+ // Drag enter without files does nothing
1637
+ const noFilesDragEvent = {
1638
+ dataTransfer: { types: ["text/plain"] },
1639
+ preventDefault: jest.fn(),
1640
+ } as any;
1641
+ component.handleDragEnter(noFilesDragEvent, content);
1642
+ expect(filesUiStore.isDragActive).toBe(false);
1643
+
1644
+ // Drag enter with Files
1645
+ const filesDragEvent = {
1646
+ dataTransfer: { types: ["Files"] },
1647
+ preventDefault: jest.fn(),
1648
+ } as any;
1649
+ component.handleDragEnter(filesDragEvent, content);
1650
+ expect(filesUiStore.isDragActive).toBe(true);
1651
+
1652
+ // Drag over sets dropEffect
1653
+ const dragOverEvent = {
1654
+ dataTransfer: { types: ["Files"], dropEffect: "" },
1655
+ preventDefault: jest.fn(),
1656
+ } as any;
1657
+ component.handleDragOver(dragOverEvent, content);
1658
+ expect(dragOverEvent.dataTransfer.dropEffect).toBe("copy");
1659
+
1660
+ // Drag leave outside content
1661
+ const dragLeaveEvent = {
1662
+ relatedTarget: document.body,
1663
+ } as any;
1664
+ component.handleDragLeave(dragLeaveEvent, content);
1665
+ expect(filesUiStore.isDragActive).toBe(false);
1666
+
1667
+ // Handle drop with files
1668
+ const dropUploadSpy = jest
1669
+ .spyOn(component, "uploadFileList")
1670
+ .mockResolvedValue(undefined as any);
1671
+ const mockFile = new File(["test content"], "dropped.txt", {
1672
+ type: "text/plain",
1673
+ });
1674
+ const dropEvent = {
1675
+ dataTransfer: { types: ["Files"], files: [mockFile] },
1676
+ preventDefault: jest.fn(),
1677
+ } as any;
1678
+ await component.handleDrop(dropEvent, {} as any, content);
1679
+ expect(dropUploadSpy).toHaveBeenCalledWith({}, [mockFile]);
1680
+
1681
+ document.body.removeChild(component);
1682
+ });
1683
+ });
1684
+
1685
+ describe("sync host to VM handling", () => {
1686
+ it("handles sync host to VM success and error", async () => {
1687
+ const component = new ShadowClawFiles();
1688
+ document.body.appendChild(component);
1689
+ await component.render();
1690
+
1691
+ (orchestratorStore as any).syncHostWorkspaceToVM = jest.fn();
1692
+ component.handleSyncHostToVM();
1693
+ expect(orchestratorStore.syncHostWorkspaceToVM).toHaveBeenCalled();
1694
+ expect(showSuccess).toHaveBeenCalledWith(
1695
+ expect.stringContaining("Requested host"),
1696
+ expect.any(Number),
1697
+ );
1698
+
1699
+ (orchestratorStore as any).syncHostWorkspaceToVM = jest.fn(() => {
1700
+ throw new Error("VM not running");
1701
+ });
1702
+ component.handleSyncHostToVM();
1703
+ expect(showError).toHaveBeenCalledWith(
1704
+ expect.stringContaining(
1705
+ "Failed to request host → VM sync: VM not running",
1706
+ ),
1707
+ expect.any(Number),
1708
+ );
1709
+
1710
+ document.body.removeChild(component);
1711
+ });
1712
+ });
1713
+
1714
+ describe("dialog cancellation and cancel buttons", () => {
1715
+ it("handles cancel events and cancel buttons respecting busy flags", async () => {
1716
+ const component = new ShadowClawFiles();
1717
+ document.body.appendChild(component);
1718
+ await component.render();
1719
+
1720
+ const newDialog = component.shadowRoot?.querySelector(
1721
+ ".files__new-dialog",
1722
+ ) as HTMLDialogElement;
1723
+ const newCancelBtn = component.shadowRoot?.querySelector(
1724
+ ".files__new-cancel",
1725
+ ) as HTMLButtonElement;
1726
+ const renameDialog = component.shadowRoot?.querySelector(
1727
+ ".files__rename-dialog",
1728
+ ) as HTMLDialogElement;
1729
+ const renameCancelBtn = component.shadowRoot?.querySelector(
1730
+ ".files__rename-cancel",
1731
+ ) as HTMLButtonElement;
1732
+ const pasteDialog = component.shadowRoot?.querySelector(
1733
+ ".files__paste-dialog",
1734
+ ) as HTMLDialogElement;
1735
+ const pasteCancelBtn = component.shadowRoot?.querySelector(
1736
+ ".files__paste-cancel",
1737
+ ) as HTMLButtonElement;
1738
+
1739
+ // newDialog cancel event when busy vs not busy
1740
+ const cancelEvt1 = new Event("cancel", { cancelable: true });
1741
+ (component as any)._isCreatingNewItem = true;
1742
+ newDialog.dispatchEvent(cancelEvt1);
1743
+ expect(cancelEvt1.defaultPrevented).toBe(true);
1744
+
1745
+ (component as any)._isCreatingNewItem = true;
1746
+ newCancelBtn.click(); // does not close
1747
+
1748
+ (component as any)._isCreatingNewItem = false;
1749
+ newCancelBtn.click(); // closes
1750
+
1751
+ // renameDialog cancel event when busy vs not busy
1752
+ const cancelEvt2 = new Event("cancel", { cancelable: true });
1753
+ (component as any)._isRenamingEntry = true;
1754
+ renameDialog.dispatchEvent(cancelEvt2);
1755
+ expect(cancelEvt2.defaultPrevented).toBe(true);
1756
+
1757
+ (component as any)._isRenamingEntry = true;
1758
+ renameCancelBtn.click();
1759
+
1760
+ (component as any)._isRenamingEntry = false;
1761
+ renameCancelBtn.click();
1762
+
1763
+ // pasteDialog cancel event when busy vs not busy
1764
+ const cancelEvt3 = new Event("cancel", { cancelable: true });
1765
+ (component as any)._isPastingEntry = true;
1766
+ pasteDialog.dispatchEvent(cancelEvt3);
1767
+ expect(cancelEvt3.defaultPrevented).toBe(true);
1768
+
1769
+ (component as any)._isPastingEntry = true;
1770
+ pasteCancelBtn.click();
1771
+
1772
+ (component as any)._isPastingEntry = false;
1773
+ pasteCancelBtn.click();
1774
+
1775
+ document.body.removeChild(component);
1776
+ });
1777
+ });
1778
+
1779
+ describe("paste dialog edge cases and validations", () => {
1780
+ it("handles openPasteDialog edge cases", async () => {
1781
+ const component = new ShadowClawFiles();
1782
+ document.body.appendChild(component);
1783
+ await component.render();
1784
+
1785
+ // No clipboard
1786
+ filesUiStore.clearClipboard();
1787
+ component.openPasteDialog();
1788
+ expect(showWarning).toHaveBeenCalledWith(
1789
+ "Nothing in clipboard to paste",
1790
+ expect.any(Number),
1791
+ );
1792
+
1793
+ // Invalid folder paste target
1794
+ (orchestratorStore as any).currentPath = "my-folder/sub";
1795
+ filesUiStore.setClipboard("my-folder/", "copy", true, "default");
1796
+ component.openPasteDialog();
1797
+ expect(showWarning).toHaveBeenCalledWith(
1798
+ expect.stringContaining("Cannot paste a folder into itself"),
1799
+ expect.any(Number),
1800
+ );
1801
+
1802
+ // Already exists case in openPasteDialog
1803
+ (orchestratorStore as any).currentPath = ".";
1804
+ (orchestratorStore as any).files = ["file.txt"];
1805
+ filesUiStore.setClipboard("file.txt", "copy", false, "default");
1806
+ component.openPasteDialog();
1807
+ const msg = component.shadowRoot?.querySelector(
1808
+ ".files__paste-message",
1809
+ )?.textContent;
1810
+ expect(msg).toContain("already exists");
1811
+
1812
+ document.body.removeChild(component);
1813
+ });
1814
+
1815
+ it("handles handlePasteEntry validation and overwrite branches", async () => {
1816
+ const component = new ShadowClawFiles();
1817
+ document.body.appendChild(component);
1818
+ await component.render();
1819
+
1820
+ const db = {} as any;
1821
+
1822
+ // When already pasting
1823
+ (component as any)._isPastingEntry = true;
1824
+ await component.handlePasteEntry(db, false);
1825
+ (component as any)._isPastingEntry = false;
1826
+
1827
+ // When clipboard is null
1828
+ filesUiStore.clearClipboard();
1829
+ await component.handlePasteEntry(db, false);
1830
+
1831
+ // Invalid folder paste target
1832
+ (orchestratorStore as any).currentPath = "target-folder/sub";
1833
+ filesUiStore.setClipboard("target-folder/", "copy", true, "default");
1834
+ await component.handlePasteEntry(db, false);
1835
+ expect(showWarning).toHaveBeenCalledWith(
1836
+ expect.stringContaining("Cannot paste a folder into itself"),
1837
+ expect.any(Number),
1838
+ );
1839
+
1840
+ // Rename input empty
1841
+ (orchestratorStore as any).currentPath = ".";
1842
+ (orchestratorStore as any).files = ["test.txt"];
1843
+ filesUiStore.setClipboard("test.txt", "copy", false, "default");
1844
+ component.openPasteDialog();
1845
+ const renameInput = component.shadowRoot?.querySelector(
1846
+ ".files__paste-rename-input",
1847
+ ) as HTMLInputElement;
1848
+ renameInput.value = " ";
1849
+ await component.handlePasteEntry(db, false);
1850
+ expect(showWarning).toHaveBeenCalledWith(
1851
+ "Please enter a name",
1852
+ expect.any(Number),
1853
+ );
1854
+
1855
+ // Rename input contains slash
1856
+ renameInput.value = "invalid/name";
1857
+ await component.handlePasteEntry(db, false);
1858
+ expect(showWarning).toHaveBeenCalledWith(
1859
+ "Use only a name, not a path",
1860
+ expect.any(Number),
1861
+ );
1862
+
1863
+ // Target already exists and not overwrite
1864
+ renameInput.value = "test.txt";
1865
+ await component.handlePasteEntry(db, false);
1866
+ expect(showWarning).toHaveBeenCalledWith(
1867
+ expect.stringContaining("already exists"),
1868
+ expect.any(Number),
1869
+ );
1870
+
1871
+ // Overwrite folder
1872
+ filesUiStore.setClipboard("old-dir/", "copy", true, "default");
1873
+ (orchestratorStore as any).files = ["old-dir/"];
1874
+ (copyGroupEntry as any).mockResolvedValue(undefined);
1875
+ await component.handlePasteEntry(db, true);
1876
+ expect(deleteGroupDirectory).toHaveBeenCalled();
1877
+
1878
+ // Overwrite file
1879
+ filesUiStore.setClipboard("old-file.txt", "copy", false, "default");
1880
+ (orchestratorStore as any).files = ["old-file.txt"];
1881
+ await component.handlePasteEntry(db, true);
1882
+ expect(deleteGroupFile).toHaveBeenCalled();
1883
+
1884
+ document.body.removeChild(component);
1885
+ });
1886
+ });
1887
+
1888
+ describe("download buttons in file and directory items", () => {
1889
+ it("handles file and directory download button clicks", async () => {
1890
+ const component = new ShadowClawFiles();
1891
+ document.body.appendChild(component);
1892
+ await component.render();
1893
+
1894
+ (orchestratorStore as any).activeGroupId = "default";
1895
+ (orchestratorStore as any).currentPath = ".";
1896
+ (orchestratorStore as any).files = ["doc.txt", "assets/"];
1897
+ component.updateFileList({} as any);
1898
+
1899
+ const items = component.shadowRoot?.querySelectorAll(".files__item");
1900
+ const fileDownloadBtn = items?.[0]?.querySelector(
1901
+ ".files__download",
1902
+ ) as HTMLButtonElement;
1903
+ const dirDownloadBtn = items?.[1]?.querySelector(
1904
+ ".files__download",
1905
+ ) as HTMLButtonElement;
1906
+
1907
+ // File download success
1908
+ (downloadGroupFile as any).mockResolvedValue(undefined);
1909
+ fileDownloadBtn.click();
1910
+ await Promise.resolve();
1911
+ await Promise.resolve();
1912
+ expect(downloadGroupFile).toHaveBeenCalledWith({}, "default", "doc.txt");
1913
+ expect(showSuccess).toHaveBeenCalledWith(
1914
+ "Downloaded file: doc.txt",
1915
+ 2500,
1916
+ );
1917
+
1918
+ // Dir download success
1919
+ (downloadGroupDirectoryAsZip as any).mockResolvedValue(undefined);
1920
+ dirDownloadBtn.click();
1921
+ await Promise.resolve();
1922
+ await Promise.resolve();
1923
+ expect(downloadGroupDirectoryAsZip).toHaveBeenCalledWith(
1924
+ {},
1925
+ "default",
1926
+ "assets/",
1927
+ );
1928
+ expect(showSuccess).toHaveBeenCalledWith(
1929
+ "Downloaded folder: assets",
1930
+ 2500,
1931
+ );
1932
+
1933
+ // Download failure
1934
+ (downloadGroupFile as any).mockRejectedValueOnce(
1935
+ new Error("Network fail"),
1936
+ );
1937
+ fileDownloadBtn.click();
1938
+ await Promise.resolve();
1939
+ await Promise.resolve();
1940
+ expect(showError).toHaveBeenCalledWith(
1941
+ "Failed to download doc.txt: Network fail",
1942
+ 6000,
1943
+ );
1944
+
1945
+ document.body.removeChild(component);
1946
+ });
1947
+ });
1948
+
1949
+ describe("restore and form events", () => {
1950
+ it("handles restore button and form submission events", async () => {
1951
+ const component = new ShadowClawFiles();
1952
+ document.body.appendChild(component);
1953
+ await component.render();
1954
+
1955
+ const restoreBtn = component.shadowRoot?.querySelector(
1956
+ ".files__restore-btn",
1957
+ ) as HTMLButtonElement;
1958
+ const restoreInput = component.shadowRoot?.querySelector(
1959
+ ".files__hidden-restore",
1960
+ ) as HTMLInputElement;
1961
+ const inputClickSpy = jest.spyOn(restoreInput, "click");
1962
+ restoreBtn.click();
1963
+ expect(inputClickSpy).toHaveBeenCalled();
1964
+
1965
+ const handleRestoreSpy = jest
1966
+ .spyOn(component, "handleRestore")
1967
+ .mockImplementation(() => {});
1968
+ restoreInput.dispatchEvent(new Event("change"));
1969
+ expect(handleRestoreSpy).toHaveBeenCalled();
1970
+
1971
+ // Form submit handlers
1972
+ const newForm = component.shadowRoot?.querySelector(".files__new-form");
1973
+ const handleCreateSpy = jest
1974
+ .spyOn(component, "handleCreateNewFile")
1975
+ .mockResolvedValue();
1976
+ newForm?.dispatchEvent(new Event("submit", { cancelable: true }));
1977
+ expect(handleCreateSpy).toHaveBeenCalled();
1978
+
1979
+ const renameForm = component.shadowRoot?.querySelector(
1980
+ ".files__rename-form",
1981
+ );
1982
+ const handleRenameSpy = jest
1983
+ .spyOn(component, "handleRenameEntry")
1984
+ .mockResolvedValue();
1985
+ renameForm?.dispatchEvent(new Event("submit", { cancelable: true }));
1986
+ expect(handleRenameSpy).toHaveBeenCalled();
1987
+
1988
+ const pasteForm =
1989
+ component.shadowRoot?.querySelector(".files__paste-form");
1990
+ const handlePasteSpy = jest
1991
+ .spyOn(component, "handlePasteEntry")
1992
+ .mockResolvedValue();
1993
+ pasteForm?.dispatchEvent(new Event("submit", { cancelable: true }));
1994
+ expect(handlePasteSpy).toHaveBeenCalledWith(expect.anything(), false);
1995
+
1996
+ const pasteOverwriteBtn = component.shadowRoot?.querySelector(
1997
+ ".files__paste-overwrite",
1998
+ ) as HTMLButtonElement;
1999
+ pasteOverwriteBtn.dispatchEvent(
2000
+ new MouseEvent("click", { cancelable: true }),
2001
+ );
2002
+ expect(handlePasteSpy).toHaveBeenCalledWith(expect.anything(), true);
2003
+
2004
+ document.body.removeChild(component);
2005
+ });
2006
+ });
1610
2007
  });
@@ -1814,5 +1814,90 @@ describe("shadow-claw-pages", () => {
1814
1814
 
1815
1815
  (orchestratorStore.isPageAllowed as jest.Mock).mockReturnValue(true);
1816
1816
  });
1817
+
1818
+ it("updates header when frontmatter title is present in renderPageList", async () => {
1819
+ const component = new ShadowClawPages();
1820
+ await component.connectedCallback();
1821
+ const root = component.shadowRoot;
1822
+ if (!root) return;
1823
+
1824
+ component.selectedPage = { groupId: "group-1", path: "docs/page.md" };
1825
+ component.pageFrontmatter.set({ title: "Amazing Guide" } as any);
1826
+ component.renderPageList(
1827
+ [{ groupId: "group-1", path: "docs/page.md" }],
1828
+ [],
1829
+ );
1830
+
1831
+ const header = root.querySelector("shadow-claw-page-header");
1832
+ expect(header?.getAttribute("title")).toBe("Amazing Guide");
1833
+ });
1834
+
1835
+ it("handles drag and drop events on page rows", async () => {
1836
+ const component = new ShadowClawPages();
1837
+ await component.connectedCallback();
1838
+ component.db = {} as any;
1839
+ const root = component.shadowRoot;
1840
+ if (!root) return;
1841
+
1842
+ const pages = [
1843
+ { groupId: "group-1", path: "docs/1.md" },
1844
+ { groupId: "group-1", path: "docs/2.md" },
1845
+ ];
1846
+ component.renderPageList(pages, []);
1847
+
1848
+ const rows = root.querySelectorAll(".pages__list-item");
1849
+ expect(rows.length).toBeGreaterThanOrEqual(2);
1850
+
1851
+ const dragHandle0 = rows[0].querySelector(".pages__drag-handle");
1852
+ const dragEvent = new Event("dragstart", { bubbles: true }) as any;
1853
+ dragEvent.dataTransfer = { effectAllowed: "" };
1854
+ dragHandle0?.dispatchEvent(dragEvent);
1855
+ expect((component as any).draggedPageIndex).toBe(0);
1856
+
1857
+ const dragOverEvent = new Event("dragover", { bubbles: true }) as any;
1858
+ dragOverEvent.preventDefault = jest.fn();
1859
+ rows[1].dispatchEvent(dragOverEvent);
1860
+ expect(rows[1].classList.contains("drag-over")).toBe(true);
1861
+
1862
+ const dragLeaveEvent = new Event("dragleave", { bubbles: true });
1863
+ rows[1].dispatchEvent(dragLeaveEvent);
1864
+ expect(rows[1].classList.contains("drag-over")).toBe(false);
1865
+
1866
+ const dropEvent = new Event("drop", { bubbles: true }) as any;
1867
+ dropEvent.preventDefault = jest.fn();
1868
+ rows[1].dispatchEvent(dropEvent);
1869
+
1870
+ const dragEndEvent = new Event("dragend", { bubbles: true });
1871
+ rows[0].dispatchEvent(dragEndEvent);
1872
+ expect((component as any).draggedPageIndex).toBeNull();
1873
+ });
1874
+
1875
+ it("handles setupAutoRefreshTimer lifecycle", async () => {
1876
+ const component = new ShadowClawPages();
1877
+ (component as any).autoRefreshTimer = setInterval(() => {}, 10000);
1878
+ component.db = null as any;
1879
+
1880
+ await component.setupAutoRefreshTimer();
1881
+ expect((component as any).autoRefreshTimer).toBeNull();
1882
+ });
1883
+
1884
+ it("handles resolveRelativeImagesInHtml with empty and present html", async () => {
1885
+ const component = new ShadowClawPages();
1886
+ component.db = {} as any;
1887
+
1888
+ const emptyRes = await component.resolveRelativeImagesInHtml(
1889
+ "",
1890
+ "doc.html",
1891
+ "group-1",
1892
+ );
1893
+ expect(emptyRes).toBe("");
1894
+
1895
+ const htmlRes = await component.resolveRelativeImagesInHtml(
1896
+ "<p>Hello</p>",
1897
+ "doc.html",
1898
+ "group-1",
1899
+ );
1900
+ expect(htmlRes).toContain("<p>Hello</p>");
1901
+ });
1817
1902
  });
1818
1903
  });
@@ -1546,7 +1546,8 @@ export class ShadowClawPages extends ShadowClawElement {
1546
1546
  }
1547
1547
 
1548
1548
  const root = this.shadowRoot;
1549
- const rendered = root?.querySelector("#rendered") as HTMLElement;
1549
+ const rendered = (root?.querySelector("[data-pages-rendered]") ||
1550
+ root?.querySelector("#rendered")) as HTMLElement;
1550
1551
  if (!root || !rendered) {
1551
1552
  return;
1552
1553
  }