@jskit-ai/assistant-core 0.2.10 → 0.2.12

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.10",
12
- "@jskit-ai/http-runtime": "0.2.4",
13
- "@jskit-ai/kernel": "0.2.4",
11
+ "@jskit-ai/assistant-core": "0.2.12",
12
+ "@jskit-ai/http-runtime": "0.2.5",
13
+ "@jskit-ai/kernel": "0.2.5",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.5",
18
- "@jskit-ai/connectors-catalog": "0.2.5"
17
+ "@jskit-ai/connectors-core": "0.2.6",
18
+ "@jskit-ai/connectors-catalog": "0.2.6"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -5,6 +5,7 @@ import { createAssistantMessageDelivery } from "../../src/client/conversation/me
5
5
 
6
6
  const recoveryFixture = new URLSearchParams(location.search).has("recovery");
7
7
  const acceptedFixture = new URLSearchParams(location.search).has("accepted-history");
8
+ const taskFixture = new URLSearchParams(location.search).has("task");
8
9
  const draft = ref("");
9
10
  const turns = ref([]);
10
11
  const requests = ref([]);
@@ -63,7 +64,10 @@ if (acceptedFixture) {
63
64
  }
64
65
  const adapter = reactive({
65
66
  delivery, attachments,
66
- conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
67
+ conversation: {
68
+ cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation.",
69
+ variant: taskFixture ? "task" : "main"
70
+ },
67
71
  composer: { draft, canSend: true, submitOnEnter: true, queueWhileSending },
68
72
  actions: {
69
73
  setDraft(value) { draft.value = value; },
@@ -9,6 +9,7 @@ import ConversationFixture from "./ConversationFixture.vue";
9
9
  import ProgressFixture from "./ProgressFixture.vue";
10
10
  import DeliveryFixture from "./DeliveryFixture.vue";
11
11
 
12
+ const query = new URLSearchParams(location.search);
12
13
  const vuetify = createVuetify({
13
14
  components,
14
15
  directives,
@@ -18,11 +19,10 @@ const vuetify = createVuetify({
18
19
  sets: { mdi }
19
20
  },
20
21
  theme: {
21
- defaultTheme: "light"
22
+ defaultTheme: query.has("dark") ? "dark" : "light"
22
23
  }
23
24
  });
24
25
 
25
- const query = new URLSearchParams(location.search);
26
26
  createApp(query.has("delivery") ? DeliveryFixture : query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
27
27
  .use(vuetify)
28
28
  .mount("#app");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -51,8 +51,8 @@
51
51
  "@ai-sdk/togetherai": "^3.0.49",
52
52
  "@ai-sdk/xai": "^4.0.58",
53
53
  "@jskit-ai/agent": "0.1.0",
54
- "@jskit-ai/resource-core": "0.1.195",
55
- "@jskit-ai/resource-crud-core": "0.1.195",
54
+ "@jskit-ai/resource-core": "0.1.196",
55
+ "@jskit-ai/resource-crud-core": "0.1.196",
56
56
  "@mdi/js": "^7.4.47",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.4",
65
- "@jskit-ai/kernel": "0.2.4",
64
+ "@jskit-ai/http-runtime": "0.2.5",
65
+ "@jskit-ai/kernel": "0.2.5",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.0.0"
68
68
  },
@@ -248,8 +248,8 @@
248
248
  }
249
249
 
250
250
  .assistant-transcript--task .assistant-transcript__message--user {
251
- background: #eee8f8;
252
- color: #2d2440;
251
+ background: rgba(126, 87, 194, 0.16);
252
+ color: rgb(var(--v-theme-on-surface));
253
253
  }
254
254
 
255
255
  .assistant-transcript__thinking {
@@ -325,13 +325,13 @@
325
325
  }
326
326
 
327
327
  .assistant-transcript__message-header time {
328
- color: #6d7888;
328
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
329
329
  font-weight: 650;
330
330
  }
331
331
 
332
332
  .assistant-transcript__message-footer {
333
333
  align-items: center;
334
- color: #9aa6b6;
334
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
335
335
  display: flex;
336
336
  font-size: 0.88rem;
337
337
  font-weight: 500;
@@ -4,6 +4,7 @@ import { isCompletedEnvelopeTurn } from "./transcript.js";
4
4
  import { ignorePromptRejection, ignorePromptSending } from "./nativeCommandCallbacks.js";
5
5
 
6
6
  // A final answer keeps its identity when an application edits its visible text.
7
+ // A turn's later replies repeat it here; distinctMessages tells them apart.
7
8
  export function conversationMessageIdentity(turnId, message) {
8
9
  return `${turnId}/${message.role}/${["user", "assistant", "system"].includes(message.role)
9
10
  ? "" : message.messageId || message.at}`;
@@ -16,6 +17,24 @@ export function conversationMessageVersion(message, { includeData = false } = {}
16
17
  })).digest("hex");
17
18
  }
18
19
 
20
+ /**
21
+ * Native continuations add later replies to a turn, and those repeat its identity. The first keeps
22
+ * it, because saved cursors and an edited final answer use it, and each later one adds its own id.
23
+ * The original version saved for a repeated identity names only the last reply written, so it is dropped.
24
+ * @param {object[]} messages
25
+ * @returns {object[]}
26
+ */
27
+ function distinctMessages(messages) {
28
+ const repeats = Map.groupBy(messages, message => message.id);
29
+ return messages.map(message => {
30
+ const repeated = repeats.get(message.id);
31
+ if (repeated.length === 1) return message;
32
+ const { originalVersion: _lastWritten, ...distinct } = message;
33
+ const position = repeated.indexOf(message);
34
+ return position ? { ...distinct, id: `${message.id}${message.messageId || position + 1}` } : distinct;
35
+ });
36
+ }
37
+
19
38
  // A changeover whose owner keeps no delivery log.
20
39
  function logNothing() {}
21
40
 
@@ -70,7 +89,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
70
89
  lastEngine: engineId,
71
90
  engines: {
72
91
  [engineId]: {
73
- seen: Object.fromEntries(messages.map((message) => [message.id, message.originalVersion || message.version]))
92
+ seen: Object.fromEntries(distinctMessages(messages).map((message) => [message.id, message.originalVersion || message.version]))
74
93
  }
75
94
  }
76
95
  };
@@ -81,7 +100,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
81
100
  const seen = value.engines[engineId].seen;
82
101
  // New replies from the last engine are already in that native conversation.
83
102
  // Preserve old fingerprints: an edited bubble still needs to be delivered.
84
- for (const message of messages) {
103
+ for (const message of distinctMessages(messages)) {
85
104
  if (message.engineId === engineId && !Object.hasOwn(seen, message.id)) {
86
105
  seen[message.id] = message.originalVersion || message.version;
87
106
  }
@@ -146,16 +165,17 @@ export function createConversationChangeover({ state, transcript, agent, identit
146
165
  code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
147
166
  }
148
167
 
149
- async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
168
+ async function send({ engineId, messages: written, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
150
169
  if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
151
170
  excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
152
171
  new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
153
172
  throw new TypeError("Native history exclusions require a tracked completed envelope and exact message IDs.");
154
173
  }
155
174
  const excluded = new Set(excludedMessageIds);
156
- const value = await readState({ engineId, messages });
175
+ const value = await readState({ engineId, messages: written });
157
176
  const binding = value.engines[engineId] ||= { seen: {} };
158
- rememberReplies(value, engineId, messages);
177
+ rememberReplies(value, engineId, written);
178
+ const messages = distinctMessages(written);
159
179
  const persist = () => state.write(value);
160
180
  async function markDelivered(seen) {
161
181
  const replacement = value.replacement;
@@ -383,7 +403,7 @@ function writtenMessages(history, native = false) {
383
403
  export const conversationNativeMessages = history => writtenMessages(history, true);
384
404
 
385
405
  export function conversationHistoryVersions(history) {
386
- return Object.fromEntries(writtenMessages(history).map(message => [message.id, message.version]));
406
+ return Object.fromEntries(distinctMessages(writtenMessages(history)).map(message => [message.id, message.version]));
387
407
  }
388
408
 
389
409
  export function conversationRequestText({ text, origin, data }) {
@@ -3685,6 +3685,160 @@ test("native history budget resolves only for new catch-up and preserves the ret
3685
3685
  assert.equal(sent[2].message, "Just these words");
3686
3686
  });
3687
3687
 
3688
+ // One saved turn that native continuations can add replies to, delivered through a changeover.
3689
+ async function continuedConversation() {
3690
+ const { createConversationChangeover, conversationNativeMessages, conversationHistoryVersions } = await import("../src/server/conversation/continuity.js");
3691
+ const storage = createMemoryConversationStorage();
3692
+ const transcript = createConversationTranscript({ storage });
3693
+ const { turnId } = await transcript.writeConversationUserMessage("chat", { messageId: "request", text: "Write a report",
3694
+ at: "2026-10-01T01:00:00.000Z", turnMetadata: { runtime: { engine: "claude" } } });
3695
+ let saved = { lastEngine: "claude", engines: { claude: { seen: {} } } };
3696
+ let sends = 0;
3697
+ let prompt = "";
3698
+ const changeover = createConversationChangeover({
3699
+ state: { async read() { return structuredClone(saved); }, async write(next) { saved = structuredClone(next); } },
3700
+ transcript: { async hasMessage() { return false; } },
3701
+ agent: { async sendMessage(input) { prompt = input.message; return { ok: true, delivered: true }; } }
3702
+ });
3703
+ const turns = () => transcript.readConversationLog("chat");
3704
+ let written = 0;
3705
+ return {
3706
+ transcript, turnId, turns,
3707
+ reply: (messageId, text) => transcript.writeConversationAssistantMessage("chat", { messageId, text, turnId,
3708
+ at: `2026-10-01T01:00:0${++written}.000Z` }),
3709
+ edit: (messageId, text) => storage.write("chat", async tx => {
3710
+ const message = (await tx.readTurn(turnId)).messages.find(row => row.messageId === messageId);
3711
+ await tx.appendMessage(turnId, { ...message, text });
3712
+ }),
3713
+ history: async () => conversationNativeMessages(await turns()),
3714
+ versions: async () => conversationHistoryVersions(await turns()),
3715
+ save(value) { saved = value; },
3716
+ seen: (engineId = "claude") => saved.engines[engineId].seen,
3717
+ async send(engineId = "claude", messages) {
3718
+ await changeover.send({ engineId, messages: messages || conversationNativeMessages(await turns()),
3719
+ input: { messageId: `follow-up-${++sends}`, message: "Next" } });
3720
+ return prompt;
3721
+ }
3722
+ };
3723
+ }
3724
+
3725
+ // What a changeover preamble carried: each message's id, text and correction mark, and the removals.
3726
+ function carriedHistory(prompt) {
3727
+ const { messages, removedMessageIds } = JSON.parse(prompt.split("\n").find(line => line.startsWith('{"messages"')));
3728
+ return { messages: messages.map(message => [message.messageId, message.text, message.corrected]), removedMessageIds };
3729
+ }
3730
+
3731
+ test("replies that share a turn are each remembered, so later messages carry no changeover preamble", async () => {
3732
+ const f = await continuedConversation();
3733
+ await f.reply("interim", "The query is still running.");
3734
+ await f.reply("report", "The report.");
3735
+ assert.equal(await f.send(), "Next");
3736
+ assert.equal(await f.send(), "Next");
3737
+ // A native continuation after that delivery is already in the engine's own conversation.
3738
+ await f.reply("late", "The stale helper stopped.");
3739
+ assert.equal(await f.send(), "Next");
3740
+ assert.deepEqual(f.seen(), await f.versions());
3741
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/report", "000001/assistant/late"]);
3742
+ });
3743
+
3744
+ test("replies without message ids keep separate identities in one turn", async () => {
3745
+ const f = await continuedConversation();
3746
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "First", turnId: f.turnId, at: "2026-10-01T01:00:01.000Z" });
3747
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "Second", turnId: f.turnId, at: "2026-10-01T01:00:02.000Z" });
3748
+ assert.equal(await f.send(), "Next");
3749
+ assert.equal(await f.send(), "Next");
3750
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/2"]);
3751
+ });
3752
+
3753
+ test("a corrected reply in a shared turn is delivered alone, and merged replies are reported removed", async () => {
3754
+ const f = await continuedConversation();
3755
+ await f.reply("interim", "The query is still running.");
3756
+ await f.reply("report", "The report.");
3757
+ assert.equal(await f.send(), "Next");
3758
+ await f.edit("report", "The corrected report.");
3759
+ assert.deepEqual(carriedHistory(await f.send()),
3760
+ { messages: [["report", "The corrected report.", true]], removedMessageIds: [] });
3761
+ assert.equal(await f.send(), "Next");
3762
+ await f.edit("interim", "The corrected status.");
3763
+ assert.deepEqual(carriedHistory(await f.send()),
3764
+ { messages: [["interim", "The corrected status.", true]], removedMessageIds: [] });
3765
+ assert.equal(await f.send(), "Next");
3766
+ // An application's edit replaces every reply in the turn and keeps the first one's identity.
3767
+ await f.transcript.upsertConversationAssistantMessage("chat", { turnId: f.turnId, text: "One merged answer." });
3768
+ assert.deepEqual(carriedHistory(await f.send()),
3769
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: ["000001/assistant/report"] });
3770
+ assert.equal(await f.send(), "Next");
3771
+ });
3772
+
3773
+ test("a cursor saved while replies shared one identity delivers one correction, then none", async () => {
3774
+ const f = await continuedConversation();
3775
+ await f.reply("interim", "The query is still running.");
3776
+ await f.reply("report", "The report.");
3777
+ const [user, first, last] = await f.history();
3778
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3779
+ assert.deepEqual(carriedHistory(await f.send()),
3780
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3781
+ assert.deepEqual(f.seen(), await f.versions());
3782
+ assert.equal(await f.send(), "Next");
3783
+ });
3784
+
3785
+ test("a cursor saved with a turn's first reply is already current", async () => {
3786
+ const f = await continuedConversation();
3787
+ await f.reply("interim", "The query is still running.");
3788
+ await f.reply("report", "The report.");
3789
+ const [user, first] = await f.history();
3790
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: first.version } } } });
3791
+ assert.equal(await f.send(), "Next");
3792
+ assert.deepEqual(f.seen(), await f.versions());
3793
+ assert.equal(await f.send(), "Next");
3794
+ });
3795
+
3796
+ test("a cursor saved while replies shared one identity delivers a returning engine's turn once", async () => {
3797
+ const f = await continuedConversation();
3798
+ await f.reply("interim", "The query is still running.");
3799
+ await f.reply("report", "The report.");
3800
+ const [user, first, last] = await f.history();
3801
+ await f.reply("late", "The stale helper stopped.");
3802
+ f.save({ lastEngine: "claude", engines: { codex: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3803
+ assert.deepEqual(carriedHistory(await f.send("codex")), { messages: [
3804
+ ["interim", "The query is still running.", true], ["report", "The report.", undefined],
3805
+ ["late", "The stale helper stopped.", undefined]], removedMessageIds: [] });
3806
+ assert.deepEqual(f.seen("codex"), await f.versions());
3807
+ assert.equal(await f.send("codex"), "Next");
3808
+ });
3809
+
3810
+ test("a cursor saved while replies shared one identity keeps real corrections", async () => {
3811
+ const shared = await continuedConversation();
3812
+ await shared.reply("interim", "The query is still running.");
3813
+ await shared.reply("report", "The report.");
3814
+ const [user, first] = await shared.history();
3815
+ shared.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: "before-the-edit" } } } });
3816
+ assert.deepEqual(carriedHistory(await shared.send()),
3817
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3818
+ assert.equal(await shared.send(), "Next");
3819
+
3820
+ const merged = await continuedConversation();
3821
+ await merged.reply("interim", "The query is still running.");
3822
+ await merged.reply("report", "The report.");
3823
+ const [mergedUser, , reportBeforeEdit] = await merged.history();
3824
+ merged.save({ lastEngine: "claude", engines: { claude: { seen: { [mergedUser.id]: mergedUser.version,
3825
+ [reportBeforeEdit.id]: reportBeforeEdit.version } } } });
3826
+ await merged.transcript.upsertConversationAssistantMessage("chat", { turnId: merged.turnId, text: "One merged answer." });
3827
+ assert.deepEqual(carriedHistory(await merged.send()),
3828
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: [] });
3829
+ assert.equal(await merged.send(), "Next");
3830
+ });
3831
+
3832
+ test("a host's history may repeat a turn's identity and its last written original version", async () => {
3833
+ const f = await continuedConversation();
3834
+ const reply = { id: "000001/assistant/", role: "assistant", engineId: "claude", originalVersion: "report-version" };
3835
+ const messages = [{ ...reply, messageId: "interim", text: "Running.", version: "interim-version" },
3836
+ { ...reply, messageId: "report", text: "The report.", version: "report-version" }];
3837
+ assert.equal(await f.send("claude", messages), "Next");
3838
+ assert.deepEqual(f.seen(), { "000001/assistant/": "interim-version", "000001/assistant/report": "report-version" });
3839
+ assert.equal(await f.send("claude", messages), "Next");
3840
+ });
3841
+
3688
3842
  test("native history budget rejects unavailable dynamic limits before pending delivery", async () => {
3689
3843
  const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3690
3844
  for (const budget of [0, -1, NaN, Infinity, "4096", undefined]) {
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fileURLToPath } from "node:url";
4
+ import { chromium, expect } from "@playwright/test";
5
+ import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
6
+
7
+ // An element's text colour, the theme values it should come from, and its WCAG contrast over what is painted under
8
+ // it: each translucent background is laid over the next one out, down to the first opaque one (white when none is).
9
+ // Runs in the page.
10
+ function readText(element) {
11
+ const channels = (value) => {
12
+ const [red, green, blue, alpha = 1] = value.match(/[\d.]+/gu).map(Number);
13
+ return { red, green, blue, alpha };
14
+ };
15
+ const over = (top, under) => ({
16
+ red: top.red * top.alpha + under.red * (1 - top.alpha),
17
+ green: top.green * top.alpha + under.green * (1 - top.alpha),
18
+ blue: top.blue * top.alpha + under.blue * (1 - top.alpha),
19
+ alpha: 1
20
+ });
21
+ const luminance = ({ red, green, blue }) => {
22
+ const [r, g, b] = [red, green, blue].map((channel) => {
23
+ const value = channel / 255;
24
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
25
+ });
26
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
27
+ };
28
+ const layers = [];
29
+ for (let node = element; node; node = node.parentElement) {
30
+ const background = channels(getComputedStyle(node).backgroundColor);
31
+ if (background.alpha > 0) layers.push(background);
32
+ if (background.alpha === 1) break;
33
+ }
34
+ const backdrop = layers.reverse().reduce((under, layer) => over(layer, under), { red: 255, green: 255, blue: 255, alpha: 1 });
35
+ const style = getComputedStyle(element);
36
+ const color = channels(style.color);
37
+ const [lighter, darker] = [luminance(over(color, backdrop)), luminance(backdrop)].sort((a, b) => b - a);
38
+ return {
39
+ color: [color.red, color.green, color.blue, color.alpha],
40
+ onSurface: style.getPropertyValue("--v-theme-on-surface").split(",").map(Number),
41
+ mediumEmphasis: Number(style.getPropertyValue("--v-medium-emphasis-opacity")),
42
+ contrast: (lighter + 0.05) / (darker + 0.05)
43
+ };
44
+ }
45
+
46
+ test("the task transcript's user bubble, header time and footer follow the light and dark themes", {
47
+ skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
48
+ }, async () => {
49
+ const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
50
+ const browser = await chromium.launch(createChromiumLaunchOptions());
51
+ try {
52
+ for (const theme of ["light", "dark"]) {
53
+ const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
54
+ await page.goto(`${vite.baseURL}/?delivery=1&recovery=1&task=1${theme === "dark" ? "&dark=1" : ""}`);
55
+ await expect(page.locator(".v-application")).toHaveClass(new RegExp(`v-theme--${theme}`, "u"));
56
+ const bubble = page.locator(".assistant-transcript--task .assistant-transcript__message--user").filter({ hasText: "Question one" });
57
+ await expect(bubble).toBeVisible();
58
+ assert.equal(await bubble.evaluate((element) => getComputedStyle(element).backgroundColor), "rgba(126, 87, 194, 0.16)");
59
+ // The transcript's own headers carry no time, so the check places one where an application's header would.
60
+ const header = page.locator(".assistant-transcript__turn .assistant-transcript__message-header").first();
61
+ await header.evaluate((element) => {
62
+ const time = document.createElement("time");
63
+ for (const { name } of element.attributes) {
64
+ if (name.startsWith("data-v-")) time.setAttribute(name, "");
65
+ }
66
+ time.textContent = "06:02";
67
+ element.append(time);
68
+ });
69
+ const samples = {
70
+ "user bubble": [bubble.locator(".assistant-transcript__user-content"), 1],
71
+ "user footer": [bubble.locator(".assistant-transcript__message-footer time"), "medium"],
72
+ "assistant footer": [page.locator(".assistant-transcript__message-footer--assistant time").first(), "medium"],
73
+ "header time": [header.locator("time"), "medium"]
74
+ };
75
+ for (const [name, [locator, alpha]] of Object.entries(samples)) {
76
+ const text = await locator.evaluate(readText);
77
+ assert.deepEqual(text.color.slice(0, 3), text.onSurface, `${theme} ${name}: the colour is the theme's on-surface`);
78
+ assert.equal(text.color[3], alpha === "medium" ? text.mediumEmphasis : alpha, `${theme} ${name}: the opacity`);
79
+ assert.ok(text.contrast >= 4.5, `${theme} ${name}: contrast ${text.contrast.toFixed(2)}:1 is under 4.5:1`);
80
+ }
81
+ await page.close();
82
+ }
83
+ } finally {
84
+ await browser.close();
85
+ await stopProcess(vite);
86
+ }
87
+ });