@1agh/maude 0.15.0 → 0.16.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/README.md +1 -3
- package/package.json +8 -8
- package/plugins/design/dev-server/api.ts +186 -3
- package/plugins/design/dev-server/canvas-shell.tsx +79 -5
- package/plugins/design/dev-server/client/app.jsx +53 -142
- package/plugins/design/dev-server/client/comments-overlay.css +381 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
- package/plugins/design/dev-server/client/styles/4-components.css +5 -161
- package/plugins/design/dev-server/client/styles.css +5 -160
- package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
- package/plugins/design/dev-server/dist/client.bundle.js +44 -211
- package/plugins/design/dev-server/dist/styles.css +1 -218
- package/plugins/design/dev-server/http.ts +29 -0
- package/plugins/design/dev-server/input-router.tsx +21 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
- package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
|
@@ -1163,7 +1163,7 @@
|
|
|
1163
1163
|
overflow: hidden;
|
|
1164
1164
|
}
|
|
1165
1165
|
|
|
1166
|
-
.sb-clear-sel
|
|
1166
|
+
.sb-clear-sel {
|
|
1167
1167
|
color: var(--u-fg-2);
|
|
1168
1168
|
border: 1px solid var(--u-border);
|
|
1169
1169
|
border-radius: var(--u-r-xs);
|
|
@@ -1177,22 +1177,12 @@
|
|
|
1177
1177
|
line-height: 1.4;
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
-
.sb-add-comment {
|
|
1181
|
-
color: var(--u-accent);
|
|
1182
|
-
border-color: var(--u-accent);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
1180
|
.sb-clear-sel:hover {
|
|
1186
1181
|
color: var(--u-fg-0);
|
|
1187
1182
|
background: var(--u-bg-2);
|
|
1188
1183
|
border-color: var(--u-border-strong);
|
|
1189
1184
|
}
|
|
1190
1185
|
|
|
1191
|
-
.sb-add-comment:hover {
|
|
1192
|
-
background: var(--u-accent);
|
|
1193
|
-
color: var(--u-accent-fg);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
1186
|
.sb-unread .sb-count {
|
|
1197
1187
|
font-family: var(--u-font-mono);
|
|
1198
1188
|
color: var(--u-fg-0);
|
|
@@ -1866,74 +1856,6 @@
|
|
|
1866
1856
|
display: flex;
|
|
1867
1857
|
}
|
|
1868
1858
|
|
|
1869
|
-
.composer {
|
|
1870
|
-
gap: var(--u-s-2);
|
|
1871
|
-
padding: var(--u-s-3);
|
|
1872
|
-
background: var(--u-bg-2);
|
|
1873
|
-
border: 1px solid var(--u-accent-line);
|
|
1874
|
-
border-radius: var(--u-r-md);
|
|
1875
|
-
flex-direction: column;
|
|
1876
|
-
margin: 4px 0;
|
|
1877
|
-
display: flex;
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
.composer-head {
|
|
1881
|
-
align-items: baseline;
|
|
1882
|
-
gap: var(--u-s-2);
|
|
1883
|
-
color: var(--u-fg-3);
|
|
1884
|
-
font-size: 11px;
|
|
1885
|
-
display: flex;
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
.composer-selector {
|
|
1889
|
-
font-family: var(--u-font-mono);
|
|
1890
|
-
color: var(--u-fg-1);
|
|
1891
|
-
background: var(--u-bg-1);
|
|
1892
|
-
border-radius: var(--u-r-sm);
|
|
1893
|
-
border: 1px solid var(--u-border);
|
|
1894
|
-
text-overflow: ellipsis;
|
|
1895
|
-
white-space: nowrap;
|
|
1896
|
-
flex: 1;
|
|
1897
|
-
min-width: 0;
|
|
1898
|
-
padding: 2px 8px;
|
|
1899
|
-
font-size: 11px;
|
|
1900
|
-
overflow: hidden;
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
.comment-bar textarea.composer-textarea {
|
|
1904
|
-
background: var(--u-bg-1);
|
|
1905
|
-
border: 1px solid var(--u-border);
|
|
1906
|
-
border-radius: var(--u-r-sm);
|
|
1907
|
-
width: 100%;
|
|
1908
|
-
color: var(--u-fg-0);
|
|
1909
|
-
padding: var(--u-s-3);
|
|
1910
|
-
resize: vertical;
|
|
1911
|
-
outline: none;
|
|
1912
|
-
min-height: 100px;
|
|
1913
|
-
font-family: inherit;
|
|
1914
|
-
font-size: 13px;
|
|
1915
|
-
line-height: 1.5;
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
.comment-bar textarea.composer-textarea:focus {
|
|
1919
|
-
border-color: var(--u-accent-line);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
.comment-bar textarea.composer-textarea::placeholder {
|
|
1923
|
-
color: var(--u-fg-3);
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
.composer-actions {
|
|
1927
|
-
justify-content: flex-end;
|
|
1928
|
-
gap: 6px;
|
|
1929
|
-
display: flex;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
.cb-row.composer {
|
|
1933
|
-
flex-wrap: nowrap;
|
|
1934
|
-
align-items: stretch;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
1859
|
.cb-label {
|
|
1938
1860
|
color: var(--u-fg-3);
|
|
1939
1861
|
flex: none;
|
|
@@ -1941,150 +1863,11 @@
|
|
|
1941
1863
|
font-size: 11px;
|
|
1942
1864
|
}
|
|
1943
1865
|
|
|
1944
|
-
.cb-label code {
|
|
1945
|
-
font-family: var(--u-font-mono);
|
|
1946
|
-
background: var(--u-bg-2);
|
|
1947
|
-
border-radius: var(--u-r-xs);
|
|
1948
|
-
color: var(--u-fg-1);
|
|
1949
|
-
padding: 1px 4px;
|
|
1950
|
-
font-size: 10px;
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
.comment-bar textarea {
|
|
1954
|
-
background: var(--u-bg-2);
|
|
1955
|
-
border: 1px solid var(--u-border);
|
|
1956
|
-
border-radius: var(--u-r-sm);
|
|
1957
|
-
color: var(--u-fg-0);
|
|
1958
|
-
padding: var(--u-s-2);
|
|
1959
|
-
resize: vertical;
|
|
1960
|
-
outline: none;
|
|
1961
|
-
flex: 1;
|
|
1962
|
-
min-height: 40px;
|
|
1963
|
-
font-family: inherit;
|
|
1964
|
-
font-size: 12px;
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
.comment-bar textarea:focus {
|
|
1968
|
-
border-color: var(--u-accent-line);
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
.cb-actions {
|
|
1972
|
-
align-self: flex-end;
|
|
1973
|
-
gap: 4px;
|
|
1974
|
-
display: flex;
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
.cb-primary, .cb-secondary {
|
|
1978
|
-
border: 1px solid var(--u-border);
|
|
1979
|
-
background: var(--u-bg-2);
|
|
1980
|
-
color: var(--u-fg-1);
|
|
1981
|
-
border-radius: var(--u-r-sm);
|
|
1982
|
-
padding: 4px var(--u-s-3);
|
|
1983
|
-
font-family: var(--u-font-mono);
|
|
1984
|
-
cursor: pointer;
|
|
1985
|
-
font-size: 11px;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
.cb-primary {
|
|
1989
|
-
background: var(--u-accent);
|
|
1990
|
-
color: var(--u-bg-0);
|
|
1991
|
-
border-color: var(--u-accent);
|
|
1992
|
-
font-weight: 600;
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
.cb-primary:hover {
|
|
1996
|
-
background: var(--u-accent-strong);
|
|
1997
|
-
border-color: var(--u-accent-strong);
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
.cb-primary:disabled {
|
|
2001
|
-
opacity: .5;
|
|
2002
|
-
cursor: not-allowed;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
.cb-secondary:hover {
|
|
2006
|
-
background: var(--u-bg-3);
|
|
2007
|
-
color: var(--u-fg-0);
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
.cb-row.focused {
|
|
2011
|
-
background: var(--u-bg-2);
|
|
2012
|
-
border-radius: var(--u-r-sm);
|
|
2013
|
-
padding: 4px var(--u-s-2);
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
.cb-pinno {
|
|
2017
|
-
font-family: var(--u-font-mono);
|
|
2018
|
-
background: var(--u-accent);
|
|
2019
|
-
color: var(--u-accent-fg);
|
|
2020
|
-
border-radius: var(--u-r-sm);
|
|
2021
|
-
letter-spacing: var(--tracking-sku);
|
|
2022
|
-
flex: none;
|
|
2023
|
-
padding: 1px 6px;
|
|
2024
|
-
font-size: 11px;
|
|
2025
|
-
font-weight: 700;
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
.cb-text {
|
|
2029
|
-
color: var(--u-fg-0);
|
|
2030
|
-
text-overflow: ellipsis;
|
|
2031
|
-
white-space: nowrap;
|
|
2032
|
-
flex: 1;
|
|
2033
|
-
font-size: 12px;
|
|
2034
|
-
overflow: hidden;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
.cb-target code {
|
|
2038
|
-
font-family: var(--u-font-mono);
|
|
2039
|
-
background: var(--u-bg-3);
|
|
2040
|
-
border-radius: var(--u-r-xs);
|
|
2041
|
-
color: var(--u-fg-2);
|
|
2042
|
-
text-overflow: ellipsis;
|
|
2043
|
-
white-space: nowrap;
|
|
2044
|
-
max-width: 240px;
|
|
2045
|
-
padding: 1px 6px;
|
|
2046
|
-
font-size: 10px;
|
|
2047
|
-
display: inline-block;
|
|
2048
|
-
overflow: hidden;
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
1866
|
.cb-row.strip {
|
|
2052
1867
|
color: var(--u-fg-3);
|
|
2053
1868
|
font-size: 11px;
|
|
2054
1869
|
}
|
|
2055
1870
|
|
|
2056
|
-
.cb-pin-strip {
|
|
2057
|
-
flex-wrap: wrap;
|
|
2058
|
-
flex: 1;
|
|
2059
|
-
gap: 4px;
|
|
2060
|
-
display: flex;
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
.cb-pin-chip {
|
|
2064
|
-
font-family: var(--u-font-mono);
|
|
2065
|
-
background: var(--u-accent);
|
|
2066
|
-
color: var(--u-accent-fg);
|
|
2067
|
-
border-radius: var(--u-r-sm);
|
|
2068
|
-
cursor: pointer;
|
|
2069
|
-
letter-spacing: var(--tracking-sku);
|
|
2070
|
-
border: 0;
|
|
2071
|
-
padding: 2px 7px;
|
|
2072
|
-
font-size: 10px;
|
|
2073
|
-
font-weight: 600;
|
|
2074
|
-
transition: transform .1s;
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
.cb-pin-chip:hover {
|
|
2078
|
-
transform: scale(1.1);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
.cb-more {
|
|
2082
|
-
font-family: var(--u-font-mono);
|
|
2083
|
-
color: var(--u-fg-3);
|
|
2084
|
-
align-self: center;
|
|
2085
|
-
font-size: 10px;
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
1871
|
.rsidebar {
|
|
2089
1872
|
background: var(--u-bg-1);
|
|
2090
1873
|
border-left: 1px solid var(--u-border);
|
|
@@ -254,6 +254,15 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
254
254
|
return new Response('Method not allowed', { status: 405 });
|
|
255
255
|
},
|
|
256
256
|
|
|
257
|
+
'/_api/git-committers': async (req: Request) => {
|
|
258
|
+
// Phase 6 — feed for the @mention autocomplete in composer + reply box.
|
|
259
|
+
// GET → top-20 committers on HEAD (`git shortlog -sne | head -20`)
|
|
260
|
+
// already cached server-side for 60 s.
|
|
261
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
262
|
+
const committers = await api.gitCommitters();
|
|
263
|
+
return Response.json({ committers }, { headers: { 'Cache-Control': 'no-store' } });
|
|
264
|
+
},
|
|
265
|
+
|
|
257
266
|
'/_api/annotations': async (req: Request) => {
|
|
258
267
|
// Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
|
|
259
268
|
// GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
|
|
@@ -325,6 +334,26 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
325
334
|
const url = new URL(req.url);
|
|
326
335
|
const pathname = url.pathname;
|
|
327
336
|
|
|
337
|
+
// Phase 6 — POST /_api/comments/<id>/reply. Dynamic path, so it lives in
|
|
338
|
+
// the fall-through instead of the static `routes` map. `<id>` is the
|
|
339
|
+
// c_<hex> id of the parent comment; body is `{ body, author? }`. Bodies
|
|
340
|
+
// share the same 4000-char cap as a top-level comment.
|
|
341
|
+
const replyMatch = pathname.match(/^\/_api\/comments\/([A-Za-z0-9_]+)\/reply$/);
|
|
342
|
+
if (replyMatch) {
|
|
343
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
344
|
+
const id = replyMatch[1] ?? '';
|
|
345
|
+
const body = await readJson<{ body?: string; author?: string }>(req);
|
|
346
|
+
if (!body || typeof body.body !== 'string' || !body.body.trim()) {
|
|
347
|
+
return new Response('body.body required', { status: 400 });
|
|
348
|
+
}
|
|
349
|
+
const next = await api.commentsAddReply(id, {
|
|
350
|
+
body: body.body,
|
|
351
|
+
author: typeof body.author === 'string' ? body.author : undefined,
|
|
352
|
+
});
|
|
353
|
+
if (!next) return new Response('Not found', { status: 404 });
|
|
354
|
+
return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
|
|
355
|
+
}
|
|
356
|
+
|
|
328
357
|
// Bundled client assets (preferred path — bundle from dist/).
|
|
329
358
|
if (pathname.startsWith('/_client/')) {
|
|
330
359
|
const rel = decodeURIComponent(pathname.slice('/_client/'.length));
|
|
@@ -240,6 +240,21 @@ export function isEditableTarget(t: EventTarget | null): boolean {
|
|
|
240
240
|
return false;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Phase 6 — the comments overlay (pins / composer / thread popover / mention
|
|
245
|
+
* popup) lives INSIDE the canvas world, which means its DOM nodes are inside
|
|
246
|
+
* the input-router's capture host. Without an explicit bail-out the router
|
|
247
|
+
* would `preventDefault + stopImmediatePropagation` every click on a
|
|
248
|
+
* composer button while comment mode is active, blocking Save / Cancel.
|
|
249
|
+
*
|
|
250
|
+
* We treat the overlay nodes like editable form widgets — the router yields,
|
|
251
|
+
* the React event handler runs.
|
|
252
|
+
*/
|
|
253
|
+
export function isOverlayTarget(t: EventTarget | null): boolean {
|
|
254
|
+
if (!t || !(t as Element).closest) return false;
|
|
255
|
+
return !!(t as Element).closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin');
|
|
256
|
+
}
|
|
257
|
+
|
|
243
258
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
244
259
|
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true } = opts;
|
|
245
260
|
|
|
@@ -290,6 +305,10 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
290
305
|
};
|
|
291
306
|
|
|
292
307
|
const onPointerDown = (e: PointerEvent): void => {
|
|
308
|
+
// Phase 6 — overlay surfaces (composer / thread / mention popup) own
|
|
309
|
+
// their own clicks. The router is in capture phase, so we have to
|
|
310
|
+
// bail HERE before classify can claim the event.
|
|
311
|
+
if (isOverlayTarget(e.target)) return;
|
|
293
312
|
const action = classify({
|
|
294
313
|
type: 'pointerdown',
|
|
295
314
|
button: e.button,
|
|
@@ -321,6 +340,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
321
340
|
* stop their twin mousedown.
|
|
322
341
|
*/
|
|
323
342
|
const onMouseDown = (e: MouseEvent): void => {
|
|
343
|
+
if (isOverlayTarget(e.target)) return;
|
|
324
344
|
const action = classify({
|
|
325
345
|
type: 'pointerdown',
|
|
326
346
|
button: e.button,
|
|
@@ -346,6 +366,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
346
366
|
* matching pointerdown (re-classify with the same modifiers).
|
|
347
367
|
*/
|
|
348
368
|
const onClick = (e: MouseEvent): void => {
|
|
369
|
+
if (isOverlayTarget(e.target)) return;
|
|
349
370
|
const tool = getActiveTool();
|
|
350
371
|
const mod = e.metaKey || e.ctrlKey;
|
|
351
372
|
const wouldRoute =
|
|
@@ -24,16 +24,6 @@ interface MetaShape {
|
|
|
24
24
|
[k: string]: unknown;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function seedCanvas(designRoot: string, name = 'Phase4.tsx', meta?: MetaShape): string {
|
|
28
|
-
const ui = join(designRoot, 'ui');
|
|
29
|
-
mkdirSync(ui, { recursive: true });
|
|
30
|
-
const tsxPath = join(ui, name);
|
|
31
|
-
writeFileSync(tsxPath, 'export default function P(){return <main/>}\n');
|
|
32
|
-
const metaPath = tsxPath.replace(/\.tsx$/, '.meta.json');
|
|
33
|
-
if (meta) writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
34
|
-
return tsxPath.replace(`${designRoot.replace(/\.design$/, '')}`, '').replace(/^\/+/, '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
27
|
function repoRel(designRoot: string, abs: string): string {
|
|
38
28
|
// designRoot ends in `.design`. repoRoot is its parent.
|
|
39
29
|
const repoRoot = designRoot.replace(/\.design$/, '').replace(/\/+$/, '');
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Phase 6 — comments API extensions: author / thread / mentions schema,
|
|
2
|
+
// POST /_api/comments/<id>/reply, GET /_api/git-committers.
|
|
3
|
+
//
|
|
4
|
+
// Verifies:
|
|
5
|
+
// - New comments include `author`, `thread`, `mentions`
|
|
6
|
+
// - parseMentions in `text` lands in `mentions[]`
|
|
7
|
+
// - POST .../reply appends to thread + folds reply mentions into the union
|
|
8
|
+
// - Legacy comments (no author/thread/mentions on disk) round-trip with
|
|
9
|
+
// defaults filled in memory; disk shape stays untouched until next write
|
|
10
|
+
// - GET /_api/git-committers returns the committer list (≥1 entry in a real
|
|
11
|
+
// git sandbox; gracefully empty if git fails)
|
|
12
|
+
// - Reply on unknown id → 404
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
20
|
+
|
|
21
|
+
async function initGit(root: string) {
|
|
22
|
+
// Make the sandbox a tiny git repo so `git config user.name` + `git shortlog`
|
|
23
|
+
// can answer. The boot path doesn't need a real repo, but the gitCommitters /
|
|
24
|
+
// gitCurrentUser helpers do.
|
|
25
|
+
//
|
|
26
|
+
// GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_SYSTEM=/dev/null isolate the
|
|
27
|
+
// sandbox from the developer's global git config (which may require GPG
|
|
28
|
+
// signing, set a non-test identity, or otherwise interfere with the
|
|
29
|
+
// throwaway commit). This is repo isolation, not a sign-bypass on a real
|
|
30
|
+
// commit — the test never produces an artifact outside its temp dir.
|
|
31
|
+
const env = {
|
|
32
|
+
...process.env,
|
|
33
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
34
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
35
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
36
|
+
};
|
|
37
|
+
await Bun.spawn(['git', 'init', '-q'], { cwd: root, env }).exited;
|
|
38
|
+
await Bun.spawn(['git', 'config', 'user.email', 'tester@example.com'], { cwd: root, env }).exited;
|
|
39
|
+
await Bun.spawn(['git', 'config', 'user.name', 'Test User'], { cwd: root, env }).exited;
|
|
40
|
+
writeFileSync(join(root, 'README.md'), '# sandbox\n');
|
|
41
|
+
await Bun.spawn(['git', 'add', '.'], { cwd: root, env }).exited;
|
|
42
|
+
await Bun.spawn(['git', 'commit', '-q', '-m', 'init'], { cwd: root, env }).exited;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('Phase 6 — comments author + thread + mentions', () => {
|
|
46
|
+
test('commentsAdd populates author + empty thread + parsed mentions', async () => {
|
|
47
|
+
const { root, designRoot } = makeSandbox();
|
|
48
|
+
await initGit(root);
|
|
49
|
+
const port = nextPort();
|
|
50
|
+
const proc = await bootServer(root, port);
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(designRoot, 'ui', 'Foo.tsx'),
|
|
55
|
+
'export default function P(){return <main/>}\n'
|
|
56
|
+
);
|
|
57
|
+
const r = await fetch(`http://localhost:${port}/_comments-all`);
|
|
58
|
+
expect(r.status).toBe(200);
|
|
59
|
+
|
|
60
|
+
// POST through the WS path is the normal route, but for unit-coverage we
|
|
61
|
+
// use the file-system view: write a comment via the http endpoint
|
|
62
|
+
// doesn't exist (commentsAdd is WS-driven). Instead, hand-author a
|
|
63
|
+
// single comment via filesystem + read it back to verify the loader
|
|
64
|
+
// backfill, then exercise reply via /_api/comments/<id>/reply.
|
|
65
|
+
const slug = 'ui-foo';
|
|
66
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
67
|
+
const legacy = [
|
|
68
|
+
{
|
|
69
|
+
id: 'c_legacy0',
|
|
70
|
+
file: '.design/ui/Foo.tsx',
|
|
71
|
+
selector: 'main',
|
|
72
|
+
dom_path: ['main'],
|
|
73
|
+
tag: 'main',
|
|
74
|
+
classes: '',
|
|
75
|
+
bounds: null,
|
|
76
|
+
html_excerpt: '',
|
|
77
|
+
text: 'old comment',
|
|
78
|
+
status: 'open',
|
|
79
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
80
|
+
resolved_at: null,
|
|
81
|
+
// no author / thread / mentions
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
writeFileSync(join(designRoot, '_comments', `${slug}.json`), JSON.stringify(legacy, null, 2));
|
|
85
|
+
|
|
86
|
+
const list = await fetch(
|
|
87
|
+
`http://localhost:${port}/_comments?file=${encodeURIComponent('.design/ui/Foo.tsx')}`
|
|
88
|
+
).then((x) => x.json());
|
|
89
|
+
expect(list.comments).toHaveLength(1);
|
|
90
|
+
expect(list.comments[0].author).toBe('');
|
|
91
|
+
expect(list.comments[0].thread).toEqual([]);
|
|
92
|
+
expect(list.comments[0].mentions).toEqual([]);
|
|
93
|
+
|
|
94
|
+
// Disk shape preserved — legacy file not rewritten on read.
|
|
95
|
+
const onDisk = JSON.parse(
|
|
96
|
+
readFileSync(join(designRoot, '_comments', `${slug}.json`), 'utf8')
|
|
97
|
+
);
|
|
98
|
+
expect(onDisk[0].author).toBeUndefined();
|
|
99
|
+
expect(onDisk[0].thread).toBeUndefined();
|
|
100
|
+
} finally {
|
|
101
|
+
await killProc(proc);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('POST /_api/comments/<id>/reply appends to thread + folds mentions', async () => {
|
|
106
|
+
const { root, designRoot } = makeSandbox();
|
|
107
|
+
await initGit(root);
|
|
108
|
+
const port = nextPort();
|
|
109
|
+
const proc = await bootServer(root, port);
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(designRoot, 'ui', 'Bar.tsx'),
|
|
114
|
+
'export default function P(){return <main/>}\n'
|
|
115
|
+
);
|
|
116
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
117
|
+
const seed = [
|
|
118
|
+
{
|
|
119
|
+
id: 'c_seed01',
|
|
120
|
+
file: '.design/ui/Bar.tsx',
|
|
121
|
+
selector: 'button.cta',
|
|
122
|
+
dom_path: ['main', 'button.cta'],
|
|
123
|
+
tag: 'button',
|
|
124
|
+
classes: 'cta',
|
|
125
|
+
bounds: { x: 10, y: 20, w: 100, h: 40 },
|
|
126
|
+
html_excerpt: '<button class="cta">x</button>',
|
|
127
|
+
text: 'needs more padding @ada',
|
|
128
|
+
status: 'open',
|
|
129
|
+
created: '2026-05-20T10:00:00.000Z',
|
|
130
|
+
resolved_at: null,
|
|
131
|
+
author: 'Original Author',
|
|
132
|
+
thread: [],
|
|
133
|
+
mentions: ['@ada'],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
writeFileSync(join(designRoot, '_comments', 'ui-bar.json'), JSON.stringify(seed, null, 2));
|
|
137
|
+
|
|
138
|
+
const reply = await fetch(`http://localhost:${port}/_api/comments/c_seed01/reply`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
body: JSON.stringify({ body: 'fixed in next pass @lin', author: 'Replier' }),
|
|
142
|
+
});
|
|
143
|
+
expect(reply.status).toBe(200);
|
|
144
|
+
const updated = await reply.json();
|
|
145
|
+
expect(updated.thread).toHaveLength(1);
|
|
146
|
+
expect(updated.thread[0].author).toBe('Replier');
|
|
147
|
+
expect(updated.thread[0].body).toBe('fixed in next pass @lin');
|
|
148
|
+
expect(updated.thread[0].id).toMatch(/^r_[0-9a-f]+$/);
|
|
149
|
+
expect(new Set(updated.mentions)).toEqual(new Set(['@ada', '@lin']));
|
|
150
|
+
|
|
151
|
+
// Persisted to disk in the v2 shape.
|
|
152
|
+
const onDisk = JSON.parse(readFileSync(join(designRoot, '_comments', 'ui-bar.json'), 'utf8'));
|
|
153
|
+
expect(onDisk[0].thread).toHaveLength(1);
|
|
154
|
+
expect(onDisk[0].thread[0].body).toBe('fixed in next pass @lin');
|
|
155
|
+
expect(new Set(onDisk[0].mentions)).toEqual(new Set(['@ada', '@lin']));
|
|
156
|
+
} finally {
|
|
157
|
+
await killProc(proc);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('POST /_api/comments/<id>/reply 404s on unknown id', async () => {
|
|
162
|
+
const { root } = makeSandbox();
|
|
163
|
+
await initGit(root);
|
|
164
|
+
const port = nextPort();
|
|
165
|
+
const proc = await bootServer(root, port);
|
|
166
|
+
try {
|
|
167
|
+
const r = await fetch(`http://localhost:${port}/_api/comments/c_ghost/reply`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ body: 'orphan' }),
|
|
171
|
+
});
|
|
172
|
+
expect(r.status).toBe(404);
|
|
173
|
+
} finally {
|
|
174
|
+
await killProc(proc);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('POST /_api/comments/<id>/reply 400 on empty body', async () => {
|
|
179
|
+
const { root } = makeSandbox();
|
|
180
|
+
await initGit(root);
|
|
181
|
+
const port = nextPort();
|
|
182
|
+
const proc = await bootServer(root, port);
|
|
183
|
+
try {
|
|
184
|
+
const r = await fetch(`http://localhost:${port}/_api/comments/c_anyid/reply`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({ body: ' ' }),
|
|
188
|
+
});
|
|
189
|
+
expect(r.status).toBe(400);
|
|
190
|
+
} finally {
|
|
191
|
+
await killProc(proc);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('GET /_api/git-committers returns the committer list', async () => {
|
|
196
|
+
const { root } = makeSandbox();
|
|
197
|
+
await initGit(root);
|
|
198
|
+
const port = nextPort();
|
|
199
|
+
const proc = await bootServer(root, port);
|
|
200
|
+
try {
|
|
201
|
+
const r = await fetch(`http://localhost:${port}/_api/git-committers`);
|
|
202
|
+
expect(r.status).toBe(200);
|
|
203
|
+
const data = await r.json();
|
|
204
|
+
expect(Array.isArray(data.committers)).toBe(true);
|
|
205
|
+
// The sandbox just made one commit, so at least one committer should exist.
|
|
206
|
+
expect(data.committers.length).toBeGreaterThanOrEqual(1);
|
|
207
|
+
expect(data.committers[0]).toMatchObject({
|
|
208
|
+
name: 'Test User',
|
|
209
|
+
email: 'tester@example.com',
|
|
210
|
+
});
|
|
211
|
+
expect(data.committers[0].commits).toBeGreaterThanOrEqual(1);
|
|
212
|
+
} finally {
|
|
213
|
+
await killProc(proc);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('GET /_api/git-committers 405 on POST', async () => {
|
|
218
|
+
const { root } = makeSandbox();
|
|
219
|
+
await initGit(root);
|
|
220
|
+
const port = nextPort();
|
|
221
|
+
const proc = await bootServer(root, port);
|
|
222
|
+
try {
|
|
223
|
+
const r = await fetch(`http://localhost:${port}/_api/git-committers`, { method: 'POST' });
|
|
224
|
+
expect(r.status).toBe(405);
|
|
225
|
+
} finally {
|
|
226
|
+
await killProc(proc);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|