hzl-web 2.2.0 → 2.4.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/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +107 -64
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +299 -189
- package/dist/server.test.js.map +1 -1
- package/dist/ui/assets/Paired-CHegtbHO.js +1 -0
- package/dist/ui/assets/force-graph-B5HrL0HG.js +42 -0
- package/dist/ui/assets/index-CdpHwHFG.css +1 -0
- package/dist/ui/assets/index-CgGSX2ei.js +5 -0
- package/dist/ui/assets/index-DhxVdKMf.js +109 -0
- package/dist/ui/index.html +9 -4466
- package/dist/ui/legacy.html +4473 -0
- package/dist/ui/screenshot-mobile.png +0 -0
- package/dist/ui/screenshot-wide.png +0 -0
- package/dist/ui/site.webmanifest +27 -0
- package/dist/ui-embed.d.ts +8 -8
- package/dist/ui-embed.d.ts.map +1 -1
- package/dist/ui-embed.js +71 -29
- package/dist/ui-embed.js.map +1 -1
- package/package.json +16 -3
package/dist/server.test.js
CHANGED
|
@@ -8,8 +8,11 @@ import { TasksCurrentProjector } from 'hzl-core/projections/tasks-current';
|
|
|
8
8
|
import { DependenciesProjector } from 'hzl-core/projections/dependencies';
|
|
9
9
|
import { CommentsCheckpointsProjector } from 'hzl-core/projections/comments-checkpoints';
|
|
10
10
|
import { ProjectsProjector } from 'hzl-core/projections/projects';
|
|
11
|
+
import { SearchProjector } from 'hzl-core/projections/search';
|
|
12
|
+
import { TagsProjector } from 'hzl-core/projections/tags';
|
|
11
13
|
import { TaskService } from 'hzl-core/services/task-service';
|
|
12
14
|
import { ProjectService } from 'hzl-core/services/project-service';
|
|
15
|
+
import { SearchService } from 'hzl-core/services/search-service';
|
|
13
16
|
import { TaskStatus } from 'hzl-core/events/types';
|
|
14
17
|
describe('hzl-web server', () => {
|
|
15
18
|
let db;
|
|
@@ -17,6 +20,7 @@ describe('hzl-web server', () => {
|
|
|
17
20
|
let projectionEngine;
|
|
18
21
|
let taskService;
|
|
19
22
|
let projectService;
|
|
23
|
+
let searchService;
|
|
20
24
|
let server;
|
|
21
25
|
beforeEach(() => {
|
|
22
26
|
db = createTestDb();
|
|
@@ -26,10 +30,13 @@ describe('hzl-web server', () => {
|
|
|
26
30
|
projectionEngine.register(new DependenciesProjector());
|
|
27
31
|
projectionEngine.register(new CommentsCheckpointsProjector());
|
|
28
32
|
projectionEngine.register(new ProjectsProjector());
|
|
33
|
+
projectionEngine.register(new SearchProjector());
|
|
34
|
+
projectionEngine.register(new TagsProjector());
|
|
29
35
|
projectService = new ProjectService(db, eventStore, projectionEngine);
|
|
30
36
|
projectService.ensureInboxExists();
|
|
31
37
|
projectService.createProject('test-project');
|
|
32
38
|
taskService = new TaskService(db, eventStore, projectionEngine, projectService);
|
|
39
|
+
searchService = new SearchService(db);
|
|
33
40
|
});
|
|
34
41
|
afterEach(async () => {
|
|
35
42
|
if (server) {
|
|
@@ -38,7 +45,7 @@ describe('hzl-web server', () => {
|
|
|
38
45
|
db.close();
|
|
39
46
|
});
|
|
40
47
|
function createServer(port, host = '127.0.0.1', allowFraming = false) {
|
|
41
|
-
server = createWebServer({ port, host, allowFraming, taskService, eventStore });
|
|
48
|
+
server = createWebServer({ port, host, allowFraming, taskService, eventStore, searchService });
|
|
42
49
|
return server;
|
|
43
50
|
}
|
|
44
51
|
async function fetchJson(path) {
|
|
@@ -414,6 +421,25 @@ describe('hzl-web server', () => {
|
|
|
414
421
|
expect(status).toBe(404);
|
|
415
422
|
expect(data.error).toContain('not found');
|
|
416
423
|
});
|
|
424
|
+
it('returns blocked_by as objects with task_id and title', async () => {
|
|
425
|
+
const blocker = taskService.createTask({
|
|
426
|
+
title: 'Blocker Task',
|
|
427
|
+
project: 'test-project',
|
|
428
|
+
});
|
|
429
|
+
const blocked = taskService.createTask({
|
|
430
|
+
title: 'Blocked Task',
|
|
431
|
+
project: 'test-project',
|
|
432
|
+
depends_on: [blocker.task_id],
|
|
433
|
+
});
|
|
434
|
+
taskService.setStatus(blocked.task_id, TaskStatus.Ready);
|
|
435
|
+
createServer(4623);
|
|
436
|
+
const { status, data } = await fetchJson(`/api/tasks/${blocked.task_id}`);
|
|
437
|
+
expect(status).toBe(200);
|
|
438
|
+
const task = data.task;
|
|
439
|
+
expect(task.blocked_by).toEqual([
|
|
440
|
+
{ task_id: blocker.task_id, title: 'Blocker Task' },
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
417
443
|
});
|
|
418
444
|
describe('GET /api/tasks/:id/comments', () => {
|
|
419
445
|
it('returns task comments', async () => {
|
|
@@ -571,38 +597,46 @@ describe('hzl-web server', () => {
|
|
|
571
597
|
expect(res.headers.get('content-type') ?? '').toMatch(/text\/event-stream/i);
|
|
572
598
|
await res.body?.cancel();
|
|
573
599
|
});
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
600
|
+
describe('(legacy dashboard)', () => {
|
|
601
|
+
beforeEach(() => { process.env.HZL_LEGACY_DASHBOARD = '1'; });
|
|
602
|
+
afterEach(() => { delete process.env.HZL_LEGACY_DASHBOARD; });
|
|
603
|
+
it('includes EventSource usage and wiring in dashboard HTML', async () => {
|
|
604
|
+
server = createServer(4556);
|
|
605
|
+
const { body } = await fetchText('/');
|
|
606
|
+
const eventSourceInit = body.match(/(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=\s*new\s+EventSource\s*\([\s\S]*?\)/i);
|
|
607
|
+
expect(eventSourceInit).toBeTruthy();
|
|
608
|
+
expect(body).toMatch(/SSE_ENDPOINT\s*=\s*['"]\/api\/events\/stream['"]/i);
|
|
609
|
+
const eventSourceVar = eventSourceInit?.[1];
|
|
610
|
+
expect(eventSourceVar).toBeTruthy();
|
|
611
|
+
const eventSourceWiring = new RegExp(`${eventSourceVar}\\s*\\.\\s*(?:onopen|onmessage|onerror|addEventListener\\s*\\()`, 'i');
|
|
612
|
+
expect(body).toMatch(eventSourceWiring);
|
|
613
|
+
});
|
|
614
|
+
it('checks both visibility and focus before opening SSE connections', async () => {
|
|
615
|
+
server = createServer(4558);
|
|
616
|
+
const { body } = await fetchText('/');
|
|
617
|
+
const connectSetup = body.match(/function\s+connectEventStream\s*\([^)]*\)\s*\{[\s\S]*?if\s*\(\s*!\s*([a-zA-Z_$][\w$]*)\s*\(\s*\)\s*\)\s*\{[\s\S]{0,260}?pauseLiveUpdates\s*\(\s*\)[\s\S]{0,260}?return[\s\S]*?new\s+EventSource\s*\(/i);
|
|
618
|
+
expect(connectSetup).toBeTruthy();
|
|
619
|
+
const liveUpdateGate = connectSetup?.[1];
|
|
620
|
+
expect(liveUpdateGate).toBeTruthy();
|
|
621
|
+
const gateFunctionPattern = new RegExp(`function\\s+${liveUpdateGate}\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?(?:document\\.(?:hidden|visibilityState)[\\s\\S]*?document\\.hasFocus\\s*\\(\\s*\\)|document\\.hasFocus\\s*\\(\\s*\\)[\\s\\S]*?document\\.(?:hidden|visibilityState))`, 'i');
|
|
622
|
+
expect(body).toMatch(gateFunctionPattern);
|
|
623
|
+
});
|
|
624
|
+
it('wires blur/focus/visibilitychange handlers to pause and resume live updates', async () => {
|
|
625
|
+
server = createServer(4559);
|
|
626
|
+
const { body } = await fetchText('/');
|
|
627
|
+
expect(body).toMatch(/(?:window|document)\s*\.\s*addEventListener\s*\(\s*['"]blur['"]\s*,[\s\S]{0,240}?pauseLiveUpdates\s*\(/i);
|
|
628
|
+
expect(body).toMatch(/(?:window|document)\s*\.\s*addEventListener\s*\(\s*['"]focus['"]\s*,[\s\S]{0,240}?resumeLiveUpdates\s*\(/i);
|
|
629
|
+
expect(body).toMatch(/document\s*\.\s*addEventListener\s*\(\s*['"]visibilitychange['"]\s*,[\s\S]{0,500}?pauseLiveUpdates\s*\([\s\S]{0,500}?resumeLiveUpdates\s*\(/i);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
describe('(legacy dashboard polling check)', () => {
|
|
633
|
+
beforeEach(() => { process.env.HZL_LEGACY_DASHBOARD = '1'; });
|
|
634
|
+
afterEach(() => { delete process.env.HZL_LEGACY_DASHBOARD; });
|
|
635
|
+
it('does not rely on setInterval(poll, ...) as the primary update loop', async () => {
|
|
636
|
+
server = createServer(4557);
|
|
637
|
+
const { body } = await fetchText('/');
|
|
638
|
+
expect(body).not.toMatch(/setInterval\s*\(\s*poll\s*,/i);
|
|
639
|
+
});
|
|
606
640
|
});
|
|
607
641
|
});
|
|
608
642
|
describe('GET / (dashboard HTML)', () => {
|
|
@@ -629,15 +663,167 @@ describe('hzl-web server', () => {
|
|
|
629
663
|
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
|
|
630
664
|
expect(res.headers.get('referrer-policy')).toBe('no-referrer');
|
|
631
665
|
});
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
666
|
+
describe('(legacy dashboard)', () => {
|
|
667
|
+
beforeEach(() => { process.env.HZL_LEGACY_DASHBOARD = '1'; });
|
|
668
|
+
afterEach(() => { delete process.env.HZL_LEGACY_DASHBOARD; });
|
|
669
|
+
it('includes favicon and manifest markups in the root HTML', async () => {
|
|
670
|
+
server = createServer(4610);
|
|
671
|
+
const { body } = await fetchText('/');
|
|
672
|
+
expect(body).toMatch(/<link[^>]*rel=["']icon["'][^>]*href=["']\/favicon-96x96\.png["'][^>]*>/i);
|
|
673
|
+
expect(body).not.toMatch(/<link[^>]*rel=["']icon["'][^>]*href=["']\/favicon\.svg["'][^>]*>/i);
|
|
674
|
+
expect(body).toMatch(/<link[^>]*rel=["']shortcut icon["'][^>]*href=["']\/favicon\.ico["'][^>]*>/i);
|
|
675
|
+
expect(body).toMatch(/<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']\/apple-touch-icon\.png["'][^>]*>/i);
|
|
676
|
+
expect(body).toMatch(/<meta[^>]*name=["']apple-mobile-web-app-title["'][^>]*content=["']HZL["'][^>]*>/i);
|
|
677
|
+
expect(body).toMatch(/<link[^>]*rel=["']manifest["'][^>]*href=["']\/site\.webmanifest["'][^>]*>/i);
|
|
678
|
+
});
|
|
679
|
+
it('renders project metadata in the top-right header and assignee metadata in card footer', async () => {
|
|
680
|
+
server = createServer(4590);
|
|
681
|
+
const { body } = await fetchText('/');
|
|
682
|
+
const renderCardBlock = body.match(/function\s+renderCard\s*\([^)]*\)\s*\{[\s\S]*?return\s*`[\s\S]*?`;\s*}/i);
|
|
683
|
+
expect(renderCardBlock).toBeTruthy();
|
|
684
|
+
expect(renderCardBlock?.[0]).toMatch(/const\s+projectHtml\s*=\s*`[\s\S]*class=["']card-project["'][\s\S]*`;/i);
|
|
685
|
+
expect(renderCardBlock?.[0]).toMatch(/const\s+assigneeClass\s*=\s*hasAssignee\s*\?\s*['"][^'"]*\bcard-assignee\b[^'"]*['"]\s*:\s*['"][^'"]*\bcard-assignee\b[^'"]*['"]/i);
|
|
686
|
+
expect(renderCardBlock?.[0]).toMatch(/const\s+assigneeHtml\s*=\s*`[\s\S]*class=["']\$\{assigneeClass\}["'][\s\S]*`;/i);
|
|
687
|
+
expect(renderCardBlock?.[0]).toMatch(/<div[^>]*class=["']card-header-right["'][^>]*>[\s\S]*\$\{projectHtml\}[\s\S]*<\/div>/i);
|
|
688
|
+
expect(renderCardBlock?.[0]).toMatch(/<div[^>]*class=["']card-meta["'][^>]*>[\s\S]*\$\{assigneeHtml\}[\s\S]*<\/div>/i);
|
|
689
|
+
});
|
|
690
|
+
it('truncates card assignee labels to 10 characters plus ellipsis', async () => {
|
|
691
|
+
server = createServer(4598);
|
|
692
|
+
const { body } = await fetchText('/');
|
|
693
|
+
const renderCardBlock = body.match(/function\s+renderCard\s*\([^)]*\)\s*\{[\s\S]*?return\s*`[\s\S]*?`;\s*}/i);
|
|
694
|
+
expect(renderCardBlock).toBeTruthy();
|
|
695
|
+
expect(body).toMatch(/function\s+truncateCardLabel\s*\(value,\s*maxChars\s*=\s*10\)\s*\{/i);
|
|
696
|
+
expect(renderCardBlock?.[0]).toMatch(/const\s+assigneeCardText\s*=\s*truncateCardLabel\(assigneeText,\s*10\)/i);
|
|
697
|
+
expect(renderCardBlock?.[0]).toMatch(/title="\$\{escapeHtml\(assigneeText\)\}"[^>]*>\$\{escapeHtml\(assigneeCardText\)\}<\/span>/i);
|
|
698
|
+
});
|
|
699
|
+
it('binds modal assignee metadata with an Unassigned fallback value', async () => {
|
|
700
|
+
server = createServer(4591);
|
|
701
|
+
const { body } = await fetchText('/');
|
|
702
|
+
const openTaskModalBlock = body.match(/async\s+function\s+openTaskModal\s*\([^)]*\)\s*\{[\s\S]*?let\s+html\s*=\s*`[\s\S]*?`;/i);
|
|
703
|
+
expect(openTaskModalBlock).toBeTruthy();
|
|
704
|
+
expect(openTaskModalBlock?.[0]).toMatch(/const\s+assigneeValue\s*=\s*hasAssignee[\s\S]*<span[^>]*class=["']modal-meta-fallback["'][^>]*>\s*Unassigned\s*<\/span>/i);
|
|
705
|
+
expect(openTaskModalBlock?.[0]).toMatch(/<div[^>]*class=["']modal-meta-label["'][^>]*>\s*Assignee\s*<\/div>[\s\S]*?<div[^>]*class=["']modal-meta-value["'][^>]*>\s*\$\{assigneeValue\}\s*<\/div>/i);
|
|
706
|
+
});
|
|
707
|
+
it('binds modal task id display to task.task_id', async () => {
|
|
708
|
+
server = createServer(4592);
|
|
709
|
+
const { body } = await fetchText('/');
|
|
710
|
+
const hasTaskIdSourceBinding = /data\.task\.task_id/i.test(body);
|
|
711
|
+
const hasModalTaskIdDisplayBinding = /modalTaskIdValue\s*\.\s*textContent\s*=\s*(?:data\.task\.task_id|[a-zA-Z0-9_$]*taskId[a-zA-Z0-9_$]*)/i.test(body);
|
|
712
|
+
expect(hasTaskIdSourceBinding).toBe(true);
|
|
713
|
+
expect(hasModalTaskIdDisplayBinding).toBe(true);
|
|
714
|
+
});
|
|
715
|
+
it('includes a modal copy control for task id', async () => {
|
|
716
|
+
server = createServer(4593);
|
|
717
|
+
const { body } = await fetchText('/');
|
|
718
|
+
expect(body).toMatch(/<div[^>]*class=["']modal-task-id-row["'][^>]*>[\s\S]*id=["']modalTaskIdValue["'][\s\S]*<button[^>]*id=["']modalTaskIdCopy["'][^>]*>[\s\S]*\bcopy\b[\s\S]*<\/button>/i);
|
|
719
|
+
});
|
|
720
|
+
it('includes a copy handler that uses clipboard API and/or execCommand fallback', async () => {
|
|
721
|
+
server = createServer(4594);
|
|
722
|
+
const { body } = await fetchText('/');
|
|
723
|
+
const hasCopyHandlerFunction = /(?:async\s+)?function\s+[a-zA-Z0-9_$]*copy[a-zA-Z0-9_$]*\s*\(/i.test(body) ||
|
|
724
|
+
/(?:const|let)\s+[a-zA-Z0-9_$]*copy[a-zA-Z0-9_$]*\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/i.test(body);
|
|
725
|
+
const hasClipboardOrExecCommand = /navigator\.clipboard\s*\.\s*writeText\s*\(|document\.execCommand\(\s*["']copy["']\s*\)/i.test(body);
|
|
726
|
+
expect(hasCopyHandlerFunction).toBe(true);
|
|
727
|
+
expect(hasClipboardOrExecCommand).toBe(true);
|
|
728
|
+
});
|
|
729
|
+
it('includes assignee filter select with id assigneeFilter', async () => {
|
|
730
|
+
server = createServer(4563);
|
|
731
|
+
const { body } = await fetchText('/');
|
|
732
|
+
expect(body).toMatch(/<select[^>]*id=["']assigneeFilter["'][^>]*>/i);
|
|
733
|
+
});
|
|
734
|
+
it('includes a default assignee option containing Any Agent', async () => {
|
|
735
|
+
server = createServer(4564);
|
|
736
|
+
const { body } = await fetchText('/');
|
|
737
|
+
const assigneeSelect = body.match(/<select[^>]*id=["']assigneeFilter["'][^>]*>([\s\S]*?)<\/select>/i);
|
|
738
|
+
expect(assigneeSelect).toBeTruthy();
|
|
739
|
+
expect(assigneeSelect?.[1]).toMatch(/<option[^>]*>[\s\S]*?Any Agent[\s\S]*?<\/option>/i);
|
|
740
|
+
});
|
|
741
|
+
it('includes script wiring for assignee filter change handling', async () => {
|
|
742
|
+
server = createServer(4565);
|
|
743
|
+
const { body } = await fetchText('/');
|
|
744
|
+
const hasAssigneeReference = /getElementById\(\s*['"]assigneeFilter['"]\s*\)/.test(body) ||
|
|
745
|
+
/querySelector\(\s*['"]#assigneeFilter['"]\s*\)/.test(body) ||
|
|
746
|
+
/assigneeFilter/.test(body);
|
|
747
|
+
const hasAssigneeChangeListener = /assigneeFilter\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body) ||
|
|
748
|
+
/getElementById\(\s*['"]assigneeFilter['"]\s*\)\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body) ||
|
|
749
|
+
/querySelector\(\s*['"]#assigneeFilter['"]\s*\)\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body);
|
|
750
|
+
expect(hasAssigneeReference).toBe(true);
|
|
751
|
+
expect(hasAssigneeChangeListener).toBe(true);
|
|
752
|
+
});
|
|
753
|
+
it('preserves full assignee strings in board and activity filter wiring', async () => {
|
|
754
|
+
server = createServer(4600);
|
|
755
|
+
const { body } = await fetchText('/');
|
|
756
|
+
expect(body).toMatch(/function\s+getAssigneeValue\s*\(value\)\s*\{[\s\S]*value\.trim\(\)\.length\s*>\s*0[\s\S]*\?\s*value\s*:\s*['"]{2}/i);
|
|
757
|
+
expect(body).toMatch(/filtered\s*=\s*filtered\.filter\(\s*task\s*=>\s*getAssigneeValue\(task\.assignee\)\s*===\s*assigneeFilter\.value\s*\)/i);
|
|
758
|
+
expect(body).toMatch(/const\s+assignee\s*=\s*getAssigneeValue\(task\.assignee\);\s*if\s*\(!assignee\)\s*continue;[\s\S]*option\.value\s*=\s*assignee/i);
|
|
759
|
+
expect(body).toMatch(/const\s+taskAssignee\s*=\s*getAssigneeValue\(event\.task_assignee\);\s*if\s*\(taskAssignee\)\s*return\s+taskAssignee;[\s\S]*return\s+getAssigneeValue\(event\.data\?\.assignee\)/i);
|
|
760
|
+
});
|
|
761
|
+
it('includes activity assignee and keyword filter controls', async () => {
|
|
762
|
+
server = createServer(4566);
|
|
763
|
+
const { body } = await fetchText('/');
|
|
764
|
+
expect(body).toMatch(/<select[^>]*id=["']activityAssigneeFilter["'][^>]*>/i);
|
|
765
|
+
expect(body).toMatch(/<input[^>]*id=["']activityKeywordFilter["'][^>]*>/i);
|
|
766
|
+
});
|
|
767
|
+
it('applies activity keyword filtering only at 3+ characters', async () => {
|
|
768
|
+
server = createServer(4567);
|
|
769
|
+
const { body } = await fetchText('/');
|
|
770
|
+
expect(body).toMatch(/keyword\.length\s*>=\s*3/);
|
|
771
|
+
});
|
|
772
|
+
it('includes activity item markup with task id binding attribute', async () => {
|
|
773
|
+
server = createServer(4568);
|
|
774
|
+
const { body } = await fetchText('/');
|
|
775
|
+
expect(body).toMatch(/<div(?=[^>]*\bclass=["'][^"']*\bactivity-item\b[^"']*["'])(?=[^>]*\bdata-task-id\s*=\s*["'][^"']*event\.task_id[^"']*["'])[^>]*>/i);
|
|
776
|
+
});
|
|
777
|
+
it('includes activity list click delegation wired to openTaskModal', async () => {
|
|
778
|
+
server = createServer(4569);
|
|
779
|
+
const { body } = await fetchText('/');
|
|
780
|
+
expect(body).toMatch(/(?:activityList|getElementById\(\s*["']activityList["']\s*\)|querySelector\(\s*["']#activityList["']\s*\))\s*\.\s*addEventListener\(\s*["']click["']\s*,[\s\S]*?closest\(\s*["']\.activity-item["']\s*\)[\s\S]*?openTaskModal\b/i);
|
|
781
|
+
});
|
|
782
|
+
it('shows static Live text with green connection-dot live state when stream is healthy', async () => {
|
|
783
|
+
server = createServer(4598);
|
|
784
|
+
const { body } = await fetchText('/');
|
|
785
|
+
expect(body).toMatch(/\.connection-dot\.live\s*\{[\s\S]*--status-done/i);
|
|
786
|
+
expect(body).toMatch(/connectionText\.textContent\s*=\s*['"]Live['"]/);
|
|
787
|
+
expect(body).not.toMatch(/Live\s*\$\{ago\}s/);
|
|
788
|
+
});
|
|
789
|
+
it('uses hidden-by-default column scrollbars with scroll/touch reveal behavior', async () => {
|
|
790
|
+
server = createServer(4599);
|
|
791
|
+
const { body } = await fetchText('/');
|
|
792
|
+
expect(body).toMatch(/\.column-cards\s*\{[\s\S]*scrollbar-width:\s*none[\s\S]*-ms-overflow-style:\s*none/i);
|
|
793
|
+
expect(body).toMatch(/\.column-cards\.is-scrolling[\s\S]*::\-webkit-scrollbar/i);
|
|
794
|
+
expect(body).toMatch(/function\s+bindColumnScrollIndicators\s*\(/i);
|
|
795
|
+
expect(body).toMatch(/classList\.add\(\s*['"]is-scrolling['"]\s*\)/i);
|
|
796
|
+
});
|
|
797
|
+
it('renders an explicit activity actor/author element in task modal activity entries', async () => {
|
|
798
|
+
server = createServer(4595);
|
|
799
|
+
const { body } = await fetchText('/');
|
|
800
|
+
const activityMarkupBlock = body.match(/displayTaskActivity\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
801
|
+
expect(activityMarkupBlock).toBeTruthy();
|
|
802
|
+
expect(activityMarkupBlock?.[0]).toMatch(/class=["'][^"']*(?:activity|event)[^"']*(?:actor|author)[^"']*["']/i);
|
|
803
|
+
expect(activityMarkupBlock?.[0]).toMatch(/\$\{escapeHtml\(\s*actor\s*\)\}/i);
|
|
804
|
+
});
|
|
805
|
+
it('uses dedicated modal classes for checkpoint and activity author fields (not just .comment-author)', async () => {
|
|
806
|
+
server = createServer(4596);
|
|
807
|
+
const { body } = await fetchText('/');
|
|
808
|
+
const modalMarkupBlock = body.match(/async\s+function\s+openTaskModal\s*\([^)]*\)\s*\{[\s\S]*?modalBody\.innerHTML\s*=\s*html\s*;/i);
|
|
809
|
+
const checkpointMarkupBlock = modalMarkupBlock?.[0].match(/visibleCheckpoints\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
810
|
+
const activityMarkupBlock = modalMarkupBlock?.[0].match(/displayTaskActivity\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
811
|
+
expect(modalMarkupBlock).toBeTruthy();
|
|
812
|
+
expect(checkpointMarkupBlock).toBeTruthy();
|
|
813
|
+
expect(activityMarkupBlock).toBeTruthy();
|
|
814
|
+
expect(checkpointMarkupBlock?.[0]).toMatch(/class=["'][^"']*\b(?:modal-|task-)?(?:checkpoint|cp)-[^"']*["']/i);
|
|
815
|
+
expect(activityMarkupBlock?.[0]).toMatch(/class=["'][^"']*\b(?:modal-|task-)?(?:activity|event)-[^"']*["']/i);
|
|
816
|
+
expect(checkpointMarkupBlock?.[0]).not.toMatch(/\bcomment-author\b/i);
|
|
817
|
+
expect(activityMarkupBlock?.[0]).not.toMatch(/\bcomment-author\b/i);
|
|
818
|
+
});
|
|
819
|
+
it('styles modal checkpoint author/name with dedicated non-accent class rules', async () => {
|
|
820
|
+
server = createServer(4597);
|
|
821
|
+
const { body } = await fetchText('/');
|
|
822
|
+
const checkpointStyleRule = body.match(/\.(?:modal-|task-)?(?:checkpoint|cp)-[\w-]*(?:entry|item|author|name|title|meta)?[\w-]*\s*\{[\s\S]*?\}/i);
|
|
823
|
+
expect(checkpointStyleRule).toBeTruthy();
|
|
824
|
+
expect(checkpointStyleRule?.[0]).toMatch(/(?:color|border(?:-left|-color)?)\s*:/i);
|
|
825
|
+
expect(checkpointStyleRule?.[0]).not.toMatch(/--accent|--status-in-progress|orange/i);
|
|
826
|
+
});
|
|
641
827
|
});
|
|
642
828
|
it('serves site.webmanifest with HZL metadata', async () => {
|
|
643
829
|
server = createServer(4611);
|
|
@@ -685,153 +871,49 @@ describe('hzl-web server', () => {
|
|
|
685
871
|
const res = await globalThis.fetch(`${server.url}/favicon.svg`);
|
|
686
872
|
expect(res.status).toBe(404);
|
|
687
873
|
});
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
expect(
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
expect(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
expect(
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const { body } = await fetchText('/');
|
|
732
|
-
const hasCopyHandlerFunction = /(?:async\s+)?function\s+[a-zA-Z0-9_$]*copy[a-zA-Z0-9_$]*\s*\(/i.test(body) ||
|
|
733
|
-
/(?:const|let)\s+[a-zA-Z0-9_$]*copy[a-zA-Z0-9_$]*\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/i.test(body);
|
|
734
|
-
const hasClipboardOrExecCommand = /navigator\.clipboard\s*\.\s*writeText\s*\(|document\.execCommand\(\s*["']copy["']\s*\)/i.test(body);
|
|
735
|
-
expect(hasCopyHandlerFunction).toBe(true);
|
|
736
|
-
expect(hasClipboardOrExecCommand).toBe(true);
|
|
737
|
-
});
|
|
738
|
-
it('includes assignee filter select with id assigneeFilter', async () => {
|
|
739
|
-
server = createServer(4563);
|
|
740
|
-
const { body } = await fetchText('/');
|
|
741
|
-
expect(body).toMatch(/<select[^>]*id=["']assigneeFilter["'][^>]*>/i);
|
|
742
|
-
});
|
|
743
|
-
it('includes a default assignee option containing Any Agent', async () => {
|
|
744
|
-
server = createServer(4564);
|
|
745
|
-
const { body } = await fetchText('/');
|
|
746
|
-
const assigneeSelect = body.match(/<select[^>]*id=["']assigneeFilter["'][^>]*>([\s\S]*?)<\/select>/i);
|
|
747
|
-
expect(assigneeSelect).toBeTruthy();
|
|
748
|
-
expect(assigneeSelect?.[1]).toMatch(/<option[^>]*>[\s\S]*?Any Agent[\s\S]*?<\/option>/i);
|
|
749
|
-
});
|
|
750
|
-
it('includes script wiring for assignee filter change handling', async () => {
|
|
751
|
-
server = createServer(4565);
|
|
752
|
-
const { body } = await fetchText('/');
|
|
753
|
-
const hasAssigneeReference = /getElementById\(\s*['"]assigneeFilter['"]\s*\)/.test(body) ||
|
|
754
|
-
/querySelector\(\s*['"]#assigneeFilter['"]\s*\)/.test(body) ||
|
|
755
|
-
/assigneeFilter/.test(body);
|
|
756
|
-
const hasAssigneeChangeListener = /assigneeFilter\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body) ||
|
|
757
|
-
/getElementById\(\s*['"]assigneeFilter['"]\s*\)\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body) ||
|
|
758
|
-
/querySelector\(\s*['"]#assigneeFilter['"]\s*\)\s*\.\s*addEventListener\(\s*['"]change['"]/.test(body);
|
|
759
|
-
expect(hasAssigneeReference).toBe(true);
|
|
760
|
-
expect(hasAssigneeChangeListener).toBe(true);
|
|
761
|
-
});
|
|
762
|
-
it('preserves full assignee strings in board and activity filter wiring', async () => {
|
|
763
|
-
server = createServer(4600);
|
|
764
|
-
const { body } = await fetchText('/');
|
|
765
|
-
expect(body).toMatch(/function\s+getAssigneeValue\s*\(value\)\s*\{[\s\S]*value\.trim\(\)\.length\s*>\s*0[\s\S]*\?\s*value\s*:\s*['"]{2}/i);
|
|
766
|
-
expect(body).toMatch(/filtered\s*=\s*filtered\.filter\(\s*task\s*=>\s*getAssigneeValue\(task\.assignee\)\s*===\s*assigneeFilter\.value\s*\)/i);
|
|
767
|
-
expect(body).toMatch(/const\s+assignee\s*=\s*getAssigneeValue\(task\.assignee\);\s*if\s*\(!assignee\)\s*continue;[\s\S]*option\.value\s*=\s*assignee/i);
|
|
768
|
-
expect(body).toMatch(/const\s+taskAssignee\s*=\s*getAssigneeValue\(event\.task_assignee\);\s*if\s*\(taskAssignee\)\s*return\s+taskAssignee;[\s\S]*return\s+getAssigneeValue\(event\.data\?\.assignee\)/i);
|
|
769
|
-
});
|
|
770
|
-
it('includes activity assignee and keyword filter controls', async () => {
|
|
771
|
-
server = createServer(4566);
|
|
772
|
-
const { body } = await fetchText('/');
|
|
773
|
-
expect(body).toMatch(/<select[^>]*id=["']activityAssigneeFilter["'][^>]*>/i);
|
|
774
|
-
expect(body).toMatch(/<input[^>]*id=["']activityKeywordFilter["'][^>]*>/i);
|
|
775
|
-
});
|
|
776
|
-
it('applies activity keyword filtering only at 3+ characters', async () => {
|
|
777
|
-
server = createServer(4567);
|
|
778
|
-
const { body } = await fetchText('/');
|
|
779
|
-
expect(body).toMatch(/keyword\.length\s*>=\s*3/);
|
|
780
|
-
});
|
|
781
|
-
it('includes activity item markup with task id binding attribute', async () => {
|
|
782
|
-
server = createServer(4568);
|
|
783
|
-
const { body } = await fetchText('/');
|
|
784
|
-
expect(body).toMatch(/<div(?=[^>]*\bclass=["'][^"']*\bactivity-item\b[^"']*["'])(?=[^>]*\bdata-task-id\s*=\s*["'][^"']*event\.task_id[^"']*["'])[^>]*>/i);
|
|
785
|
-
});
|
|
786
|
-
it('includes activity list click delegation wired to openTaskModal', async () => {
|
|
787
|
-
server = createServer(4569);
|
|
788
|
-
const { body } = await fetchText('/');
|
|
789
|
-
expect(body).toMatch(/(?:activityList|getElementById\(\s*["']activityList["']\s*\)|querySelector\(\s*["']#activityList["']\s*\))\s*\.\s*addEventListener\(\s*["']click["']\s*,[\s\S]*?closest\(\s*["']\.activity-item["']\s*\)[\s\S]*?openTaskModal\b/i);
|
|
790
|
-
});
|
|
791
|
-
it('shows static Live text with green connection-dot live state when stream is healthy', async () => {
|
|
792
|
-
server = createServer(4598);
|
|
793
|
-
const { body } = await fetchText('/');
|
|
794
|
-
expect(body).toMatch(/\.connection-dot\.live\s*\{[\s\S]*--status-done/i);
|
|
795
|
-
expect(body).toMatch(/connectionText\.textContent\s*=\s*['"]Live['"]/);
|
|
796
|
-
expect(body).not.toMatch(/Live\s*\$\{ago\}s/);
|
|
797
|
-
});
|
|
798
|
-
it('uses hidden-by-default column scrollbars with scroll/touch reveal behavior', async () => {
|
|
799
|
-
server = createServer(4599);
|
|
800
|
-
const { body } = await fetchText('/');
|
|
801
|
-
expect(body).toMatch(/\.column-cards\s*\{[\s\S]*scrollbar-width:\s*none[\s\S]*-ms-overflow-style:\s*none/i);
|
|
802
|
-
expect(body).toMatch(/\.column-cards\.is-scrolling[\s\S]*::\-webkit-scrollbar/i);
|
|
803
|
-
expect(body).toMatch(/function\s+bindColumnScrollIndicators\s*\(/i);
|
|
804
|
-
expect(body).toMatch(/classList\.add\(\s*['"]is-scrolling['"]\s*\)/i);
|
|
805
|
-
});
|
|
806
|
-
it('renders an explicit activity actor/author element in task modal activity entries', async () => {
|
|
807
|
-
server = createServer(4595);
|
|
808
|
-
const { body } = await fetchText('/');
|
|
809
|
-
const activityMarkupBlock = body.match(/displayTaskActivity\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
810
|
-
expect(activityMarkupBlock).toBeTruthy();
|
|
811
|
-
expect(activityMarkupBlock?.[0]).toMatch(/class=["'][^"']*(?:activity|event)[^"']*(?:actor|author)[^"']*["']/i);
|
|
812
|
-
expect(activityMarkupBlock?.[0]).toMatch(/\$\{escapeHtml\(\s*actor\s*\)\}/i);
|
|
813
|
-
});
|
|
814
|
-
it('uses dedicated modal classes for checkpoint and activity author fields (not just .comment-author)', async () => {
|
|
815
|
-
server = createServer(4596);
|
|
816
|
-
const { body } = await fetchText('/');
|
|
817
|
-
const modalMarkupBlock = body.match(/async\s+function\s+openTaskModal\s*\([^)]*\)\s*\{[\s\S]*?modalBody\.innerHTML\s*=\s*html\s*;/i);
|
|
818
|
-
const checkpointMarkupBlock = modalMarkupBlock?.[0].match(/visibleCheckpoints\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
819
|
-
const activityMarkupBlock = modalMarkupBlock?.[0].match(/displayTaskActivity\.map\([\s\S]*?\)\.join\(\s*['"]{2}\s*\)/i);
|
|
820
|
-
expect(modalMarkupBlock).toBeTruthy();
|
|
821
|
-
expect(checkpointMarkupBlock).toBeTruthy();
|
|
822
|
-
expect(activityMarkupBlock).toBeTruthy();
|
|
823
|
-
expect(checkpointMarkupBlock?.[0]).toMatch(/class=["'][^"']*\b(?:modal-|task-)?(?:checkpoint|cp)-[^"']*["']/i);
|
|
824
|
-
expect(activityMarkupBlock?.[0]).toMatch(/class=["'][^"']*\b(?:modal-|task-)?(?:activity|event)-[^"']*["']/i);
|
|
825
|
-
expect(checkpointMarkupBlock?.[0]).not.toMatch(/\bcomment-author\b/i);
|
|
826
|
-
expect(activityMarkupBlock?.[0]).not.toMatch(/\bcomment-author\b/i);
|
|
827
|
-
});
|
|
828
|
-
it('styles modal checkpoint author/name with dedicated non-accent class rules', async () => {
|
|
829
|
-
server = createServer(4597);
|
|
830
|
-
const { body } = await fetchText('/');
|
|
831
|
-
const checkpointStyleRule = body.match(/\.(?:modal-|task-)?(?:checkpoint|cp)-[\w-]*(?:entry|item|author|name|title|meta)?[\w-]*\s*\{[\s\S]*?\}/i);
|
|
832
|
-
expect(checkpointStyleRule).toBeTruthy();
|
|
833
|
-
expect(checkpointStyleRule?.[0]).toMatch(/(?:color|border(?:-left|-color)?)\s*:/i);
|
|
834
|
-
expect(checkpointStyleRule?.[0]).not.toMatch(/--accent|--status-in-progress|orange/i);
|
|
874
|
+
});
|
|
875
|
+
describe('GET /api/search', () => {
|
|
876
|
+
it('returns matching tasks', async () => {
|
|
877
|
+
taskService.createTask({ title: 'Implement authentication', project: 'test-project' });
|
|
878
|
+
taskService.createTask({ title: 'Write documentation', project: 'test-project' });
|
|
879
|
+
createServer(4620);
|
|
880
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
881
|
+
const { status, data } = await fetchJson('/api/search?q=authentication');
|
|
882
|
+
expect(status).toBe(200);
|
|
883
|
+
const body = data;
|
|
884
|
+
expect(body.tasks).toHaveLength(1);
|
|
885
|
+
expect(body.tasks[0].title).toBe('Implement authentication');
|
|
886
|
+
expect(body.total).toBe(1);
|
|
887
|
+
});
|
|
888
|
+
it('returns empty results for missing q param', async () => {
|
|
889
|
+
createServer(4621);
|
|
890
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
891
|
+
const { status, data } = await fetchJson('/api/search');
|
|
892
|
+
expect(status).toBe(200);
|
|
893
|
+
const body = data;
|
|
894
|
+
expect(body.tasks).toHaveLength(0);
|
|
895
|
+
expect(body.total).toBe(0);
|
|
896
|
+
});
|
|
897
|
+
it('supports project filter', async () => {
|
|
898
|
+
taskService.createTask({ title: 'Auth for A', project: 'test-project' });
|
|
899
|
+
projectService.createProject('other-project');
|
|
900
|
+
taskService.createTask({ title: 'Auth for B', project: 'other-project' });
|
|
901
|
+
createServer(4622);
|
|
902
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
903
|
+
const { status, data } = await fetchJson('/api/search?q=Auth&project=test-project');
|
|
904
|
+
expect(status).toBe(200);
|
|
905
|
+
const body = data;
|
|
906
|
+
expect(body.tasks).toHaveLength(1);
|
|
907
|
+
expect(body.tasks[0].project).toBe('test-project');
|
|
908
|
+
});
|
|
909
|
+
it('finds tasks by tag', async () => {
|
|
910
|
+
taskService.createTask({ title: 'Backend work', project: 'test-project', tags: ['api', 'urgent'] });
|
|
911
|
+
createServer(4623);
|
|
912
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
913
|
+
const { status, data } = await fetchJson('/api/search?q=urgent');
|
|
914
|
+
expect(status).toBe(200);
|
|
915
|
+
const body = data;
|
|
916
|
+
expect(body.tasks).toHaveLength(1);
|
|
835
917
|
});
|
|
836
918
|
});
|
|
837
919
|
describe('404 handling', () => {
|
|
@@ -846,6 +928,34 @@ describe('hzl-web server', () => {
|
|
|
846
928
|
expect(data.error).toBe('Not Found');
|
|
847
929
|
});
|
|
848
930
|
});
|
|
931
|
+
describe('tags API', () => {
|
|
932
|
+
it('GET /api/tasks includes tags in response', async () => {
|
|
933
|
+
taskService.createTask({ title: 'Tagged', project: 'test-project', tags: ['bug', 'urgent'] });
|
|
934
|
+
const s = createServer(4620);
|
|
935
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
936
|
+
const { data } = await fetchJson('/api/tasks?since=30d');
|
|
937
|
+
const tasks = data.tasks;
|
|
938
|
+
const found = tasks.find((t) => t.title === 'Tagged');
|
|
939
|
+
expect(found?.tags).toEqual(['bug', 'urgent']);
|
|
940
|
+
});
|
|
941
|
+
it('GET /api/tasks filters by tag', async () => {
|
|
942
|
+
taskService.createTask({ title: 'Bug', project: 'test-project', tags: ['bug'] });
|
|
943
|
+
taskService.createTask({ title: 'Feature', project: 'test-project', tags: ['feature'] });
|
|
944
|
+
const s = createServer(4621);
|
|
945
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
946
|
+
const { data } = await fetchJson('/api/tasks?since=30d&tag=bug');
|
|
947
|
+
const tasks = data.tasks;
|
|
948
|
+
expect(tasks.map((t) => t.title)).toEqual(['Bug']);
|
|
949
|
+
});
|
|
950
|
+
it('GET /api/tags returns tag counts', async () => {
|
|
951
|
+
taskService.createTask({ title: 'A', project: 'test-project', tags: ['bug', 'urgent'] });
|
|
952
|
+
taskService.createTask({ title: 'B', project: 'test-project', tags: ['bug'] });
|
|
953
|
+
const s = createServer(4622);
|
|
954
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
955
|
+
const { data } = await fetchJson('/api/tags');
|
|
956
|
+
expect(data).toEqual({ tags: [{ tag: 'bug', count: 2 }, { tag: 'urgent', count: 1 }] });
|
|
957
|
+
});
|
|
958
|
+
});
|
|
849
959
|
describe('JSON API response format', () => {
|
|
850
960
|
it('does not include CORS headers', async () => {
|
|
851
961
|
createServer(4580);
|