@jskit-ai/assistant-core 0.2.24 → 0.2.26
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/CHANGELOG.md +6 -1
- package/examples/conversation/package.json +6 -6
- package/package.json +8 -8
- package/src/client/components/AssistantSettingsFormCard.vue +14 -9
- package/src/client/conversation/AssistantAttachmentQueue.vue +2 -4
- package/src/client/conversation/AssistantConversationElement.vue +2 -2
- package/src/client/conversation/AssistantMessageAttachments.vue +2 -2
- package/src/client/conversation/AssistantModelControl.vue +5 -4
- package/src/client/conversation/AssistantPromptInput.vue +24 -18
- package/src/client/conversation/AssistantTranscript.css +7 -15
- package/src/client/conversation/LongTextInlineParts.vue +2 -3
- package/src/client/conversation/LongTextPreviewBlocks.vue +7 -6
- package/src/server/conversation/codexProviderOwner.js +6 -1
- package/src/server/conversation/conversationEvents.js +30 -2
- package/src/server/conversation/conversationHandles.js +1 -1
- package/src/server/lib/providers/openAiCompatibleClient.js +12 -8
- package/test/assistantScroll.browser.test.js +53 -103
- package/test/claudeConversationSession.test.js +16 -1
- package/test/componentContracts.test.js +44 -0
- package/test/conversationAvatarThumbnail.test.js +3 -4
- package/test/conversationSession.test.js +63 -0
- package/test/fixtures/nestedListQuestionMessage.md +19 -19
- package/test/longTextBlocks.test.js +14 -14
- package/test/messageDelivery.browser.test.js +12 -14
- package/test/openAiLoading.test.js +25 -0
- package/test/opencodeConversationSession.test.js +20 -4
- package/test/transcriptTheme.browser.test.js +6 -6
|
@@ -3,16 +3,14 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
createChromiumLaunchOptions,
|
|
9
|
-
startViteFixture,
|
|
10
|
-
stopProcess
|
|
11
|
-
} from "../../../tooling/testUtils/browserFixture.mjs";
|
|
6
|
+
import { expect } from "../../../tooling/testUtils/browserExpect.mjs";
|
|
7
|
+
import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
|
|
12
8
|
|
|
13
9
|
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
14
10
|
const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
|
|
15
11
|
const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
|
|
12
|
+
// Every test reads the same fixture app, so the file shares one server and one Chromium; each test gets its own pages.
|
|
13
|
+
const openFixture = sharedBrowserFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
16
14
|
// Turns use content-visibility estimates. Moving the reader materializes rows
|
|
17
15
|
// near the new position, and Chromium's scroll anchoring then shifts scrollTop
|
|
18
16
|
// to keep the same content in view. Hold a reading position only once that
|
|
@@ -109,9 +107,8 @@ const VIEWPORTS = Object.freeze([
|
|
|
109
107
|
|
|
110
108
|
test("progress previews omit expandable details without hiding the current update", {
|
|
111
109
|
skip: !RUN_BROWSER_TEST, timeout: 60_000
|
|
112
|
-
}, async () => {
|
|
113
|
-
const vite = await
|
|
114
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
110
|
+
}, async (t) => {
|
|
111
|
+
const { vite, browser } = await openFixture(t);
|
|
115
112
|
try {
|
|
116
113
|
for (const width of [390, 800, 1365]) {
|
|
117
114
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -137,15 +134,13 @@ test("progress previews omit expandable details without hiding the current updat
|
|
|
137
134
|
}
|
|
138
135
|
} finally {
|
|
139
136
|
await browser.close();
|
|
140
|
-
await stopProcess(vite);
|
|
141
137
|
}
|
|
142
138
|
});
|
|
143
139
|
|
|
144
140
|
test("saved reply attribution remains when the current assistant changes", {
|
|
145
141
|
skip: !RUN_BROWSER_TEST, timeout: 60_000
|
|
146
|
-
}, async () => {
|
|
147
|
-
const vite = await
|
|
148
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
142
|
+
}, async (t) => {
|
|
143
|
+
const { vite, browser } = await openFixture(t);
|
|
149
144
|
try {
|
|
150
145
|
const page = await browser.newPage();
|
|
151
146
|
await page.goto(`${vite.baseURL}/?conversation=1&attribution=1`);
|
|
@@ -157,15 +152,13 @@ test("saved reply attribution remains when the current assistant changes", {
|
|
|
157
152
|
await expect(names).toHaveText(["First assistant", "Message assistant", "Next assistant"]);
|
|
158
153
|
} finally {
|
|
159
154
|
await browser.close();
|
|
160
|
-
await stopProcess(vite);
|
|
161
155
|
}
|
|
162
156
|
});
|
|
163
157
|
|
|
164
158
|
test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
|
|
165
159
|
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
166
|
-
}, async () => {
|
|
167
|
-
const vite = await
|
|
168
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
160
|
+
}, async (t) => {
|
|
161
|
+
const { vite, browser } = await openFixture(t);
|
|
169
162
|
try {
|
|
170
163
|
for (const width of [390, 800, 1365]) {
|
|
171
164
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -189,16 +182,14 @@ test("streaming answers grow in one bubble, preserve drafts and settle once at e
|
|
|
189
182
|
}
|
|
190
183
|
} finally {
|
|
191
184
|
await browser.close();
|
|
192
|
-
await stopProcess(vite);
|
|
193
185
|
}
|
|
194
186
|
});
|
|
195
187
|
|
|
196
188
|
test("adjacent reasoning stays grouped across storage rows, history pages and execution changes", {
|
|
197
189
|
skip: !RUN_BROWSER_TEST,
|
|
198
190
|
timeout: 90_000
|
|
199
|
-
}, async () => {
|
|
200
|
-
const vite = await
|
|
201
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
191
|
+
}, async (t) => {
|
|
192
|
+
const { vite, browser } = await openFixture(t);
|
|
202
193
|
try {
|
|
203
194
|
for (const width of [390, 800, 1365]) {
|
|
204
195
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -262,7 +253,6 @@ test("adjacent reasoning stays grouped across storage rows, history pages and ex
|
|
|
262
253
|
}
|
|
263
254
|
} finally {
|
|
264
255
|
await browser.close();
|
|
265
|
-
await stopProcess(vite);
|
|
266
256
|
}
|
|
267
257
|
});
|
|
268
258
|
|
|
@@ -286,13 +276,11 @@ test("AssistantConversationElement keeps long conversations scrollable across re
|
|
|
286
276
|
skip: RUN_BROWSER_TEST
|
|
287
277
|
? false
|
|
288
278
|
: "set JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION=1 to run assistant-core browser integration",
|
|
289
|
-
timeout:
|
|
290
|
-
}, async () => {
|
|
291
|
-
const vite = await
|
|
292
|
-
let browser = null;
|
|
279
|
+
timeout: 90_000
|
|
280
|
+
}, async (t) => {
|
|
281
|
+
const { vite, browser } = await openFixture(t);
|
|
293
282
|
|
|
294
283
|
try {
|
|
295
|
-
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
296
284
|
const context = await browser.newContext({
|
|
297
285
|
baseURL: vite.baseURL,
|
|
298
286
|
locale: "en-US"
|
|
@@ -315,16 +303,14 @@ test("AssistantConversationElement keeps long conversations scrollable across re
|
|
|
315
303
|
);
|
|
316
304
|
} finally {
|
|
317
305
|
await browser?.close();
|
|
318
|
-
await stopProcess(vite);
|
|
319
306
|
}
|
|
320
307
|
});
|
|
321
308
|
|
|
322
309
|
test("conversation scrolling preserves history anchors and resets expansion on selection", {
|
|
323
310
|
skip: !RUN_BROWSER_TEST,
|
|
324
311
|
timeout: 60_000
|
|
325
|
-
}, async () => {
|
|
326
|
-
const vite = await
|
|
327
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
312
|
+
}, async (t) => {
|
|
313
|
+
const { vite, browser } = await openFixture(t);
|
|
328
314
|
try {
|
|
329
315
|
const page = await browser.newPage();
|
|
330
316
|
await page.goto(`${vite.baseURL}/?controls=1`);
|
|
@@ -375,16 +361,14 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
375
361
|
assert.equal(await page.evaluate(() => window.revealPlaceholders), 0, "reopening loaded text does not flash a loading placeholder");
|
|
376
362
|
} finally {
|
|
377
363
|
await browser.close();
|
|
378
|
-
await stopProcess(vite);
|
|
379
364
|
}
|
|
380
365
|
});
|
|
381
366
|
|
|
382
367
|
test("a delayed final history page preserves the visible message at every width", {
|
|
383
368
|
skip: !RUN_BROWSER_TEST,
|
|
384
369
|
timeout: 60_000
|
|
385
|
-
}, async () => {
|
|
386
|
-
const vite = await
|
|
387
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
370
|
+
}, async (t) => {
|
|
371
|
+
const { vite, browser } = await openFixture(t);
|
|
388
372
|
try {
|
|
389
373
|
for (const width of [390, 800, 1365]) {
|
|
390
374
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -410,16 +394,14 @@ test("a delayed final history page preserves the visible message at every width"
|
|
|
410
394
|
}
|
|
411
395
|
} finally {
|
|
412
396
|
await browser.close();
|
|
413
|
-
await stopProcess(vite);
|
|
414
397
|
}
|
|
415
398
|
});
|
|
416
399
|
|
|
417
400
|
test("upward scrolling loads older history once and preserves retry and selection behavior", {
|
|
418
401
|
skip: !RUN_BROWSER_TEST,
|
|
419
402
|
timeout: 90_000
|
|
420
|
-
}, async () => {
|
|
421
|
-
const vite = await
|
|
422
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
403
|
+
}, async (t) => {
|
|
404
|
+
const { vite, browser } = await openFixture(t);
|
|
423
405
|
try {
|
|
424
406
|
for (const width of [390, 800, 1365]) {
|
|
425
407
|
const touch = width < 1000;
|
|
@@ -504,16 +486,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
504
486
|
}
|
|
505
487
|
} finally {
|
|
506
488
|
await browser.close();
|
|
507
|
-
await stopProcess(vite);
|
|
508
489
|
}
|
|
509
490
|
});
|
|
510
491
|
|
|
511
492
|
test("a reader's own upward scrolling leaves the latest message from its first frame", {
|
|
512
493
|
skip: !RUN_BROWSER_TEST,
|
|
513
494
|
timeout: 60_000
|
|
514
|
-
}, async () => {
|
|
515
|
-
const vite = await
|
|
516
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
495
|
+
}, async (t) => {
|
|
496
|
+
const { vite, browser } = await openFixture(t);
|
|
517
497
|
try {
|
|
518
498
|
for (const width of [390, 1365]) {
|
|
519
499
|
const touch = width < 1000;
|
|
@@ -580,16 +560,14 @@ test("a reader's own upward scrolling leaves the latest message from its first f
|
|
|
580
560
|
}
|
|
581
561
|
} finally {
|
|
582
562
|
await browser.close();
|
|
583
|
-
await stopProcess(vite);
|
|
584
563
|
}
|
|
585
564
|
});
|
|
586
565
|
|
|
587
566
|
test("a reader moved away from the end without a scroll direction stays there", {
|
|
588
567
|
skip: !RUN_BROWSER_TEST,
|
|
589
568
|
timeout: 60_000
|
|
590
|
-
}, async () => {
|
|
591
|
-
const vite = await
|
|
592
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
569
|
+
}, async (t) => {
|
|
570
|
+
const { vite, browser } = await openFixture(t);
|
|
593
571
|
try {
|
|
594
572
|
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
|
595
573
|
const errors = [];
|
|
@@ -618,16 +596,14 @@ test("a reader moved away from the end without a scroll direction stays there",
|
|
|
618
596
|
assert.deepEqual(errors, []);
|
|
619
597
|
} finally {
|
|
620
598
|
await browser.close();
|
|
621
|
-
await stopProcess(vite);
|
|
622
599
|
}
|
|
623
600
|
});
|
|
624
601
|
|
|
625
602
|
test("retained hidden transcripts preserve readers and follow only at the latest message", {
|
|
626
603
|
skip: !RUN_BROWSER_TEST,
|
|
627
604
|
timeout: 90_000
|
|
628
|
-
}, async () => {
|
|
629
|
-
const vite = await
|
|
630
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
605
|
+
}, async (t) => {
|
|
606
|
+
const { vite, browser } = await openFixture(t);
|
|
631
607
|
try {
|
|
632
608
|
for (const width of [390, 800, 1365]) {
|
|
633
609
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -683,16 +659,14 @@ test("retained hidden transcripts preserve readers and follow only at the latest
|
|
|
683
659
|
}
|
|
684
660
|
} finally {
|
|
685
661
|
await browser.close();
|
|
686
|
-
await stopProcess(vite);
|
|
687
662
|
}
|
|
688
663
|
});
|
|
689
664
|
|
|
690
665
|
test("transient update errors keep the loaded transcript, reader, draft and focus in a compact banner", {
|
|
691
666
|
skip: !RUN_BROWSER_TEST,
|
|
692
667
|
timeout: 90_000
|
|
693
|
-
}, async () => {
|
|
694
|
-
const vite = await
|
|
695
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
668
|
+
}, async (t) => {
|
|
669
|
+
const { vite, browser } = await openFixture(t);
|
|
696
670
|
try {
|
|
697
671
|
for (const width of [390, 800, 1365]) {
|
|
698
672
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -750,16 +724,14 @@ test("transient update errors keep the loaded transcript, reader, draft and focu
|
|
|
750
724
|
}
|
|
751
725
|
} finally {
|
|
752
726
|
await browser.close();
|
|
753
|
-
await stopProcess(vite);
|
|
754
727
|
}
|
|
755
728
|
});
|
|
756
729
|
|
|
757
730
|
test("retained hidden history loads restore their original anchor and invalidate retired requests", {
|
|
758
731
|
skip: !RUN_BROWSER_TEST,
|
|
759
732
|
timeout: 90_000
|
|
760
|
-
}, async () => {
|
|
761
|
-
const vite = await
|
|
762
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
733
|
+
}, async (t) => {
|
|
734
|
+
const { vite, browser } = await openFixture(t);
|
|
763
735
|
try {
|
|
764
736
|
for (const width of [390, 800, 1365]) {
|
|
765
737
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -818,16 +790,14 @@ test("retained hidden history loads restore their original anchor and invalidate
|
|
|
818
790
|
}
|
|
819
791
|
} finally {
|
|
820
792
|
await browser.close();
|
|
821
|
-
await stopProcess(vite);
|
|
822
793
|
}
|
|
823
794
|
});
|
|
824
795
|
|
|
825
796
|
test("conversation composer preserves typing and reacts to external state and pane resizing", {
|
|
826
797
|
skip: !RUN_BROWSER_TEST,
|
|
827
798
|
timeout: 60_000
|
|
828
|
-
}, async () => {
|
|
829
|
-
const vite = await
|
|
830
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
799
|
+
}, async (t) => {
|
|
800
|
+
const { vite, browser } = await openFixture(t);
|
|
831
801
|
try {
|
|
832
802
|
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
|
833
803
|
await page.goto(`${vite.baseURL}/?conversation=1`);
|
|
@@ -872,16 +842,14 @@ test("conversation composer preserves typing and reacts to external state and pa
|
|
|
872
842
|
}
|
|
873
843
|
} finally {
|
|
874
844
|
await browser.close();
|
|
875
|
-
await stopProcess(vite);
|
|
876
845
|
}
|
|
877
846
|
});
|
|
878
847
|
|
|
879
848
|
test("working state appears before output and suggestion agents and activity rendering are replaceable", {
|
|
880
849
|
skip: !RUN_BROWSER_TEST,
|
|
881
850
|
timeout: 60_000
|
|
882
|
-
}, async () => {
|
|
883
|
-
const vite = await
|
|
884
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
851
|
+
}, async (t) => {
|
|
852
|
+
const { vite, browser } = await openFixture(t);
|
|
885
853
|
try {
|
|
886
854
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
|
887
855
|
await page.goto(`${vite.baseURL}/?conversation=1&support=1`);
|
|
@@ -910,7 +878,6 @@ test("working state appears before output and suggestion agents and activity ren
|
|
|
910
878
|
await expect(input).toHaveValue("Keep typing while waiting");
|
|
911
879
|
} finally {
|
|
912
880
|
await browser.close();
|
|
913
|
-
await stopProcess(vite);
|
|
914
881
|
}
|
|
915
882
|
});
|
|
916
883
|
|
|
@@ -918,9 +885,8 @@ test("working state appears before output and suggestion agents and activity ren
|
|
|
918
885
|
test("long goal objectives keep Start reachable on short phone, tablet and desktop screens", {
|
|
919
886
|
skip: !RUN_BROWSER_TEST,
|
|
920
887
|
timeout: 60_000
|
|
921
|
-
}, async () => {
|
|
922
|
-
const vite = await
|
|
923
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
888
|
+
}, async (t) => {
|
|
889
|
+
const { vite, browser } = await openFixture(t);
|
|
924
890
|
try {
|
|
925
891
|
const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
|
|
926
892
|
for (const width of [390, 800, 1280]) {
|
|
@@ -947,16 +913,14 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
|
|
|
947
913
|
}
|
|
948
914
|
} finally {
|
|
949
915
|
await browser.close();
|
|
950
|
-
await stopProcess(vite);
|
|
951
916
|
}
|
|
952
917
|
});
|
|
953
918
|
|
|
954
919
|
test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
|
|
955
920
|
skip: !RUN_BROWSER_TEST,
|
|
956
921
|
timeout: 60_000
|
|
957
|
-
}, async () => {
|
|
958
|
-
const vite = await
|
|
959
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
922
|
+
}, async (t) => {
|
|
923
|
+
const { vite, browser } = await openFixture(t);
|
|
960
924
|
try {
|
|
961
925
|
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
|
962
926
|
await page.clock.install();
|
|
@@ -1035,7 +999,6 @@ test("optional goals show running and paused lights, count only active time, and
|
|
|
1035
999
|
await expect(input).toHaveValue("Preserve this draft and caret");
|
|
1036
1000
|
} finally {
|
|
1037
1001
|
await browser.close();
|
|
1038
|
-
await stopProcess(vite);
|
|
1039
1002
|
}
|
|
1040
1003
|
});
|
|
1041
1004
|
|
|
@@ -1043,9 +1006,8 @@ test("optional goals show running and paused lights, count only active time, and
|
|
|
1043
1006
|
test("shared model choices and file delivery preserve a responsive composer", {
|
|
1044
1007
|
skip: !RUN_BROWSER_TEST,
|
|
1045
1008
|
timeout: 60_000
|
|
1046
|
-
}, async () => {
|
|
1047
|
-
const vite = await
|
|
1048
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1009
|
+
}, async (t) => {
|
|
1010
|
+
const { vite, browser } = await openFixture(t);
|
|
1049
1011
|
try {
|
|
1050
1012
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
|
1051
1013
|
await page.goto(`${vite.baseURL}/?conversation=1&capabilities=1`);
|
|
@@ -1084,15 +1046,13 @@ test("shared model choices and file delivery preserve a responsive composer", {
|
|
|
1084
1046
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
1085
1047
|
} finally {
|
|
1086
1048
|
await browser.close();
|
|
1087
|
-
await stopProcess(vite);
|
|
1088
1049
|
}
|
|
1089
1050
|
});
|
|
1090
1051
|
|
|
1091
1052
|
test("an optional avatar clamps in a short conversation without consuming its preference or draft", {
|
|
1092
1053
|
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
1093
|
-
}, async () => {
|
|
1094
|
-
const vite = await
|
|
1095
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1054
|
+
}, async (t) => {
|
|
1055
|
+
const { vite, browser } = await openFixture(t);
|
|
1096
1056
|
try {
|
|
1097
1057
|
for (const width of [390, 800, 1365]) {
|
|
1098
1058
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -1122,8 +1082,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
1122
1082
|
assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
|
|
1123
1083
|
assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
|
|
1124
1084
|
assert.ok((await input.boundingBox()).height >= 40);
|
|
1125
|
-
|
|
1126
|
-
assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
|
|
1085
|
+
assert.equal(Math.round(avatarButton.width), 40, "Avatar visibility is a 40dp icon button, Show and Minimise alike");
|
|
1127
1086
|
assert.equal(Math.round(avatarButton.height), 40, "Avatar visibility is a 40dp icon button, with no fixed height");
|
|
1128
1087
|
const avatarTarget = await sizeControl.evaluate((element) => Number.parseFloat(window.getComputedStyle(element, "::before").height));
|
|
1129
1088
|
assert.ok(avatarTarget >= 48, "Avatar visibility keeps Material's 48dp touch target");
|
|
@@ -1175,7 +1134,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
1175
1134
|
await expect(artwork).toHaveCount(0);
|
|
1176
1135
|
await expect(sizeControl).toBeVisible();
|
|
1177
1136
|
const showBounds = await sizeControl.boundingBox();
|
|
1178
|
-
assert.equal(showBounds.width,
|
|
1137
|
+
assert.equal(Math.round(showBounds.width), 40, "Collapsed Show is the small icon button, as Minimise is");
|
|
1179
1138
|
assert.equal(Math.round(showBounds.height), 40, "Collapsed Show keeps the small icon button's height");
|
|
1180
1139
|
assert.ok(await sizeControl.evaluate(element => Number.parseFloat(getComputedStyle(element, "::before").height)) >= 48,
|
|
1181
1140
|
"Collapsed Show keeps a 48dp touch target");
|
|
@@ -1249,16 +1208,14 @@ test("an optional avatar clamps in a short conversation without consuming its pr
|
|
|
1249
1208
|
}
|
|
1250
1209
|
} finally {
|
|
1251
1210
|
await browser.close();
|
|
1252
|
-
await stopProcess(vite);
|
|
1253
1211
|
}
|
|
1254
1212
|
});
|
|
1255
1213
|
|
|
1256
1214
|
|
|
1257
1215
|
test("unsent preview edits stay in their bubble and disappear for the canonical message", {
|
|
1258
1216
|
skip: !RUN_BROWSER_TEST, timeout: 60_000
|
|
1259
|
-
}, async () => {
|
|
1260
|
-
const vite = await
|
|
1261
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1217
|
+
}, async (t) => {
|
|
1218
|
+
const { vite, browser } = await openFixture(t);
|
|
1262
1219
|
try {
|
|
1263
1220
|
const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
|
|
1264
1221
|
await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
|
|
@@ -1363,7 +1320,6 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
|
|
|
1363
1320
|
}
|
|
1364
1321
|
} finally {
|
|
1365
1322
|
await browser.close();
|
|
1366
|
-
await stopProcess(vite);
|
|
1367
1323
|
}
|
|
1368
1324
|
});
|
|
1369
1325
|
|
|
@@ -1371,9 +1327,8 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
|
|
|
1371
1327
|
test("pending history keeps following the reader when a new reply changes transcript height", {
|
|
1372
1328
|
skip: !RUN_BROWSER_TEST,
|
|
1373
1329
|
timeout: 90_000
|
|
1374
|
-
}, async () => {
|
|
1375
|
-
const vite = await
|
|
1376
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1330
|
+
}, async (t) => {
|
|
1331
|
+
const { vite, browser } = await openFixture(t);
|
|
1377
1332
|
try {
|
|
1378
1333
|
for (const width of [390, 1365]) {
|
|
1379
1334
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
@@ -1414,16 +1369,14 @@ test("pending history keeps following the reader when a new reply changes transc
|
|
|
1414
1369
|
}
|
|
1415
1370
|
} finally {
|
|
1416
1371
|
await browser.close();
|
|
1417
|
-
await stopProcess(vite);
|
|
1418
1372
|
}
|
|
1419
1373
|
});
|
|
1420
1374
|
|
|
1421
1375
|
|
|
1422
1376
|
test("connection warnings keep avatar controls and Reload reachable without replacing history", {
|
|
1423
1377
|
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
1424
|
-
}, async () => {
|
|
1425
|
-
const vite = await
|
|
1426
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1378
|
+
}, async (t) => {
|
|
1379
|
+
const { vite, browser } = await openFixture(t);
|
|
1427
1380
|
try {
|
|
1428
1381
|
for (const width of [320, 800]) {
|
|
1429
1382
|
const page = await browser.newPage({ viewport: { width, height: 1000 } });
|
|
@@ -1498,16 +1451,14 @@ test("connection warnings keep avatar controls and Reload reachable without repl
|
|
|
1498
1451
|
}
|
|
1499
1452
|
} finally {
|
|
1500
1453
|
await browser.close();
|
|
1501
|
-
await stopProcess(vite);
|
|
1502
1454
|
}
|
|
1503
1455
|
});
|
|
1504
1456
|
|
|
1505
1457
|
|
|
1506
1458
|
test("short connection warnings preserve full text and recovery targets at phone widths", {
|
|
1507
1459
|
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
1508
|
-
}, async () => {
|
|
1509
|
-
const vite = await
|
|
1510
|
-
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1460
|
+
}, async (t) => {
|
|
1461
|
+
const { vite, browser } = await openFixture(t);
|
|
1511
1462
|
try {
|
|
1512
1463
|
for (const width of [320, 390]) {
|
|
1513
1464
|
const page = await browser.newPage({ viewport: { width, height: 768 } });
|
|
@@ -1580,6 +1531,5 @@ test("short connection warnings preserve full text and recovery targets at phone
|
|
|
1580
1531
|
}
|
|
1581
1532
|
} finally {
|
|
1582
1533
|
await browser.close();
|
|
1583
|
-
await stopProcess(vite);
|
|
1584
1534
|
}
|
|
1585
1535
|
});
|
|
@@ -759,8 +759,23 @@ test("Claude cancellation waits for an application effect after closing the nati
|
|
|
759
759
|
});
|
|
760
760
|
|
|
761
761
|
test("the common request deadline stops an unfinished native turn and retains its admitted history", async t => {
|
|
762
|
-
|
|
762
|
+
// The deadline also covers starting the native process and admitting the turn, which took up to 8.4 s (median 1.3 to
|
|
763
|
+
// 2.5 s) with 4 to 8 copies of this test running on an 8-thread host. Rather than a deadline long enough to outlast
|
|
764
|
+
// that, the test holds the request's deadline timer and runs it once the turn is admitted, so it always stops an
|
|
765
|
+
// admitted turn and never waits for the clock.
|
|
766
|
+
const timeoutMs = 10_000;
|
|
767
|
+
const setTimer = globalThis.setTimeout;
|
|
768
|
+
let deadline = null;
|
|
769
|
+
t.mock.method(globalThis, "setTimeout", (callback, delay, ...args) => {
|
|
770
|
+
if (deadline || delay !== timeoutMs) return setTimer(callback, delay, ...args);
|
|
771
|
+
deadline = () => callback(...args);
|
|
772
|
+
return setTimer(() => {}, 0);
|
|
773
|
+
});
|
|
774
|
+
const f = await fixture(t, { limits: { timeoutMs } });
|
|
763
775
|
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
776
|
+
assert.equal(typeof deadline, "function", "The request armed its deadline with the configured time limit");
|
|
777
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1, "The turn was admitted");
|
|
778
|
+
deadline();
|
|
764
779
|
const snapshot = await f.conversation.wait();
|
|
765
780
|
assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
|
|
766
781
|
assert.match(snapshot.error, /time limit/);
|
|
@@ -11,3 +11,47 @@ test("client entry points expose one conversation implementation and retain sett
|
|
|
11
11
|
const conversation = await readFile(new URL("../src/client/conversation/index.js", import.meta.url), "utf8");
|
|
12
12
|
assert.match(conversation, /AssistantConversationElement/);
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
// Material 3: one container at most, the composer an outlined text field, and no heading inside a titled pane.
|
|
16
|
+
test("the transcript, composer and settings card each draw one Material 3 container", async () => {
|
|
17
|
+
const transcript = await readFile(new URL("../src/client/conversation/AssistantTranscript.css", import.meta.url), "utf8");
|
|
18
|
+
const block = (css, selector) => new RegExp(`(?:^|\\n)${selector.replaceAll(".", "\\.")} \\{([^}]*)\\}`, "u").exec(css)?.[1] ?? "";
|
|
19
|
+
assert.doesNotMatch(block(transcript, ".assistant-transcript"), /border/u, "the transcript area is not outlined");
|
|
20
|
+
assert.doesNotMatch(block(transcript, ".assistant-transcript__welcome"), /\bborder:/u, "the welcome bubble is a tonal fill only");
|
|
21
|
+
const prompt = await readFile(new URL("../src/client/conversation/AssistantPromptInput.vue", import.meta.url), "utf8");
|
|
22
|
+
const field = block(prompt, ".assistant-prompt-input__field");
|
|
23
|
+
assert.match(field, /border: 1px solid rgb\(var\(--v-theme-outline\)\);/u);
|
|
24
|
+
assert.match(field, /border-radius: 4px;/u);
|
|
25
|
+
assert.match(block(prompt, ".assistant-prompt-input__footer"), /padding: 0 12px 12px;/u);
|
|
26
|
+
const card = await readFile(new URL("../src/client/components/AssistantSettingsFormCard.vue", import.meta.url), "utf8");
|
|
27
|
+
assert.doesNotMatch(card, /<v-card-title|<v-card-subtitle|<v-divider/u);
|
|
28
|
+
assert.match(card, /:aria-label="props\.title"/u);
|
|
29
|
+
// Given a formId, the page's app bar carries Save: the form takes the id and the card draws no Save of its own.
|
|
30
|
+
assert.match(card, /<v-form :id="props\.formId \|\| undefined"/u);
|
|
31
|
+
assert.match(card, /<div v-if="!props\.formId \|\| !props\.addEdit\.canSave"/u);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Material 3 tonal containers: inside the conversation only the composer, an outlined text field, draws an outline;
|
|
35
|
+
// attachments, questions, code, tables, details and the model menu are surface-container tones.
|
|
36
|
+
test("the conversation's containers are surface-container tones, not outlined boxes", async () => {
|
|
37
|
+
const folder = new URL("../src/client/conversation/", import.meta.url);
|
|
38
|
+
const files = ["AssistantTranscript.css", "AssistantAttachmentQueue.vue", "AssistantMessageAttachments.vue",
|
|
39
|
+
"AssistantModelControl.vue", "LongTextPreviewBlocks.vue", "LongTextInlineParts.vue", "AssistantPromptInput.vue"];
|
|
40
|
+
const outlines = [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const source = await readFile(new URL(file, folder), "utf8");
|
|
43
|
+
for (const [declaration] of source.matchAll(/\bborder(?:-(?:top|bottom|left|right|inline|block)(?:-(?:start|end))?)?: [^;]*\d[^;]*;/gu)) {
|
|
44
|
+
if (!/: 0;/u.test(declaration)) outlines.push(`${file}: ${declaration}`);
|
|
45
|
+
}
|
|
46
|
+
if (file === "AssistantModelControl.vue") {
|
|
47
|
+
assert.doesNotMatch(source, /<v-sheet[^>]*\n\s*border\n/u, "the model menu has no outline");
|
|
48
|
+
assert.match(source, /color="surface-container"\s+:elevation="2"/u);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
assert.deepEqual(outlines, [
|
|
52
|
+
"AssistantModelControl.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
|
|
53
|
+
"AssistantModelControl.vue: border-top: 1px solid rgb(var(--v-theme-outline-variant));",
|
|
54
|
+
"LongTextPreviewBlocks.vue: border-bottom: 1px solid rgb(var(--v-theme-outline-variant));",
|
|
55
|
+
"AssistantPromptInput.vue: border: 1px solid rgb(var(--v-theme-outline));"
|
|
56
|
+
], "only outline-variant dividers and the composer's outlined field draw lines");
|
|
57
|
+
});
|
|
@@ -40,7 +40,6 @@ async function renderShowAvatarButton(element, slots) {
|
|
|
40
40
|
assert.equal(buttons.length, 1, "One Show avatar button while the avatar is hidden");
|
|
41
41
|
const [, attributes, content] = buttons[0];
|
|
42
42
|
assert.match(attributes, /title="jskit.assistant.chat.showAvatar"/u);
|
|
43
|
-
assert.match(attributes, /width="44"/u);
|
|
44
43
|
return content;
|
|
45
44
|
}
|
|
46
45
|
|
|
@@ -61,10 +60,10 @@ test("the hidden avatar's Show button shows the avatar-thumbnail slot, or the ac
|
|
|
61
60
|
assert.doesNotMatch(fallback, /fixture-thumbnail/u);
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
test("the avatar's Show and Minimise buttons set no fixed
|
|
63
|
+
test("the avatar's Show and Minimise buttons set no fixed size, so material-web gives them the 48dp touch target", () => {
|
|
65
64
|
const source = readFileSync(new URL(elementUrl), "utf8");
|
|
66
65
|
const buttons = [...source.matchAll(/<v-btn\b[^>]*>/gu)].map(([tag]) => tag).filter((tag) => tag.includes("assistant-conversation__avatar-size"));
|
|
67
66
|
assert.equal(buttons.length, 2);
|
|
68
|
-
for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?height=/u);
|
|
69
|
-
assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*height/u);
|
|
67
|
+
for (const tag of buttons) assert.doesNotMatch(tag, /(?:^|\s):?(?:min-)?(?:height|width)=/u);
|
|
68
|
+
assert.doesNotMatch(source, /\.assistant-conversation__avatar-size\s*\{[^}]*(?:height|width)/u);
|
|
70
69
|
});
|
|
@@ -75,6 +75,69 @@ test("API runtime persists one admission for concurrent identical sends and stre
|
|
|
75
75
|
assert.equal(f.requests.length, 1);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
// A reply streamed as `deltas` chunks to one subscriber, each written once the one before it
|
|
79
|
+
// was heard, with Date frozen from the send on (a streamed delta reuses a subscriber's
|
|
80
|
+
// authorization while it is under a second old). `during` runs before the reply finishes.
|
|
81
|
+
async function streamedReply(t, deltas, { subscriberAllowed = () => true, during } = {}) {
|
|
82
|
+
const source = new TransformStream();
|
|
83
|
+
const writer = source.writable.getWriter();
|
|
84
|
+
const write = text => writer.write(new TextEncoder().encode(text));
|
|
85
|
+
let subscribeChecks = 0;
|
|
86
|
+
const f = await fixture(t, {
|
|
87
|
+
authorize: ({ operation }) => operation !== "subscribe" || (subscribeChecks += 1, subscriberAllowed()),
|
|
88
|
+
fetch: () => new Response(source.readable, { headers: { "content-type": "text/event-stream" } })
|
|
89
|
+
});
|
|
90
|
+
const heard = [];
|
|
91
|
+
let deltaHeard = Promise.withResolvers();
|
|
92
|
+
await f.conversation.subscribe(event => {
|
|
93
|
+
heard.push(event);
|
|
94
|
+
if (event.type === "message" && event.status === "inProgress") deltaHeard.resolve();
|
|
95
|
+
});
|
|
96
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.now() });
|
|
97
|
+
await f.conversation.send(input);
|
|
98
|
+
for (let index = 0; index < deltas; index += 1) {
|
|
99
|
+
deltaHeard = Promise.withResolvers();
|
|
100
|
+
await write(event(`w${index} `));
|
|
101
|
+
await deltaHeard.promise;
|
|
102
|
+
}
|
|
103
|
+
await during?.({ heard, delta: text => write(event(text)) });
|
|
104
|
+
await write(event("", true));
|
|
105
|
+
await writer.close();
|
|
106
|
+
const result = await f.conversation.wait();
|
|
107
|
+
t.mock.timers.reset();
|
|
108
|
+
return { result, heard, subscribeChecks };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const words = count => Array.from({ length: count }, (_, index) => `w${index}`).join(" ");
|
|
112
|
+
|
|
113
|
+
test("a streamed reply authorizes its subscriber as often for 200 deltas as for 10", async t => {
|
|
114
|
+
const short = await streamedReply(t, 10);
|
|
115
|
+
const long = await streamedReply(t, 200);
|
|
116
|
+
assert.equal(short.result.conversationLog[0].assistant.text.trim(), words(10));
|
|
117
|
+
assert.equal(long.result.conversationLog[0].assistant.text.trim(), words(200));
|
|
118
|
+
assert.equal(long.heard.filter(event => event.type === "message" && event.status === "inProgress").length, 200,
|
|
119
|
+
"The subscriber hears every delta");
|
|
120
|
+
assert.equal(long.heard.at(-1).type, "settled");
|
|
121
|
+
assert.equal(long.subscribeChecks, short.subscribeChecks, "Streamed deltas reuse the subscriber's authorization for a second");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a subscriber whose access is revoked stops hearing a streamed reply within a second", async t => {
|
|
125
|
+
let allowed = true;
|
|
126
|
+
let heardAtRevocation = 0;
|
|
127
|
+
const { result, heard } = await streamedReply(t, 3, {
|
|
128
|
+
subscriberAllowed: () => allowed,
|
|
129
|
+
during: async ({ heard: heardSoFar, delta }) => {
|
|
130
|
+
allowed = false;
|
|
131
|
+
heardAtRevocation = heardSoFar.length;
|
|
132
|
+
t.mock.timers.tick(1000);
|
|
133
|
+
await delta("after revocation");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
assert.equal(result.conversationLog[0].assistant.text, "w0 w1 w2 after revocation", "The turn itself continues");
|
|
137
|
+
assert.ok(heardAtRevocation > 3);
|
|
138
|
+
assert.deepEqual(heard.slice(heardAtRevocation), [], "Nothing reaches the subscriber once its authorization is a second old");
|
|
139
|
+
});
|
|
140
|
+
|
|
78
141
|
test("saved history and current instructions use the existing authorized model connection", async t => {
|
|
79
142
|
const f = await fixture(t);
|
|
80
143
|
await f.conversation.send(input);
|