mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 18:22:39 +00:00
660 lines
22 KiB
TypeScript
660 lines
22 KiB
TypeScript
import { beforeAll, beforeEach, expect, mock, setSystemTime, test, vi } from "bun:test"
|
|
import { createEffect, createRoot } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import type { OpenCodeEvent } from "@opencode/client/promise"
|
|
import type { ServerConnection } from "@/runtime/server/registry"
|
|
import { ServerScope } from "@/runtime/server/scope"
|
|
import type { Tab } from "@/shell/tabs/tabs"
|
|
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
|
|
|
type NotificationDelivery = Parameters<ReturnType<typeof createNotificationCoordinator>["system"]>[1]
|
|
|
|
// SAFETY: this is the key format `ServerConnection.key` produces for a local server.
|
|
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
|
|
|
const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
|
|
|
|
const alerts: string[] = []
|
|
|
|
const sounds: string[] = []
|
|
|
|
const clicks: Array<() => void> = []
|
|
|
|
const selected: string[] = []
|
|
|
|
const desktopStorage = new Map<string, AsyncStorage>()
|
|
|
|
const tabs = {
|
|
store: new Array<Tab>(),
|
|
addSessionTab: (input: { server: ServerConnection.Key; sessionId: string }) => ({
|
|
type: "session" as const,
|
|
...input,
|
|
}),
|
|
rememberSessionRoute: () => {},
|
|
select: (tab: Tab) => {
|
|
if (tab.type === "session") selected.push(tab.sessionId)
|
|
},
|
|
}
|
|
|
|
let createServerNotificationState: typeof import("@/shell/notifications/notification").createServerNotificationState
|
|
|
|
const preferences = {
|
|
autoApprove: false,
|
|
agent: true,
|
|
permissions: true,
|
|
questions: true,
|
|
permissionsSound: true,
|
|
questionsSound: true,
|
|
}
|
|
|
|
// Each test fakes only the parts of the SDK, data store and coordinator that the notification state reads.
|
|
const createState = (input: { sdk: object; data: object; coordinator: object }) =>
|
|
// SAFETY: the fakes cover every member the notification state reaches in that test.
|
|
createServerNotificationState({ key: server, ...input } as never)
|
|
|
|
beforeAll(async () => {
|
|
// Fakes replace only the hooks this test controls. Spreading the real module keeps every other export, such as
|
|
// projectSettingsKey, for the modules the notification state imports.
|
|
const real = {
|
|
platform: await import("@/runtime/platform/platform"),
|
|
settings: await import("@/settings/model"),
|
|
language: await import("@/runtime/i18n/language"),
|
|
tabs: await import("@/shell/tabs/tabs"),
|
|
}
|
|
|
|
mock.module("@/runtime/platform/platform", () => ({
|
|
...real.platform,
|
|
usePlatform: () => ({
|
|
platform: desktopStorage.has("desktop") ? "desktop" : "web",
|
|
windowID: "notification-test",
|
|
storage: () => desktopStorage.get("desktop"),
|
|
notify: async (title: string, _description?: string, onClick?: () => void) => {
|
|
alerts.push(title)
|
|
|
|
if (onClick) clicks.push(onClick)
|
|
},
|
|
}),
|
|
}))
|
|
mock.module("@/runtime/i18n/language", () => ({
|
|
...real.language,
|
|
useLanguage: () => ({ t: (key: string) => key }),
|
|
}))
|
|
mock.module("@/settings/model", () => ({
|
|
...real.settings,
|
|
useSettings: () => ({
|
|
sounds: {
|
|
agentEnabled: () => false,
|
|
errorsEnabled: () => false,
|
|
permissionsEnabled: () => preferences.permissionsSound,
|
|
permissions: () => "alert-01",
|
|
questionsEnabled: () => preferences.questionsSound,
|
|
questions: () => "alert-02",
|
|
},
|
|
notifications: {
|
|
agent: () => preferences.agent,
|
|
errors: () => true,
|
|
permissions: () => preferences.permissions,
|
|
questions: () => preferences.questions,
|
|
},
|
|
permissions: { autoApproves: () => preferences.autoApprove },
|
|
}),
|
|
}))
|
|
mock.module("@/shell/tabs/tabs", () => ({ ...real.tabs, useTabs: () => tabs }))
|
|
mock.module("@/shell/notifications/sound", () => ({
|
|
playSoundById: async (id: string) => {
|
|
sounds.push(id)
|
|
},
|
|
}))
|
|
createServerNotificationState = (await import("@/shell/notifications/notification")).createServerNotificationState
|
|
})
|
|
|
|
beforeEach(() => {
|
|
alerts.length = 0
|
|
sounds.length = 0
|
|
clicks.length = 0
|
|
selected.length = 0
|
|
desktopStorage.clear()
|
|
Object.assign(preferences, {
|
|
autoApprove: false,
|
|
agent: true,
|
|
permissions: true,
|
|
questions: true,
|
|
permissionsSound: true,
|
|
questionsSound: true,
|
|
})
|
|
})
|
|
|
|
const requests = [
|
|
{ kind: "permission", category: "permissions", title: "notification.permission.title" },
|
|
{ kind: "question", category: "questions", title: "notification.question.title" },
|
|
{ kind: "mcp-elicitation", category: "questions", title: "notification.elicitation.title" },
|
|
{ kind: "websearch.provider", category: "permissions", title: "notification.permission.title" },
|
|
] as const
|
|
|
|
test.each(
|
|
requests.flatMap((request) =>
|
|
[
|
|
"enabled",
|
|
"other category disabled",
|
|
"notifications disabled",
|
|
"sound disabled",
|
|
"resolved",
|
|
"auto approve",
|
|
"root tab",
|
|
"no tab",
|
|
"disposed",
|
|
].map((scenario) => ({ ...request, scenario })),
|
|
),
|
|
)("request alerts: $kind / $scenario", async (options) => {
|
|
preferences.agent = false
|
|
preferences.autoApprove = options.scenario === "auto approve"
|
|
preferences[options.category] = options.scenario !== "notifications disabled"
|
|
preferences[options.category === "questions" ? "questionsSound" : "permissionsSound"] =
|
|
options.scenario !== "sound disabled"
|
|
|
|
if (options.scenario === "other category disabled") {
|
|
preferences[options.category === "questions" ? "permissions" : "questions"] = false
|
|
preferences[options.category === "questions" ? "permissionsSound" : "questionsSound"] = false
|
|
}
|
|
|
|
tabs.store =
|
|
options.scenario === "no tab"
|
|
? []
|
|
: [{ type: "session", server, sessionId: options.scenario === "root tab" ? "root" : session.id }]
|
|
const listeners = new Array<(event: OpenCodeEvent) => void>()
|
|
|
|
const deliveries = new Array<NotificationDelivery>()
|
|
|
|
const pending = { value: true }
|
|
|
|
const dispose = createRoot((dispose) => {
|
|
createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: {
|
|
listen: (listener: (event: OpenCodeEvent) => void) => {
|
|
listeners.push(listener)
|
|
|
|
return () => {}
|
|
},
|
|
},
|
|
},
|
|
data: {
|
|
session: {
|
|
get: () => ({ ...session, parentID: "root", projectID: "project-1" }),
|
|
root: () => "root",
|
|
permission: { list: () => (pending.value ? [{ id: "request-1" }] : []) },
|
|
form: { list: () => (pending.value ? [{ id: "request-1" }] : []) },
|
|
},
|
|
},
|
|
coordinator: {
|
|
sound: async (_key: string, run: NotificationDelivery) => {
|
|
deliveries.push(run)
|
|
},
|
|
system: async (_key: string, run: NotificationDelivery) => {
|
|
deliveries.push(run)
|
|
},
|
|
},
|
|
})
|
|
|
|
return dispose
|
|
})
|
|
|
|
try {
|
|
const event: OpenCodeEvent =
|
|
options.kind === "permission"
|
|
? {
|
|
id: "event-1",
|
|
created: 0,
|
|
location: session.location,
|
|
type: "permission.asked",
|
|
data: { sessionID: session.id, id: "request-1", action: "shell", resources: [], metadata: {} },
|
|
}
|
|
: {
|
|
id: "event-1",
|
|
created: 0,
|
|
location: session.location,
|
|
type: "form.created",
|
|
data: {
|
|
form: {
|
|
sessionID: session.id,
|
|
id: "request-1",
|
|
title: "Input",
|
|
fields: [{ key: "answer", type: "string", title: "Continue?" }],
|
|
metadata: { kind: options.kind },
|
|
},
|
|
},
|
|
}
|
|
|
|
listeners.forEach((listener) => listener(event))
|
|
await Promise.resolve()
|
|
|
|
if (options.scenario === "resolved") pending.value = false
|
|
|
|
if (options.scenario === "disposed") dispose()
|
|
await Promise.all(deliveries.map((run) => run()))
|
|
|
|
const suppressed =
|
|
["resolved", "disposed", "no tab"].includes(options.scenario) ||
|
|
(options.scenario === "auto approve" && options.kind === "permission")
|
|
|
|
expect(alerts).toEqual(!suppressed && options.scenario !== "notifications disabled" ? [options.title] : [])
|
|
expect(sounds).toEqual(
|
|
!suppressed && options.scenario !== "sound disabled"
|
|
? [options.category === "questions" ? "alert-02" : "alert-01"]
|
|
: [],
|
|
)
|
|
clicks.forEach((click) => click())
|
|
expect(selected).toEqual(alerts.length ? [options.scenario === "root tab" ? "root" : session.id] : [])
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
|
|
test.each([
|
|
["session.execution.succeeded", "notification.session.responseReady.title"],
|
|
["session.execution.failed", "notification.session.error.title"],
|
|
] as const)("system alert for %s requires an open session tab", async (type, title) => {
|
|
alerts.length = 0
|
|
tabs.store = [{ type: "session", server, sessionId: "another-session" }]
|
|
const listeners = new Array<(event: OpenCodeEvent) => void>()
|
|
|
|
const emit = (id: string) =>
|
|
listeners.forEach((listener) =>
|
|
// SAFETY: the notification state reads only the type, id and session of execution events.
|
|
listener({ type, id, data: { sessionID: session.id, error: { type: "api", message: "failed" } } } as never),
|
|
)
|
|
|
|
const dispose = createRoot((dispose) => {
|
|
createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: {
|
|
listen: (listener: (event: OpenCodeEvent) => void) => {
|
|
listeners.push(listener)
|
|
|
|
return () => {}
|
|
},
|
|
},
|
|
},
|
|
data: { session: { get: () => session, family: () => [session.id], status: () => "idle" } },
|
|
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
|
|
})
|
|
|
|
return dispose
|
|
})
|
|
|
|
emit("event-1")
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(alerts).toEqual([])
|
|
|
|
tabs.store = [{ type: "session", server, sessionId: session.id }]
|
|
emit("event-2")
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(alerts).toEqual([title])
|
|
dispose()
|
|
})
|
|
|
|
test("manual unread edits wait for desktop storage to load", async () => {
|
|
const loaded = Promise.withResolvers<string | null>()
|
|
|
|
const hydrated = Promise.withResolvers<void>()
|
|
|
|
desktopStorage.set("desktop", {
|
|
getItem: (key) => (key === "notification.manual-unread" ? loaded.promise : Promise.resolve(null)),
|
|
setItem: async () => {},
|
|
removeItem: async () => {},
|
|
})
|
|
|
|
const mounted = createRoot((dispose) => {
|
|
const state = createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: { listen: () => () => {} },
|
|
},
|
|
data: {
|
|
session: {
|
|
root: (id: string) => id,
|
|
get: () => ({ id: "desktop-marked", time: { idle: 1, viewed: 1 } }),
|
|
},
|
|
},
|
|
coordinator: {},
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (state.session.ready()) hydrated.resolve()
|
|
})
|
|
|
|
return { dispose, state }
|
|
})
|
|
|
|
try {
|
|
expect(mounted.state.session.ready()).toBe(false)
|
|
mounted.state.session.opened("desktop-marked")
|
|
mounted.state.session.markUnread("desktop-new")
|
|
loaded.resolve(JSON.stringify({ sessions: ["desktop-marked", "desktop-other"] }))
|
|
await hydrated.promise
|
|
expect(mounted.state.session.manuallyUnread("desktop-marked")).toBe(false)
|
|
expect(mounted.state.session.manuallyUnread("desktop-other")).toBe(true)
|
|
expect(mounted.state.session.manuallyUnread("desktop-new")).toBe(true)
|
|
} finally {
|
|
mounted.dispose()
|
|
}
|
|
})
|
|
|
|
test("manual unread marks survive remounts and clear on read, reopen or deletion", () => {
|
|
const views: { sessionID: string; idle: number }[] = []
|
|
|
|
const listeners = new Set<(event: OpenCodeEvent) => void>()
|
|
|
|
const [sessions, setSessions] = createStore({
|
|
marked: { id: "manual-marked", time: { idle: 3, viewed: 3 }, location: { directory: "/manual" } },
|
|
other: { id: "manual-other", time: { idle: 4, viewed: 4 }, location: { directory: "/manual" } },
|
|
})
|
|
|
|
const start = () =>
|
|
createRoot((dispose) => ({
|
|
dispose,
|
|
state: createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: {
|
|
listen: (listener: (event: OpenCodeEvent) => void) => {
|
|
listeners.add(listener)
|
|
|
|
return () => listeners.delete(listener)
|
|
},
|
|
},
|
|
api: {
|
|
session: {
|
|
view: async (input: { sessionID: string; idle: number }) => {
|
|
views.push(input)
|
|
setSessions("marked", "time", "viewed", input.idle)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
data: {
|
|
session: {
|
|
root: (id: string) => (id === "manual-child" ? sessions.marked.id : id),
|
|
get: (id: string) => Object.values(sessions).find((session) => session.id === id),
|
|
list: () => Object.values(sessions),
|
|
},
|
|
},
|
|
coordinator: {},
|
|
}),
|
|
}))
|
|
|
|
vi.useFakeTimers()
|
|
const first = start()
|
|
|
|
try {
|
|
first.state.session.markUnread("manual-child")
|
|
first.state.session.markUnread(sessions.marked.id)
|
|
first.state.session.markUnread(sessions.other.id)
|
|
expect(first.state.session.unread("manual-child")).toBe(true)
|
|
expect(first.state.project.hasUnread("/manual")).toBe(true)
|
|
expect(views).toEqual([])
|
|
vi.runOnlyPendingTimers()
|
|
first.dispose()
|
|
|
|
const second = start()
|
|
|
|
try {
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(true)
|
|
second.state.session.markRead("manual-child")
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(false)
|
|
expect(second.state.session.unread(sessions.other.id)).toBe(true)
|
|
expect(views).toEqual([])
|
|
second.state.session.markUnread(sessions.marked.id)
|
|
setSessions("marked", "time", "idle", 5)
|
|
second.state.session.markViewed("manual-child")
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(true)
|
|
second.state.session.opened("manual-child")
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(false)
|
|
second.state.session.markUnread(sessions.marked.id)
|
|
listeners.forEach((listener) =>
|
|
listener({
|
|
type: "session.deleted",
|
|
id: "manual-deleted",
|
|
created: 0,
|
|
location: sessions.marked.location,
|
|
data: { sessionID: sessions.marked.id },
|
|
}),
|
|
)
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(false)
|
|
second.state.project.markViewed("/manual")
|
|
expect(second.state.project.hasUnread("/manual")).toBe(false)
|
|
|
|
setSessions("marked", "time", "idle", 6)
|
|
second.state.session.markRead(sessions.marked.id)
|
|
expect(views).toEqual([
|
|
{ sessionID: sessions.marked.id, idle: 5 },
|
|
{ sessionID: sessions.marked.id, idle: 6 },
|
|
])
|
|
expect(second.state.session.unread(sessions.marked.id)).toBe(false)
|
|
} finally {
|
|
second.dispose()
|
|
}
|
|
} finally {
|
|
first.dispose()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
test("unread state and viewing follow the server's idle and viewed watermarks", async () => {
|
|
const views: { sessionID: string; idle: number }[] = []
|
|
const failures = { remaining: 1 }
|
|
|
|
type Info = {
|
|
id: string
|
|
parentID?: string
|
|
time: { idle?: number; viewed?: number; archived?: number }
|
|
location: { directory: string }
|
|
}
|
|
|
|
const [store, setStore] = createStore<{ viewing?: string; sessions: Record<string, Info> }>({
|
|
sessions: {
|
|
root: { id: "root", time: { idle: 2, viewed: 1 }, location: { directory: "/project" } },
|
|
child: { id: "child", parentID: "root", time: { idle: 5 }, location: { directory: "/project" } },
|
|
read: { id: "read", time: { idle: 3, viewed: 3 }, location: { directory: "/project" } },
|
|
archived: { id: "archived", time: { idle: 4, archived: 1 }, location: { directory: "/project" } },
|
|
other: { id: "other", time: { idle: 4 }, location: { directory: "/other" } },
|
|
},
|
|
})
|
|
|
|
const dispose = createRoot((dispose) => {
|
|
const state = createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: { listen: () => () => {} },
|
|
api: {
|
|
session: {
|
|
view: async (input: { sessionID: string; idle: number }) => {
|
|
views.push(input)
|
|
|
|
if (failures.remaining-- > 0) throw new Error("offline")
|
|
},
|
|
},
|
|
},
|
|
},
|
|
data: {
|
|
session: {
|
|
get: (id: string) => store.sessions[id],
|
|
root: (id: string) => store.sessions[id]?.parentID ?? id,
|
|
list: () => Object.values(store.sessions),
|
|
},
|
|
},
|
|
coordinator: {},
|
|
})
|
|
|
|
// Mirrors the session route, which re-runs markViewed whenever what it reads changes.
|
|
createEffect(() => {
|
|
if (store.viewing) state.session.markViewed(store.viewing)
|
|
})
|
|
|
|
return { dispose, state }
|
|
})
|
|
|
|
const state = dispose.state
|
|
|
|
// Unread reads the root session; children and archived sessions never mark a project unread.
|
|
expect(["root", "child", "read", "other"].map((id) => state.session.unread(id))).toEqual([true, true, false, true])
|
|
expect(state.project.hasUnread("/project")).toBe(true)
|
|
|
|
// Viewing a child acknowledges its root's idle watermark; a failed report retries after a backoff.
|
|
setStore("viewing", "child")
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(views).toEqual([{ sessionID: "root", idle: 2 }])
|
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
expect(views).toEqual([
|
|
{ sessionID: "root", idle: 2 },
|
|
{ sessionID: "root", idle: 2 },
|
|
])
|
|
|
|
setStore("sessions", "root", "time", "viewed", 2)
|
|
expect(state.session.unread("root")).toBe(false)
|
|
expect(state.project.hasUnread("/project")).toBe(false)
|
|
|
|
// A newer turn while the session stays open makes it unread and reports the new watermark once.
|
|
setStore("sessions", "root", "time", "idle", 6)
|
|
expect(state.session.unread("root")).toBe(true)
|
|
state.project.markViewed("/project")
|
|
expect(views.slice(2)).toEqual([{ sessionID: "root", idle: 6 }])
|
|
|
|
// Menu and Inbox actions mark sessions viewed from event handlers, with nothing to re-run them: a failed report
|
|
// still retries on its own, and a sibling's success does not cancel that retry.
|
|
setStore("viewing", undefined)
|
|
setStore("sessions", "root", "time", "idle", 8)
|
|
failures.remaining = 1
|
|
state.project.markViewed("/project")
|
|
state.project.markViewed("/other")
|
|
expect(views.slice(3)).toEqual([
|
|
{ sessionID: "root", idle: 8 },
|
|
{ sessionID: "other", idle: 4 },
|
|
])
|
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
expect(views.slice(5)).toEqual([{ sessionID: "root", idle: 8 }])
|
|
dispose.dispose()
|
|
})
|
|
|
|
test("unread state honours the old local list until its viewed state is carried to the server, then deletes it", async () => {
|
|
const key = "opencode.global.dat:notification"
|
|
const lists: unknown[] = []
|
|
const views: { sessionID: string; idle: number }[] = []
|
|
const failures = { remaining: 1 }
|
|
|
|
// The old list was found at time 10: sessions that went idle by then were read here unless the list says otherwise.
|
|
vi.useFakeTimers()
|
|
setSystemTime(new Date(10))
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
list: [
|
|
{ type: "turn-complete", time: 1, viewed: true, session: "read-here" },
|
|
{ type: "turn-complete", time: 2, viewed: false, session: "unread-here" },
|
|
],
|
|
}),
|
|
)
|
|
|
|
const sessions = [
|
|
{ id: "read-here", time: { idle: 5 } },
|
|
{ id: "unread-here", time: { idle: 6 } },
|
|
{ id: "viewed", time: { idle: 4, viewed: 4 } },
|
|
{ id: "untracked", time: { idle: 7, viewed: 3 } },
|
|
{ id: "new", time: { idle: 20 } },
|
|
{ id: "never-ran", time: {} },
|
|
]
|
|
|
|
const start = () =>
|
|
createRoot((dispose) => ({
|
|
dispose,
|
|
state: createState({
|
|
sdk: {
|
|
scope: ServerScope.local,
|
|
connection: { status: () => "connected" },
|
|
event: { listen: () => () => {} },
|
|
api: {
|
|
session: {
|
|
list: async (input: { cursor?: string }) => {
|
|
lists.push(input)
|
|
|
|
return input.cursor
|
|
? { data: sessions.slice(3), cursor: {} }
|
|
: { data: sessions.slice(0, 3), cursor: { next: "second" } }
|
|
},
|
|
view: async (input: { sessionID: string; idle: number }) => {
|
|
views.push(input)
|
|
|
|
if (failures.remaining-- > 0) throw new Error("offline")
|
|
},
|
|
},
|
|
},
|
|
},
|
|
data: { session: { get: () => undefined, root: (id: string) => id } },
|
|
coordinator: {},
|
|
}),
|
|
}))
|
|
|
|
// Sessions come from fetched snapshots here, like home rows for sessions that aren't loaded.
|
|
const unread = (state: ReturnType<typeof start>["state"]) =>
|
|
sessions.flatMap((session) =>
|
|
// SAFETY: the snapshots carry the id and time fields unread state reads.
|
|
state.session.unread(session.id, session as never) ? [session.id] : [],
|
|
)
|
|
|
|
// Advances fake time, then lets the mocked requests it started settle.
|
|
const advance = async (ms: number) => {
|
|
vi.advanceTimersByTime(ms)
|
|
|
|
for (const _ of Array.from({ length: 50 })) await Promise.resolve()
|
|
}
|
|
|
|
const first = start()
|
|
// localStorage holds the old list, so it is read at once: nothing read here lights up, while a session that went
|
|
// idle after the list was found is new.
|
|
expect(unread(first.state)).toEqual(["unread-here", "new"])
|
|
|
|
// The carry-over waits for startup to settle, then a failed report keeps the list for the next connection.
|
|
await advance(4_000)
|
|
expect(lists).toEqual([])
|
|
await advance(1_000)
|
|
expect(views).toEqual([{ sessionID: "read-here", idle: 5 }])
|
|
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toMatchObject({ started: 10 })
|
|
first.dispose()
|
|
|
|
// A later start keeps the first start's cutoff, reports every page, then deletes the list without anything flashing.
|
|
setSystemTime(new Date(100))
|
|
lists.length = 0
|
|
views.length = 0
|
|
const second = start()
|
|
await advance(0)
|
|
expect(unread(second.state)).toEqual(["unread-here", "new"])
|
|
await advance(5_000)
|
|
expect(lists).toEqual([
|
|
{ limit: 200, order: "desc", parentID: null },
|
|
{ limit: 200, order: "desc", parentID: null, cursor: "second" },
|
|
])
|
|
expect(views).toEqual([
|
|
{ sessionID: "read-here", idle: 5 },
|
|
{ sessionID: "untracked", idle: 7 },
|
|
])
|
|
expect(localStorage.getItem(key)).toBeNull()
|
|
expect(unread(second.state)).toEqual(["unread-here", "new"])
|
|
second.dispose()
|
|
|
|
// Without the old list, later starts never touch the server's viewed state.
|
|
const third = start()
|
|
await advance(5_000)
|
|
expect(lists).toHaveLength(2)
|
|
expect(views).toHaveLength(2)
|
|
third.dispose()
|
|
vi.useRealTimers()
|
|
setSystemTime()
|
|
})
|