Language and theme follow the account: PATCH /me for the theme, /me's language by default
ci / ui (push) Waiting to run
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s

The server's /api/v2 now has PATCH /me {language, theme} and /me lists its languages and
themes. The theme is the account's and choosing one saves it there (a refusal is said);
a server whose /me lists no themes keeps the earlier choice in the app. The language is the
account's unless Settings chose this computer's or another (kept in the app, as the phone
does); a change of the account's opens the window again in it, once.

Fixtures re-pinned to the server's dev (f7f2ff2), with the new /api/v2 parity ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:19:44 +00:00
1 parent 151fc5fcca
commit 04736db057
23 files changed
+783 -84

No files matched your search

+1 -1
View File
@@ -1 +1 @@
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2
+28
View File
@@ -0,0 +1,28 @@
{
"id": "chat1",
"kind": "chat",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": false,
"mode": "",
"effort": "low",
"device": null,
"device_session_id": null,
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": {
"offered": true,
"named_only": false,
"cap": 32
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
+29
View File
@@ -0,0 +1,29 @@
{
"chats": [
{
"id": "chat1",
"kind": "chat",
"title": "Waybread",
"model": "qwen",
"folder_id": null,
"pinned": false,
"archived": false,
"temporary": false,
"unread": true,
"running": false,
"mode": "",
"effort": "low",
"device": null,
"device_session_id": null,
"parent_chat_id": null,
"agents": {
"min": 1,
"max": 4
},
"subagents": null,
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
],
"more": false
}
+109
View File
@@ -0,0 +1,109 @@
{
"version": "d2f307bf1c7b8d6b",
"schedules": [
{
"id": "sched1",
"title": "Waybread check",
"instruction": "Check the waybread is wrapped.",
"request": "every second Monday at 7:30",
"rule": {
"at": {
"nth": [
{
"n": 2,
"weekday": 0
}
],
"times": [
"07:30"
]
}
},
"description": "On the second Monday of each month at 07:30",
"timezone": "UTC",
"enabled": true,
"next_run": "2026-01-12T07:30:00+00:00",
"next_runs": [
"2026-01-12T07:30:00+00:00",
"2026-02-09T07:30:00+00:00",
"2026-03-09T07:30:00+00:00",
"2026-04-13T07:30:00+00:00",
"2026-05-11T07:30:00+00:00"
],
"target": "chat",
"mode": "fresh",
"notify": "matters",
"catch_up": "one",
"grace_minutes": null,
"model": "qwen",
"effort": "high",
"agents": null,
"tools": [
"files",
"recall",
"search",
"fetch",
"memory",
"skills",
"plan"
],
"origin": "user",
"chat_id": "chat1",
"fired_count": 3,
"failure_streak": 0,
"watch": null,
"last_error": null,
"last_run": {
"id": "run1",
"schedule_id": "sched1",
"status": "ok",
"timing": "late",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": "2026-01-02T03:04:05+00:00",
"finished_at": "2026-01-02T03:04:05+00:00",
"waiting_for": null,
"retry_at": null,
"progress": null,
"attempts": 1,
"model": {
"id": "gpt-oss",
"name": "GPT-OSS",
"reason": "switched",
"switched_from": "Qwen"
},
"tokens": {
"prompt": 1200,
"completion": 340
},
"cost": null,
"chat_id": "chat1",
"message_id": "msg2",
"report_path": null,
"preview": "All the waybread is wrapped.",
"error": null,
"notified": true,
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"watch": null
},
"running": false,
"created_at": "2026-01-02T03:04:05+00:00"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"path": "/home/frodo",
"parent": "/home",
"entries": [
{
"name": "recipes",
"path": "/home/frodo/recipes",
"kind": "dir",
"size": 0,
"mime": "",
"updated_at": "2026-01-02T03:04:05+00:00"
},
{
"name": "lembas.md",
"path": "/home/frodo/lembas.md",
"kind": "file",
"size": 27,
"mime": "text/markdown",
"updated_at": "2026-01-02T03:04:05+00:00"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"folder": {
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"folders": [
{
"id": "fold1",
"name": "Waybread",
"parent_id": null,
"kind": "",
"position": 0,
"collapsed": false
},
{
"id": "fold2",
"name": "Recipes",
"parent_id": "fold1",
"kind": "chat",
"position": 1,
"collapsed": true
}
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"api": 2,
"version": "2.0.0",
"account": {
"id": "user1",
"name": "Frodo",
"email": "frodo@shire.test"
},
"language": "sk",
"theme": "shire",
"default_model": "qwen",
"permissions": {
"chat.create": true,
"chat.delete": false,
"chat.model_select": true,
"chat.system_prompt": true,
"chat.params": true,
"files.upload": true,
"tools.agent": true,
"audio.transcribe": true,
"audio.listen": true
},
"services": {
"stt": true,
"tts": false,
"search": true,
"fetch": true,
"images": false
},
"scopes": [
"app",
"models"
],
"language_setting": "sk",
"languages": [
{
"id": "en",
"name": "English"
},
{
"id": "sk",
"name": "Slovenčina"
}
],
"themes": [
{
"id": "moria",
"label": "Moria",
"scheme": "dark"
},
{
"id": "shire",
"label": "Shire",
"scheme": "light"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"memories": [
{
"id": "mem1",
"content": "Prefers lembas warm.",
"author": "user",
"data_group": {
"id": "default",
"name": "Default"
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
],
"groups": [
{
"id": "default",
"name": "Default"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"memory": {
"id": "mem1",
"content": "Prefers lembas warm.",
"author": "user",
"data_group": {
"id": "default",
"name": "Default"
},
"created_at": "2026-01-02T03:04:05+00:00",
"updated_at": "2026-01-02T03:04:05+00:00"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"schedule_id": "sched1",
"run_id": "run1",
"chat_id": "chat1",
"status": "ok",
"visibility": "private",
"actions": [
"open",
"run_again"
],
"ask": "",
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"pause_label": "Pause",
"title": "LLeMbas",
"body": "A scheduled task has finished.",
"url": "/scheduled/sched1?run=run1",
"kind": "schedule"
}
@@ -0,0 +1,37 @@
{
"schedule_id": "sched1",
"run_id": "run3",
"chat_id": "chat1",
"status": "failed",
"visibility": "private",
"actions": [
"open",
"run_again",
"pause"
],
"ask": "pause",
"notification": {
"title": "Waybread check",
"body": "Failed 3 times in a row. Pause it?",
"public": {
"title": "Waybread check",
"body": "Failed"
},
"actions": [
"open",
"run_again",
"pause"
],
"labels": {
"open": "Open",
"run_again": "Run again",
"pause": "Pause"
},
"ask": "pause"
},
"pause_label": "Pause",
"title": "LLeMbas",
"body": "A scheduled task failed 3 times in a row. Pause it?",
"url": "/scheduled/sched1?run=run3",
"kind": "schedule_failed"
}
+52
View File
@@ -0,0 +1,52 @@
{
"run": {
"id": "run1",
"schedule_id": "sched1",
"status": "ok",
"timing": "late",
"missed": 0,
"due_at": "2026-01-02T03:04:05+00:00",
"started_at": "2026-01-02T03:04:05+00:00",
"finished_at": "2026-01-02T03:04:05+00:00",
"waiting_for": null,
"retry_at": null,
"progress": null,
"attempts": 1,
"model": {
"id": "gpt-oss",
"name": "GPT-OSS",
"reason": "switched",
"switched_from": "Qwen"
},
"tokens": {
"prompt": 1200,
"completion": 340
},
"cost": null,
"chat_id": "chat1",
"message_id": "msg2",
"report_path": null,
"preview": "All the waybread is wrapped.",
"error": null,
"notified": true,
"notification": {
"title": "Waybread check",
"body": "All the waybread is wrapped.",
"public": {
"title": "Waybread check",
"body": "Finished"
},
"actions": [
"open",
"run_again"
],
"labels": {
"open": "Open",
"run_again": "Run again"
},
"ask": ""
},
"watch": null
},
"text": "All the waybread is *wrapped*."
}
+26
View File
@@ -0,0 +1,26 @@
{
"scope": {
"families": [
{
"gate": "notes",
"label": "Notes",
"on": true
},
{
"gate": "web",
"label": "Web search",
"on": false
}
],
"skills": [
{
"name": "baking",
"description": "Bake waybread.",
"on": true
}
],
"allow": [
"bash:ls *"
]
}
}
+69
View File
@@ -0,0 +1,69 @@
{
"q": "lembas",
"in": "all",
"offered": [
"chats",
"files",
"memories"
],
"results": [
{
"kind": "chats",
"more": true,
"hits": [
{
"kind": "chat",
"title": "Waybread",
"snippet": "…how long does lembas keep & travel?",
"marks": [
[
15,
21
]
],
"archived": false,
"id": "chat1",
"agent": false
}
]
},
{
"kind": "files",
"more": false,
"hits": [
{
"kind": "file",
"title": "bread.md",
"snippet": "/home/frodo/bread.md — Lembas recipe",
"marks": [
[
23,
29
]
],
"archived": false,
"path": "/home/frodo/bread.md"
}
]
},
{
"kind": "memories",
"more": false,
"hits": [
{
"kind": "memory",
"id": "mem1",
"title": "",
"snippet": "Prefers lembas warm.",
"marks": [
[
8,
14
]
],
"archived": false
}
]
}
]
}
+54 -22
View File
@@ -994,57 +994,89 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th
assert.deepEqual(p.errors, [])
})
// E5: the language and the theme, chosen in Settings and kept in this app.
// E5: the language and the theme. The server's /me (fixture me.json) offers its themes and takes
// one on PATCH /me; its language is the app's unless Settings chose this computer's or another.
const ME = fixture("me")
const meAs = (over = {}) => ({ ...ME, account: { id: "u1", name: "Ada Example", email: "ada@example.org" }, permissions: {}, ...over })
async function openSettings(p) {
await p.click("#settings-btn")
await p.waitForSelector("#set-look")
}
test("E5: the theme is the account's until one is chosen here; chosen, it is drawn at once and kept", async () => {
test("E5: the theme is the account's, as /me offers them; chosen, PATCH /me saves it and it is drawn at once", async () => {
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": meAs({ language: "en", theme: "moria" }) }), streams: {} })
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
await openSettings(p)
assert.equal(await p.inputValue("#set-theme"), "shire")
assert.deepEqual(await p.$$eval("#set-theme option", (o) => o.map((x) => x.textContent)), ["Moria — dark", "Shire — light"])
await p.selectOption("#set-theme", "moria")
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
const [saved] = await p.evaluate(() => window.__mock.api("PATCH", "/me"))
assert.deepEqual(saved.args.body, { theme: "moria" })
const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name))
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
assert.equal(await p.inputValue("#set-theme"), "moria")
assert.deepEqual(p.errors, [])
})
test("E5: a theme the server refuses is said, and the look stays", async () => {
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": meAs({ language: "en" }), "PATCH /me": { __error: { status: 400, detail: "That theme is not offered." } } }), streams: {} })
await openSettings(p)
await p.selectOption("#set-theme", "moria")
await p.waitForSelector("#set-look [role=alert]")
assert.match(await p.textContent("#set-look [role=alert]"), /not offered/)
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire")
assert.equal(await p.inputValue("#set-theme"), "shire")
assert.deepEqual(p.errors, [])
})
test("E5: a server that cannot be told: the theme chosen is kept in the app, over the account's", async () => {
const p = await page({ status: status(), routes: baseRoutes({ "GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "shire", language: "en", permissions: {} } }), streams: {} })
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
await openSettings(p)
assert.equal(await p.inputValue("#set-theme"), "")
assert.match(await p.textContent("#set-theme option[value='']"), /account.*Shire/)
await p.selectOption("#set-theme", "moria")
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
const painted = await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "set_theme").map((c) => c.args.name))
assert.equal(painted.at(-1), "moria", "the window is painted in it too")
// Opened again: still Moria, over the account's Shire.
await p.waitForFunction(() => document.documentElement.dataset.theme === "moria")
assert.equal(await p.evaluate(() => window.__mock.api("PATCH", "/me").length), 0, "nothing sent")
await p.reload()
await p.waitForSelector("#shell:not([hidden])")
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "set_theme"))
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "moria")
await openSettings(p)
assert.equal(await p.inputValue("#set-theme"), "moria")
await p.selectOption("#set-theme", "")
assert.equal(await p.evaluate(() => document.documentElement.dataset.theme), "shire", "back to the account's")
await p.waitForFunction(() => document.documentElement.dataset.theme === "shire")
assert.deepEqual(p.errors, [])
})
test("E5: a language chosen here opens the window again in it, the shells let go first; the system's again undoes it", async () => {
const p = await page(b3())
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
test("E5: the account's language is the app's; this computer's or another, chosen, opens the window again in it, the shells let go first", async () => {
const p = await page({ ...b3(), routes: { ...b3().routes, "GET /me": meAs({ language: "de", theme: "moria" }) } })
// The account says German: the window opens again in it, once.
await p.waitForFunction(() => document.documentElement.lang === "de", null, { timeout: 15000 })
await p.waitForSelector("#shell:not([hidden])")
assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen")
await openChatRow(p, "chat2")
await p.click('#chat-tools [data-terminal-toggle]')
await p.waitForFunction(() => window.__mock.terminals().length === 1)
// What the page let go of before it loaded again, kept across the load.
await p.evaluate(() => addEventListener("beforeunload", () => localStorage.setItem("test.calls", JSON.stringify(window.__mock.calls.map((c) => c.cmd)))))
await openSettings(p)
assert.match(await p.textContent("#set-language option[value='']"), /English/)
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "de")])
await p.waitForSelector("#shell:not([hidden])")
assert.equal(await p.evaluate(() => document.documentElement.lang), "de")
assert.equal((await p.textContent("#settings-btn .navrow__label")).trim(), "Einstellungen")
const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]"))
assert.ok(before.includes("terminal_close"), "the shell was let go")
await openSettings(p)
assert.equal(await p.inputValue("#set-language"), "de")
assert.match(await p.textContent("#set-look"), /Sprache/)
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "")])
assert.equal(await p.inputValue("#set-language"), "")
assert.match(await p.textContent("#set-language option[value='']"), /Deutsch/)
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "system")])
await p.waitForSelector("#shell:not([hidden])")
assert.equal(await p.evaluate(() => document.documentElement.lang), "en")
const before = await p.evaluate(() => JSON.parse(localStorage.getItem("test.calls") || "[]"))
assert.ok(before.includes("terminal_close"), "the shell was let go")
await p.waitForTimeout(300)
assert.equal(await p.evaluate(() => document.documentElement.lang), "en", "the account's German does not take it back")
await openSettings(p)
assert.equal(await p.inputValue("#set-language"), "system")
await Promise.all([p.waitForEvent("load"), p.selectOption("#set-language", "fr")])
await p.waitForSelector("#shell:not([hidden])")
assert.equal(await p.evaluate(() => document.documentElement.lang), "fr")
assert.deepEqual(p.errors, [])
})