md-redline 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +50 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, utimes, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { createApp, isPathInsideRoot, type CreateAppOptions } from './index';
|
|
6
|
+
|
|
7
|
+
type AppInstance = ReturnType<typeof createApp>;
|
|
8
|
+
|
|
9
|
+
let app: AppInstance;
|
|
10
|
+
let initialFileApp: AppInstance;
|
|
11
|
+
let initialDirApp: AppInstance;
|
|
12
|
+
|
|
13
|
+
let cwdRoot: string;
|
|
14
|
+
let fakeHome: string;
|
|
15
|
+
let initialDir: string;
|
|
16
|
+
let externalDir: string;
|
|
17
|
+
let docsDir: string;
|
|
18
|
+
let nestedDir: string;
|
|
19
|
+
|
|
20
|
+
let rootFile: string;
|
|
21
|
+
let docsFile: string;
|
|
22
|
+
let homeFile: string;
|
|
23
|
+
let textFile: string;
|
|
24
|
+
let externalFile: string;
|
|
25
|
+
let allowedSymlinkFile: string;
|
|
26
|
+
let outsideSymlinkFile: string;
|
|
27
|
+
let allowedSymlinkDir: string;
|
|
28
|
+
let outsideSymlinkDir: string;
|
|
29
|
+
let writtenFile: string;
|
|
30
|
+
let initialSiblingFile: string;
|
|
31
|
+
|
|
32
|
+
function createExecFileStub(stdout: string) {
|
|
33
|
+
const calls: Array<{ file: string; args: string[] }> = [];
|
|
34
|
+
const execFileImpl = ((
|
|
35
|
+
file: string,
|
|
36
|
+
args: readonly string[],
|
|
37
|
+
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
38
|
+
) => {
|
|
39
|
+
calls.push({ file, args: [...args] });
|
|
40
|
+
callback(null, stdout, '');
|
|
41
|
+
}) as unknown as CreateAppOptions['execFileImpl'];
|
|
42
|
+
|
|
43
|
+
return { calls, execFileImpl };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requestJson(appInstance: AppInstance, path: string, init?: RequestInit) {
|
|
47
|
+
const response = await appInstance.request(`http://localhost${path}`, init);
|
|
48
|
+
return {
|
|
49
|
+
response,
|
|
50
|
+
body: (await response.json()) as Record<string, unknown>,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
cwdRoot = await mkdtemp(join(tmpdir(), 'md-redline-server-cwd-'));
|
|
56
|
+
fakeHome = await mkdtemp(join(tmpdir(), 'md-redline-server-home-'));
|
|
57
|
+
initialDir = await mkdtemp(join(tmpdir(), 'md-redline-server-initial-'));
|
|
58
|
+
externalDir = await mkdtemp(join(tmpdir(), 'md-redline-server-external-'));
|
|
59
|
+
cwdRoot = await realpath(cwdRoot);
|
|
60
|
+
fakeHome = await realpath(fakeHome);
|
|
61
|
+
initialDir = await realpath(initialDir);
|
|
62
|
+
externalDir = await realpath(externalDir);
|
|
63
|
+
|
|
64
|
+
docsDir = join(cwdRoot, 'docs');
|
|
65
|
+
nestedDir = join(docsDir, 'nested');
|
|
66
|
+
const hiddenDir = join(docsDir, '.hidden-dir');
|
|
67
|
+
|
|
68
|
+
await mkdir(docsDir, { recursive: true });
|
|
69
|
+
await mkdir(nestedDir, { recursive: true });
|
|
70
|
+
await mkdir(hiddenDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
rootFile = join(cwdRoot, 'root.md');
|
|
73
|
+
docsFile = join(docsDir, 'alpha.md');
|
|
74
|
+
homeFile = join(fakeHome, 'home.md');
|
|
75
|
+
textFile = join(docsDir, 'notes.txt');
|
|
76
|
+
externalFile = join(externalDir, 'outside.md');
|
|
77
|
+
allowedSymlinkFile = join(docsDir, 'home-link.md');
|
|
78
|
+
outsideSymlinkFile = join(docsDir, 'outside-link.md');
|
|
79
|
+
allowedSymlinkDir = join(cwdRoot, 'home-dir');
|
|
80
|
+
outsideSymlinkDir = join(cwdRoot, 'outside-dir');
|
|
81
|
+
writtenFile = join(docsDir, 'written.md');
|
|
82
|
+
initialSiblingFile = join(initialDir, 'follow-up.md');
|
|
83
|
+
|
|
84
|
+
await writeFile(rootFile, '# Root\n');
|
|
85
|
+
await writeFile(docsFile, '# Alpha\n\nHello world\n');
|
|
86
|
+
await writeFile(homeFile, '# Home\n');
|
|
87
|
+
await writeFile(textFile, 'not markdown');
|
|
88
|
+
await writeFile(externalFile, '# Outside\n');
|
|
89
|
+
await writeFile(writtenFile, '# Previous\n');
|
|
90
|
+
await writeFile(join(docsDir, 'zeta.md'), '# Zeta\n');
|
|
91
|
+
await writeFile(join(docsDir, 'README.MD'), '# Uppercase\n');
|
|
92
|
+
await writeFile(join(nestedDir, 'nested.md'), '# Nested\n');
|
|
93
|
+
await writeFile(join(hiddenDir, 'secret.md'), '# Secret\n');
|
|
94
|
+
await writeFile(join(initialDir, 'initial.md'), '# Initial\n');
|
|
95
|
+
await writeFile(initialSiblingFile, '# Follow-up\n\nInitial sibling\n');
|
|
96
|
+
|
|
97
|
+
await symlink(rootFile, allowedSymlinkFile);
|
|
98
|
+
await symlink(externalFile, outsideSymlinkFile);
|
|
99
|
+
await symlink(nestedDir, allowedSymlinkDir);
|
|
100
|
+
await symlink(externalDir, outsideSymlinkDir);
|
|
101
|
+
|
|
102
|
+
app = createApp({
|
|
103
|
+
cwd: cwdRoot,
|
|
104
|
+
homeDir: fakeHome,
|
|
105
|
+
platformName: 'linux',
|
|
106
|
+
});
|
|
107
|
+
initialFileApp = createApp({
|
|
108
|
+
cwd: cwdRoot,
|
|
109
|
+
homeDir: fakeHome,
|
|
110
|
+
initialArg: join(initialDir, 'initial.md'),
|
|
111
|
+
platformName: 'linux',
|
|
112
|
+
});
|
|
113
|
+
initialDirApp = createApp({
|
|
114
|
+
cwd: cwdRoot,
|
|
115
|
+
homeDir: fakeHome,
|
|
116
|
+
initialArg: initialDir,
|
|
117
|
+
platformName: 'linux',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(async () => {
|
|
122
|
+
await rm(cwdRoot, { recursive: true, force: true });
|
|
123
|
+
await rm(fakeHome, { recursive: true, force: true });
|
|
124
|
+
await rm(initialDir, { recursive: true, force: true });
|
|
125
|
+
await rm(externalDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('/api/config', () => {
|
|
129
|
+
it('returns empty initial paths by default', async () => {
|
|
130
|
+
const { response, body } = await requestJson(app, '/api/config');
|
|
131
|
+
|
|
132
|
+
expect(response.status).toBe(200);
|
|
133
|
+
expect(body).toEqual({ initialFile: '', initialDir: '' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns the configured initial file or directory', async () => {
|
|
137
|
+
const fileConfig = await requestJson(initialFileApp, '/api/config');
|
|
138
|
+
const dirConfig = await requestJson(initialDirApp, '/api/config');
|
|
139
|
+
|
|
140
|
+
expect(fileConfig.body).toEqual({
|
|
141
|
+
initialFile: join(initialDir, 'initial.md'),
|
|
142
|
+
initialDir: '',
|
|
143
|
+
});
|
|
144
|
+
expect(dirConfig.body).toEqual({
|
|
145
|
+
initialFile: '',
|
|
146
|
+
initialDir,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('/api/preferences', () => {
|
|
152
|
+
it('returns {} when no dotfile exists', async () => {
|
|
153
|
+
const { response, body } = await requestJson(app, '/api/preferences');
|
|
154
|
+
expect(response.status).toBe(200);
|
|
155
|
+
expect(body).toEqual({});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns dotfile content when it exists', async () => {
|
|
159
|
+
const { writeFile: wf } = await import('fs/promises');
|
|
160
|
+
await wf(join(fakeHome, '.md-redline.json'), JSON.stringify({ author: 'Test', theme: 'nord' }));
|
|
161
|
+
const { response, body } = await requestJson(app, '/api/preferences');
|
|
162
|
+
expect(response.status).toBe(200);
|
|
163
|
+
expect(body).toEqual({ author: 'Test', theme: 'nord' });
|
|
164
|
+
// Clean up
|
|
165
|
+
const { rm: rmf } = await import('fs/promises');
|
|
166
|
+
await rmf(join(fakeHome, '.md-redline.json'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('PUT creates dotfile and returns merged content', async () => {
|
|
170
|
+
const { response, body } = await requestJson(app, '/api/preferences', {
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({ author: 'Alice' }),
|
|
174
|
+
});
|
|
175
|
+
expect(response.status).toBe(200);
|
|
176
|
+
expect(body.author).toBe('Alice');
|
|
177
|
+
// Clean up
|
|
178
|
+
const { rm: rmf } = await import('fs/promises');
|
|
179
|
+
await rmf(join(fakeHome, '.md-redline.json'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('PUT merges partial updates', async () => {
|
|
183
|
+
const { writeFile: wf, rm: rmf } = await import('fs/promises');
|
|
184
|
+
await wf(
|
|
185
|
+
join(fakeHome, '.md-redline.json'),
|
|
186
|
+
JSON.stringify({ author: 'Alice', theme: 'light' }),
|
|
187
|
+
);
|
|
188
|
+
const { response, body } = await requestJson(app, '/api/preferences', {
|
|
189
|
+
method: 'PUT',
|
|
190
|
+
headers: { 'Content-Type': 'application/json' },
|
|
191
|
+
body: JSON.stringify({ theme: 'dark' }),
|
|
192
|
+
});
|
|
193
|
+
expect(response.status).toBe(200);
|
|
194
|
+
expect(body).toEqual({ author: 'Alice', theme: 'dark' });
|
|
195
|
+
await rmf(join(fakeHome, '.md-redline.json'));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('PUT rejects invalid JSON body', async () => {
|
|
199
|
+
const response = await app.request('/api/preferences', {
|
|
200
|
+
method: 'PUT',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: 'not json',
|
|
203
|
+
});
|
|
204
|
+
expect(response.status).toBe(400);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('isPathInsideRoot', () => {
|
|
209
|
+
it('accepts nested POSIX paths', () => {
|
|
210
|
+
expect(isPathInsideRoot('/repo/docs/spec.md', '/repo')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects sibling POSIX paths', () => {
|
|
214
|
+
expect(isPathInsideRoot('/repo-other/spec.md', '/repo')).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('handles Windows separators and case-insensitive matching', () => {
|
|
218
|
+
expect(isPathInsideRoot('C:\\Work\\Docs\\Spec.md', 'c:\\work', true)).toBe(true);
|
|
219
|
+
expect(isPathInsideRoot('D:\\Work\\Docs\\Spec.md', 'c:\\work', true)).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('accepts all paths when root is /', () => {
|
|
223
|
+
expect(isPathInsideRoot('/etc/foo', '/')).toBe(true);
|
|
224
|
+
expect(isPathInsideRoot('/home/user/file.md', '/')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('/api/file', () => {
|
|
229
|
+
it('requires a path query parameter', async () => {
|
|
230
|
+
const { response, body } = await requestJson(app, '/api/file');
|
|
231
|
+
|
|
232
|
+
expect(response.status).toBe(400);
|
|
233
|
+
expect(body).toEqual({ error: 'path query parameter is required' });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('reads markdown files under allowed roots', async () => {
|
|
237
|
+
const { response, body } = await requestJson(
|
|
238
|
+
app,
|
|
239
|
+
`/api/file?path=${encodeURIComponent(docsFile)}`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(response.status).toBe(200);
|
|
243
|
+
expect(body).toMatchObject({
|
|
244
|
+
path: docsFile,
|
|
245
|
+
content: '# Alpha\n\nHello world\n',
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('rejects tilde paths when home directory is outside allowed roots', async () => {
|
|
250
|
+
const { response, body } = await requestJson(
|
|
251
|
+
app,
|
|
252
|
+
`/api/file?path=${encodeURIComponent('~/home.md')}`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(response.status).toBe(403);
|
|
256
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('expands tilde paths when home directory is inside an allowed root', async () => {
|
|
260
|
+
const homeInCwdApp = createApp({
|
|
261
|
+
cwd: cwdRoot,
|
|
262
|
+
homeDir: cwdRoot,
|
|
263
|
+
platformName: 'linux',
|
|
264
|
+
});
|
|
265
|
+
const { response, body } = await requestJson(
|
|
266
|
+
homeInCwdApp,
|
|
267
|
+
`/api/file?path=${encodeURIComponent('~/root.md')}`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(response.status).toBe(200);
|
|
271
|
+
expect(body).toMatchObject({
|
|
272
|
+
path: rootFile,
|
|
273
|
+
content: '# Root\n',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('rejects non-markdown files', async () => {
|
|
278
|
+
const { response, body } = await requestJson(
|
|
279
|
+
app,
|
|
280
|
+
`/api/file?path=${encodeURIComponent(textFile)}`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(response.status).toBe(400);
|
|
284
|
+
expect(body).toEqual({ error: 'Only .md files are supported' });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects files outside the allowed roots', async () => {
|
|
288
|
+
const { response, body } = await requestJson(
|
|
289
|
+
app,
|
|
290
|
+
`/api/file?path=${encodeURIComponent(externalFile)}`,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(response.status).toBe(403);
|
|
294
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('reads symlinked files that resolve inside allowed roots', async () => {
|
|
298
|
+
const { response, body } = await requestJson(
|
|
299
|
+
app,
|
|
300
|
+
`/api/file?path=${encodeURIComponent(allowedSymlinkFile)}`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(response.status).toBe(200);
|
|
304
|
+
expect(body).toMatchObject({
|
|
305
|
+
path: rootFile,
|
|
306
|
+
content: '# Root\n',
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects symlinked files that resolve outside allowed roots', async () => {
|
|
311
|
+
const { response, body } = await requestJson(
|
|
312
|
+
app,
|
|
313
|
+
`/api/file?path=${encodeURIComponent(outsideSymlinkFile)}`,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(response.status).toBe(403);
|
|
317
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('reads the configured initial file even when it is outside the default roots', async () => {
|
|
321
|
+
const { response, body } = await requestJson(
|
|
322
|
+
initialFileApp,
|
|
323
|
+
`/api/file?path=${encodeURIComponent(join(initialDir, 'initial.md'))}`,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(response.status).toBe(200);
|
|
327
|
+
expect(body).toMatchObject({
|
|
328
|
+
path: join(initialDir, 'initial.md'),
|
|
329
|
+
content: '# Initial\n',
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('PUT /api/file', () => {
|
|
335
|
+
it('rejects invalid JSON bodies', async () => {
|
|
336
|
+
const { response, body } = await requestJson(app, '/api/file', {
|
|
337
|
+
method: 'PUT',
|
|
338
|
+
headers: { 'Content-Type': 'application/json' },
|
|
339
|
+
body: 'not valid json',
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(response.status).toBe(400);
|
|
343
|
+
expect(body).toEqual({ error: 'Invalid JSON body' });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('writes markdown content inside allowed roots', async () => {
|
|
347
|
+
const newContent = '# Written\n\nSaved from test\n';
|
|
348
|
+
const { response, body } = await requestJson(app, '/api/file', {
|
|
349
|
+
method: 'PUT',
|
|
350
|
+
headers: { 'Content-Type': 'application/json' },
|
|
351
|
+
body: JSON.stringify({ path: writtenFile, content: newContent }),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(response.status).toBe(200);
|
|
355
|
+
expect(body).toMatchObject({ success: true, path: writtenFile });
|
|
356
|
+
expect(typeof body.mtime).toBe('number');
|
|
357
|
+
await expect(readFile(writtenFile, 'utf-8')).resolves.toBe(newContent);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('returns 409 when expectedMtime does not match (conflict detection)', async () => {
|
|
361
|
+
// First write to establish the file
|
|
362
|
+
const content1 = '# Version 1\n';
|
|
363
|
+
const write1 = await requestJson(app, '/api/file', {
|
|
364
|
+
method: 'PUT',
|
|
365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify({ path: writtenFile, content: content1 }),
|
|
367
|
+
});
|
|
368
|
+
expect(write1.response.status).toBe(200);
|
|
369
|
+
const mtime1 = write1.body.mtime;
|
|
370
|
+
|
|
371
|
+
// Simulate external edit by writing directly and ensuring a different mtime
|
|
372
|
+
await writeFile(writtenFile, '# External edit\n', 'utf-8');
|
|
373
|
+
const futureTime = new Date(Date.now() + 5000);
|
|
374
|
+
await utimes(writtenFile, futureTime, futureTime);
|
|
375
|
+
|
|
376
|
+
// Try to save with the old mtime — should conflict
|
|
377
|
+
const { response, body } = await requestJson(app, '/api/file', {
|
|
378
|
+
method: 'PUT',
|
|
379
|
+
headers: { 'Content-Type': 'application/json' },
|
|
380
|
+
body: JSON.stringify({ path: writtenFile, content: '# Version 2\n', expectedMtime: mtime1 }),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(response.status).toBe(409);
|
|
384
|
+
expect(body.code).toBe('CONFLICT');
|
|
385
|
+
expect(body.currentContent).toBe('# External edit\n');
|
|
386
|
+
expect(typeof body.mtime).toBe('number');
|
|
387
|
+
// File should NOT have been overwritten
|
|
388
|
+
await expect(readFile(writtenFile, 'utf-8')).resolves.toBe('# External edit\n');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('allows save when expectedMtime matches', async () => {
|
|
392
|
+
// Write and get mtime
|
|
393
|
+
const content1 = '# Saved\n';
|
|
394
|
+
const write1 = await requestJson(app, '/api/file', {
|
|
395
|
+
method: 'PUT',
|
|
396
|
+
headers: { 'Content-Type': 'application/json' },
|
|
397
|
+
body: JSON.stringify({ path: writtenFile, content: content1 }),
|
|
398
|
+
});
|
|
399
|
+
const mtime1 = write1.body.mtime;
|
|
400
|
+
|
|
401
|
+
// Save again with correct mtime
|
|
402
|
+
const content2 = '# Updated\n';
|
|
403
|
+
const { response, body } = await requestJson(app, '/api/file', {
|
|
404
|
+
method: 'PUT',
|
|
405
|
+
headers: { 'Content-Type': 'application/json' },
|
|
406
|
+
body: JSON.stringify({ path: writtenFile, content: content2, expectedMtime: mtime1 }),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(response.status).toBe(200);
|
|
410
|
+
expect(body).toMatchObject({ success: true });
|
|
411
|
+
await expect(readFile(writtenFile, 'utf-8')).resolves.toBe(content2);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rejects new files under symlinked directories that point outside allowed roots', async () => {
|
|
415
|
+
const targetFile = join(outsideSymlinkDir, 'new.md');
|
|
416
|
+
const { response, body } = await requestJson(app, '/api/file', {
|
|
417
|
+
method: 'PUT',
|
|
418
|
+
headers: { 'Content-Type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({ path: targetFile, content: '# Nope\n' }),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(response.status).toBe(403);
|
|
423
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('writes sibling files next to the configured initial file outside the repo', async () => {
|
|
427
|
+
const newContent = '# Follow-up\n\nOpened from CLI\n';
|
|
428
|
+
const { response, body } = await requestJson(initialFileApp, '/api/file', {
|
|
429
|
+
method: 'PUT',
|
|
430
|
+
headers: { 'Content-Type': 'application/json' },
|
|
431
|
+
body: JSON.stringify({ path: initialSiblingFile, content: newContent }),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(response.status).toBe(200);
|
|
435
|
+
expect(body).toMatchObject({ success: true, path: initialSiblingFile });
|
|
436
|
+
expect(typeof body.mtime).toBe('number');
|
|
437
|
+
await expect(readFile(initialSiblingFile, 'utf-8')).resolves.toBe(newContent);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('/api/files', () => {
|
|
442
|
+
it('lists .md files case-insensitively in the requested directory', async () => {
|
|
443
|
+
const { response, body } = await requestJson(
|
|
444
|
+
app,
|
|
445
|
+
`/api/files?dir=${encodeURIComponent(docsDir)}`,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
expect(response.status).toBe(200);
|
|
449
|
+
expect(body.dir).toBe(docsDir);
|
|
450
|
+
expect(body.files).toContain(join(docsDir, 'README.MD'));
|
|
451
|
+
expect(body.files).toContain(join(docsDir, 'alpha.md'));
|
|
452
|
+
expect(body.files).toContain(writtenFile);
|
|
453
|
+
expect(body.files).toContain(join(docsDir, 'zeta.md'));
|
|
454
|
+
expect(body.files).toHaveLength(4);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('rejects directories outside allowed roots', async () => {
|
|
458
|
+
const { response, body } = await requestJson(
|
|
459
|
+
app,
|
|
460
|
+
`/api/files?dir=${encodeURIComponent(externalDir)}`,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
expect(response.status).toBe(403);
|
|
464
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('lists files through symlinked directories that resolve inside allowed roots', async () => {
|
|
468
|
+
const { response, body } = await requestJson(
|
|
469
|
+
app,
|
|
470
|
+
`/api/files?dir=${encodeURIComponent(allowedSymlinkDir)}`,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
expect(response.status).toBe(200);
|
|
474
|
+
expect(body).toEqual({
|
|
475
|
+
dir: nestedDir,
|
|
476
|
+
files: [join(nestedDir, 'nested.md')],
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('rejects symlinked directories that resolve outside allowed roots', async () => {
|
|
481
|
+
const { response, body } = await requestJson(
|
|
482
|
+
app,
|
|
483
|
+
`/api/files?dir=${encodeURIComponent(outsideSymlinkDir)}`,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
expect(response.status).toBe(403);
|
|
487
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('lists files in the configured initial directory outside the repo', async () => {
|
|
491
|
+
const { response, body } = await requestJson(
|
|
492
|
+
initialDirApp,
|
|
493
|
+
`/api/files?dir=${encodeURIComponent(initialDir)}`,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(response.status).toBe(200);
|
|
497
|
+
expect(body).toEqual({
|
|
498
|
+
dir: initialDir,
|
|
499
|
+
files: [initialSiblingFile, join(initialDir, 'initial.md')],
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe('/api/browse', () => {
|
|
505
|
+
it('lists visible directories and markdown files with an allowed parent', async () => {
|
|
506
|
+
const { response, body } = await requestJson(
|
|
507
|
+
app,
|
|
508
|
+
`/api/browse?dir=${encodeURIComponent(docsDir)}`,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(response.status).toBe(200);
|
|
512
|
+
expect(body).toEqual({
|
|
513
|
+
dir: docsDir,
|
|
514
|
+
parent: cwdRoot,
|
|
515
|
+
directories: [{ name: 'nested', path: join(docsDir, 'nested') }],
|
|
516
|
+
files: [
|
|
517
|
+
{ name: 'alpha.md', path: join(docsDir, 'alpha.md') },
|
|
518
|
+
{ name: 'README.MD', path: join(docsDir, 'README.MD') },
|
|
519
|
+
{ name: 'written.md', path: writtenFile },
|
|
520
|
+
{ name: 'zeta.md', path: join(docsDir, 'zeta.md') },
|
|
521
|
+
],
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('returns 400 when the path points to a file instead of a directory', async () => {
|
|
526
|
+
const { response, body } = await requestJson(
|
|
527
|
+
app,
|
|
528
|
+
`/api/browse?dir=${encodeURIComponent(docsFile)}`,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
expect(response.status).toBe(400);
|
|
532
|
+
expect(body).toEqual({ error: 'Not a directory' });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('browses symlinked directories that resolve inside allowed roots', async () => {
|
|
536
|
+
const { response, body } = await requestJson(
|
|
537
|
+
app,
|
|
538
|
+
`/api/browse?dir=${encodeURIComponent(allowedSymlinkDir)}`,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
expect(response.status).toBe(200);
|
|
542
|
+
expect(body).toEqual({
|
|
543
|
+
dir: nestedDir,
|
|
544
|
+
parent: docsDir,
|
|
545
|
+
directories: [],
|
|
546
|
+
files: [{ name: 'nested.md', path: join(nestedDir, 'nested.md') }],
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('rejects symlinked directories that resolve outside allowed roots', async () => {
|
|
551
|
+
const { response, body } = await requestJson(
|
|
552
|
+
app,
|
|
553
|
+
`/api/browse?dir=${encodeURIComponent(outsideSymlinkDir)}`,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
expect(response.status).toBe(403);
|
|
557
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('browses the configured initial directory outside the repo', async () => {
|
|
561
|
+
const { response, body } = await requestJson(
|
|
562
|
+
initialDirApp,
|
|
563
|
+
`/api/browse?dir=${encodeURIComponent(initialDir)}`,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
expect(response.status).toBe(200);
|
|
567
|
+
expect(body).toEqual({
|
|
568
|
+
dir: initialDir,
|
|
569
|
+
parent: null,
|
|
570
|
+
directories: [],
|
|
571
|
+
files: [
|
|
572
|
+
{ name: 'follow-up.md', path: initialSiblingFile },
|
|
573
|
+
{ name: 'initial.md', path: join(initialDir, 'initial.md') },
|
|
574
|
+
],
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
describe('/api/watch', () => {
|
|
580
|
+
/** Read the initial SSE frames from a streaming response (the stream never closes). */
|
|
581
|
+
async function readSseFrames(response: Response): Promise<string> {
|
|
582
|
+
const reader = response.body!.getReader();
|
|
583
|
+
const decoder = new TextDecoder();
|
|
584
|
+
let text = '';
|
|
585
|
+
// Read available chunks with a short deadline — "connected" frames are written synchronously.
|
|
586
|
+
const deadline = Date.now() + 500;
|
|
587
|
+
while (Date.now() < deadline) {
|
|
588
|
+
const result = await Promise.race([
|
|
589
|
+
reader.read(),
|
|
590
|
+
new Promise<{ done: true; value: undefined }>((r) =>
|
|
591
|
+
setTimeout(() => r({ done: true, value: undefined }), 100),
|
|
592
|
+
),
|
|
593
|
+
]);
|
|
594
|
+
if (result.value) text += decoder.decode(result.value, { stream: true });
|
|
595
|
+
if (result.done) break;
|
|
596
|
+
}
|
|
597
|
+
reader.cancel().catch(() => {});
|
|
598
|
+
return text;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parseSseEvents(text: string) {
|
|
602
|
+
return text
|
|
603
|
+
.split('\n\n')
|
|
604
|
+
.filter(Boolean)
|
|
605
|
+
.map((block) => {
|
|
606
|
+
const eventMatch = block.match(/^event: (.+)$/m);
|
|
607
|
+
const dataLines = block
|
|
608
|
+
.split('\n')
|
|
609
|
+
.filter((l) => l.startsWith('data: '))
|
|
610
|
+
.map((l) => l.slice(6));
|
|
611
|
+
return {
|
|
612
|
+
event: eventMatch?.[1] ?? '',
|
|
613
|
+
data: dataLines.join('\n'),
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
it('returns 400 when no path is provided', async () => {
|
|
619
|
+
const { response, body } = await requestJson(app, '/api/watch');
|
|
620
|
+
|
|
621
|
+
expect(response.status).toBe(400);
|
|
622
|
+
expect(body).toEqual({ error: 'path query parameter is required' });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('returns 400 for a single non-markdown file', async () => {
|
|
626
|
+
const { response, body } = await requestJson(
|
|
627
|
+
app,
|
|
628
|
+
`/api/watch?path=${encodeURIComponent(textFile)}`,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
expect(response.status).toBe(400);
|
|
632
|
+
expect(body).toEqual({ error: 'Only .md files are supported' });
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('returns 403 for a single file outside allowed roots', async () => {
|
|
636
|
+
const { response, body } = await requestJson(
|
|
637
|
+
app,
|
|
638
|
+
`/api/watch?path=${encodeURIComponent(externalFile)}`,
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
expect(response.status).toBe(403);
|
|
642
|
+
expect(body).toEqual({ error: 'Access denied: path outside allowed directories' });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('opens an SSE stream for a single valid file', async () => {
|
|
646
|
+
const response = await app.request(
|
|
647
|
+
`http://localhost/api/watch?path=${encodeURIComponent(docsFile)}`,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
expect(response.status).toBe(200);
|
|
651
|
+
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
|
652
|
+
|
|
653
|
+
const text = await readSseFrames(response);
|
|
654
|
+
const events = parseSseEvents(text);
|
|
655
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
656
|
+
expect(events[0].event).toBe('connected');
|
|
657
|
+
expect(JSON.parse(events[0].data)).toEqual({ path: docsFile });
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('opens a multiplexed SSE stream for multiple valid files', async () => {
|
|
661
|
+
const response = await app.request(
|
|
662
|
+
`http://localhost/api/watch?path=${encodeURIComponent(docsFile)}&path=${encodeURIComponent(rootFile)}`,
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
expect(response.status).toBe(200);
|
|
666
|
+
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
|
667
|
+
|
|
668
|
+
const text = await readSseFrames(response);
|
|
669
|
+
const events = parseSseEvents(text);
|
|
670
|
+
const connectedPaths = events
|
|
671
|
+
.filter((e) => e.event === 'connected')
|
|
672
|
+
.map((e) => JSON.parse(e.data).path);
|
|
673
|
+
expect(connectedPaths).toContain(docsFile);
|
|
674
|
+
expect(connectedPaths).toContain(rootFile);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('skips invalid paths silently in a multi-path request', async () => {
|
|
678
|
+
const badPath = join(externalDir, 'outside.md');
|
|
679
|
+
const response = await app.request(
|
|
680
|
+
`http://localhost/api/watch?path=${encodeURIComponent(docsFile)}&path=${encodeURIComponent(badPath)}`,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
expect(response.status).toBe(200);
|
|
684
|
+
|
|
685
|
+
const text = await readSseFrames(response);
|
|
686
|
+
const events = parseSseEvents(text);
|
|
687
|
+
const connectedPaths = events
|
|
688
|
+
.filter((e) => e.event === 'connected')
|
|
689
|
+
.map((e) => JSON.parse(e.data).path);
|
|
690
|
+
expect(connectedPaths).toEqual([docsFile]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('returns 400 when all paths in a multi-path request are invalid', async () => {
|
|
694
|
+
const bad1 = join(externalDir, 'outside.md');
|
|
695
|
+
const bad2 = join(externalDir, 'also-outside.md');
|
|
696
|
+
const { response, body } = await requestJson(
|
|
697
|
+
app,
|
|
698
|
+
`/api/watch?path=${encodeURIComponent(bad1)}&path=${encodeURIComponent(bad2)}`,
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
expect(response.status).toBe(400);
|
|
702
|
+
expect(body).toEqual({ error: 'No valid .md files to watch' });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe('/api/platform', () => {
|
|
707
|
+
it('returns the injected platform value', async () => {
|
|
708
|
+
const { response, body } = await requestJson(app, '/api/platform');
|
|
709
|
+
|
|
710
|
+
expect(response.status).toBe(200);
|
|
711
|
+
expect(body).toEqual({ platform: 'linux' });
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe('/api/pick-file', () => {
|
|
716
|
+
it('uses PowerShell on Windows to launch the system picker', async () => {
|
|
717
|
+
const { calls, execFileImpl } = createExecFileStub('C:\\docs\\spec.md\n');
|
|
718
|
+
const windowsApp = createApp({
|
|
719
|
+
cwd: cwdRoot,
|
|
720
|
+
homeDir: fakeHome,
|
|
721
|
+
platformName: 'win32',
|
|
722
|
+
execFileImpl,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const { response, body } = await requestJson(windowsApp, '/api/pick-file');
|
|
726
|
+
|
|
727
|
+
expect(response.status).toBe(200);
|
|
728
|
+
expect(body).toEqual({ path: 'C:\\docs\\spec.md' });
|
|
729
|
+
expect(calls).toHaveLength(1);
|
|
730
|
+
expect(calls[0].file).toBe('powershell');
|
|
731
|
+
expect(calls[0].args).toContain('-STA');
|
|
732
|
+
expect(calls[0].args.join(' ')).toContain('OpenFileDialog');
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe('Content-Type enforcement', () => {
|
|
737
|
+
it('rejects POST requests without application/json Content-Type', async () => {
|
|
738
|
+
const response = await app.request('/api/reveal', {
|
|
739
|
+
method: 'POST',
|
|
740
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
741
|
+
body: JSON.stringify({ path: docsFile }),
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(response.status).toBe(415);
|
|
745
|
+
expect(await response.json()).toEqual({ error: 'Content-Type must be application/json' });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('rejects PUT requests without Content-Type header', async () => {
|
|
749
|
+
const response = await app.request('/api/file', {
|
|
750
|
+
method: 'PUT',
|
|
751
|
+
body: JSON.stringify({ path: writtenFile, content: '# Test\n' }),
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
expect(response.status).toBe(415);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('allows GET requests without Content-Type', async () => {
|
|
758
|
+
const response = await app.request(
|
|
759
|
+
`http://localhost/api/file?path=${encodeURIComponent(docsFile)}`,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
expect(response.status).toBe(200);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
describe('/api/reveal', () => {
|
|
767
|
+
it('uses osascript on macOS to reveal and activate Finder', async () => {
|
|
768
|
+
const { calls, execFileImpl } = createExecFileStub('');
|
|
769
|
+
const macApp = createApp({
|
|
770
|
+
cwd: cwdRoot,
|
|
771
|
+
homeDir: fakeHome,
|
|
772
|
+
platformName: 'darwin',
|
|
773
|
+
execFileImpl,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const { response, body } = await requestJson(macApp, '/api/reveal', {
|
|
777
|
+
method: 'POST',
|
|
778
|
+
headers: { 'Content-Type': 'application/json' },
|
|
779
|
+
body: JSON.stringify({ path: docsFile }),
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
expect(response.status).toBe(200);
|
|
783
|
+
expect(body).toEqual({ success: true });
|
|
784
|
+
expect(calls).toHaveLength(1);
|
|
785
|
+
expect(calls[0].file).toBe('osascript');
|
|
786
|
+
expect(calls[0].args).toContain('-e');
|
|
787
|
+
expect(calls[0].args.join(' ')).toContain('tell application "Finder" to reveal');
|
|
788
|
+
expect(calls[0].args.join(' ')).toContain('tell application "Finder" to activate');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('uses Explorer on Windows to reveal a file', async () => {
|
|
792
|
+
const { calls, execFileImpl } = createExecFileStub('');
|
|
793
|
+
const windowsApp = createApp({
|
|
794
|
+
cwd: cwdRoot,
|
|
795
|
+
homeDir: fakeHome,
|
|
796
|
+
platformName: 'win32',
|
|
797
|
+
execFileImpl,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const { response, body } = await requestJson(windowsApp, '/api/reveal', {
|
|
801
|
+
method: 'POST',
|
|
802
|
+
headers: { 'Content-Type': 'application/json' },
|
|
803
|
+
body: JSON.stringify({ path: docsFile }),
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
expect(response.status).toBe(200);
|
|
807
|
+
expect(body).toEqual({ success: true });
|
|
808
|
+
expect(calls).toHaveLength(1);
|
|
809
|
+
expect(calls[0]).toEqual({
|
|
810
|
+
file: 'explorer',
|
|
811
|
+
args: ['/select,', docsFile],
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
});
|