mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
perf(ui): lazy-load the lobster pet out of the Control UI startup graph (#110628)
* perf(ui): lazy-load the lobster pet out of the Control UI startup graph Keep the lightweight lobster contract on the sidebar's static path while idle-loading the heavy pet implementation. Move the logo stand-in custom element into the pet module so both decorative elements upgrade together after the lazy import. * style(ui): format lobster-pet lazy boundary * fix(ui): clear the lobster-pet chunk cache on load failure A rejected import stayed cached forever and surfaced as an unhandled rejection; clearing it lets a later scheduling call retry. * fix(ui): retry the lobster-pet chunk once connectivity returns
This commit is contained in:
committed by
GitHub
parent
efa7e1a85a
commit
60cb6d78de
@@ -22,20 +22,40 @@ import { AppSidebarSessionListElement } from "./app-sidebar-session-list.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
LOBSTER_PET_BUILD_MULS,
|
||||
LOBSTER_PET_CLAW_MULS,
|
||||
lobsterPetSeed,
|
||||
renderLobsterSvg,
|
||||
resolveLobsterPetMode,
|
||||
resolveLobsterRunOutcome,
|
||||
type LobsterLogoVisitDetail,
|
||||
} from "./lobster-pet.ts";
|
||||
} from "./lobster-pet-contract.ts";
|
||||
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
const OFFLINE_INDICATOR_DELAY_MS = 2_000;
|
||||
|
||||
let lobsterPetModuleLoad: Promise<unknown> | null = null;
|
||||
|
||||
function scheduleLobsterPetLoad() {
|
||||
if (lobsterPetModuleLoad || customElements.get("openclaw-lobster-pet")) {
|
||||
return;
|
||||
}
|
||||
const start = () => {
|
||||
// A failed chunk fetch must not pin a rejected promise forever: clear the
|
||||
// cache and retry when connectivity returns. The sidebar mounts once per
|
||||
// page, so without this a transient failure would disable the pet for the
|
||||
// whole session; a deploy-pruned chunk stays off until reload, by design.
|
||||
lobsterPetModuleLoad ??= import("./lobster-pet.ts").catch(() => {
|
||||
lobsterPetModuleLoad = null;
|
||||
window.addEventListener("online", () => start(), { once: true });
|
||||
});
|
||||
};
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => start(), { timeout: 3000 });
|
||||
} else {
|
||||
setTimeout(start, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
class AppSidebar extends AppSidebarSessionListElement {
|
||||
@state() private logoVisit: LobsterLogoVisitDetail | null = null;
|
||||
@state() private debouncedDisconnected = false;
|
||||
@@ -51,6 +71,9 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.syncOfflineIndicator();
|
||||
// The decorative pet's large module stays out of startup and upgrades in place.
|
||||
// Its first visit is at least 15 seconds after load, so idle loading cannot miss one.
|
||||
scheduleLobsterPetLoad();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -87,34 +110,6 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
this.logoVisit = detail.phase === "out" ? null : detail;
|
||||
};
|
||||
|
||||
private renderLogoStandIn() {
|
||||
const visit = this.logoVisit;
|
||||
if (!visit?.look) {
|
||||
return nothing;
|
||||
}
|
||||
const look = visit.look;
|
||||
const classes = [
|
||||
"sidebar-brand__pet",
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const style = [
|
||||
`--lob-shell:${look.palette.shell}`,
|
||||
`--lob-claw:${look.palette.claw}`,
|
||||
`--lob-blink-delay:${look.blinkDelayS}s`,
|
||||
`--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`,
|
||||
`--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`,
|
||||
`--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`,
|
||||
].join(";");
|
||||
return html`
|
||||
<span class=${classes} style=${style} title=${`${visit.name} · filling in for the logo`}
|
||||
>${renderLobsterSvg(look)}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBrand() {
|
||||
const collapseLabel = t("nav.collapse");
|
||||
const gatewayStatus = t("chat.gatewayStatus", {
|
||||
@@ -238,7 +233,7 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
${this.renderLogoStandIn()}
|
||||
<openclaw-lobster-logo-standin .visit=${this.logoVisit}></openclaw-lobster-logo-standin>
|
||||
</span>
|
||||
<openclaw-sidebar-build-chip
|
||||
.basePath=${this.basePath}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export type LobsterPetMode = "idle" | "busy" | "offline";
|
||||
|
||||
export type LobsterRunOutcome = "ok" | "error" | "aborted";
|
||||
|
||||
export type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff";
|
||||
|
||||
export type LobsterPetPaletteId =
|
||||
| "crimson"
|
||||
| "coral"
|
||||
| "teal"
|
||||
| "violet"
|
||||
| "ink"
|
||||
| "blue"
|
||||
| "gold"
|
||||
| "calico"
|
||||
| "abyss"
|
||||
| "ghost"
|
||||
| "split"
|
||||
| "retro";
|
||||
|
||||
export type LobsterPetPalette = {
|
||||
id: LobsterPetPaletteId;
|
||||
shell: string;
|
||||
claw: string;
|
||||
};
|
||||
|
||||
export type LobsterPetAccessory =
|
||||
| "none"
|
||||
| "crown"
|
||||
| "sprout"
|
||||
| "patch"
|
||||
| "santa"
|
||||
| "pumpkin"
|
||||
| "party";
|
||||
|
||||
export type LobsterPetAntennae = "perky" | "droopy";
|
||||
|
||||
export type LobsterPetBuild = "round" | "squat" | "slender";
|
||||
|
||||
export type LobsterPetClawSize = "dainty" | "regular" | "mighty";
|
||||
|
||||
export type LobsterPetLook = {
|
||||
palette: LobsterPetPalette;
|
||||
scale: number;
|
||||
accessory: LobsterPetAccessory;
|
||||
antennae: LobsterPetAntennae;
|
||||
side: "left" | "right";
|
||||
spotPct: number;
|
||||
facing: 1 | -1;
|
||||
personality: LobsterPetPersonalityId;
|
||||
blinkDelayS: number;
|
||||
build: LobsterPetBuild;
|
||||
clawSize: LobsterPetClawSize;
|
||||
tailFan: boolean;
|
||||
};
|
||||
|
||||
export type LobsterLogoVisitPhase = "in" | "leaving" | "out";
|
||||
|
||||
export type LobsterLogoVisitDetail = {
|
||||
phase: LobsterLogoVisitPhase;
|
||||
// A null look on a non-"out" phase means "hide the logo, render no
|
||||
// stand-in": a ledge visit scared the brand mark away.
|
||||
look: LobsterPetLook | null;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
// Fired on the pet host whenever the logo stand-in phase changes; the
|
||||
// sidebar owns the brand slot, so the swap renders there, not here.
|
||||
export const LOBSTER_LOGO_VISIT_EVENT = "openclaw-lobster-logo-visit";
|
||||
|
||||
function fnv1a(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
// One salt per page load: revisiting the UI re-rolls every session's lobster,
|
||||
// while re-renders within a load stay stable for a given session key.
|
||||
const LOAD_SALT = Math.trunc(Math.random() * 0xffffffff);
|
||||
|
||||
export function lobsterPetSeed(sessionKey: string): number {
|
||||
return (fnv1a(sessionKey) ^ LOAD_SALT) >>> 0;
|
||||
}
|
||||
|
||||
// The most recently active session with a terminal status decides how the
|
||||
// pet reacts when the busy state clears: failures earn sympathy, not cheers.
|
||||
export function resolveLobsterRunOutcome(
|
||||
sessions:
|
||||
| ReadonlyArray<{
|
||||
status?: "running" | "done" | "failed" | "killed" | "timeout";
|
||||
endedAt?: number | null;
|
||||
lastActivityAt?: number | null;
|
||||
updatedAt?: number | null;
|
||||
}>
|
||||
| null
|
||||
| undefined,
|
||||
): LobsterRunOutcome {
|
||||
let latest: { at: number; outcome: LobsterRunOutcome } | null = null;
|
||||
for (const row of sessions ?? []) {
|
||||
if (!row.status || row.status === "running") {
|
||||
continue;
|
||||
}
|
||||
// endedAt is the run-completion timestamp; activity/updated stamps also
|
||||
// move on unrelated events (reads, renames) and only serve as fallbacks.
|
||||
const at = row.endedAt ?? row.lastActivityAt ?? row.updatedAt ?? 0;
|
||||
if (!latest || at > latest.at) {
|
||||
const outcome: LobsterRunOutcome =
|
||||
row.status === "failed" || row.status === "timeout"
|
||||
? "error"
|
||||
: row.status === "killed"
|
||||
? "aborted"
|
||||
: "ok";
|
||||
latest = { at, outcome };
|
||||
}
|
||||
}
|
||||
return latest?.outcome ?? "ok";
|
||||
}
|
||||
|
||||
export function resolveLobsterPetMode(
|
||||
connected: boolean,
|
||||
sessions: ReadonlyArray<{ hasActiveRun?: boolean | null }> | null | undefined,
|
||||
): LobsterPetMode {
|
||||
if (!connected) {
|
||||
return "offline";
|
||||
}
|
||||
return sessions?.some((row) => row.hasActiveRun === true) ? "busy" : "idle";
|
||||
}
|
||||
@@ -20,6 +20,33 @@ import {
|
||||
recordLobsterVisit,
|
||||
type LobsterFamiliarity,
|
||||
} from "./lobster-dex.ts";
|
||||
import {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
type LobsterLogoVisitDetail,
|
||||
type LobsterLogoVisitPhase,
|
||||
type LobsterPetAccessory,
|
||||
type LobsterPetAntennae,
|
||||
type LobsterPetBuild,
|
||||
type LobsterPetClawSize,
|
||||
type LobsterPetLook,
|
||||
type LobsterPetMode,
|
||||
type LobsterPetPalette,
|
||||
type LobsterPetPaletteId,
|
||||
type LobsterPetPersonalityId,
|
||||
type LobsterRunOutcome,
|
||||
} from "./lobster-pet-contract.ts";
|
||||
|
||||
export {
|
||||
LOBSTER_LOGO_VISIT_EVENT,
|
||||
lobsterPetSeed,
|
||||
resolveLobsterPetMode,
|
||||
resolveLobsterRunOutcome,
|
||||
type LobsterLogoVisitDetail,
|
||||
type LobsterLogoVisitPhase,
|
||||
type LobsterPetLook,
|
||||
type LobsterPetMode,
|
||||
type LobsterRunOutcome,
|
||||
} from "./lobster-pet-contract.ts";
|
||||
|
||||
type LobsterPetAct =
|
||||
| "wave"
|
||||
@@ -37,53 +64,6 @@ type LobsterPetAct =
|
||||
| "droop"
|
||||
| "sweep";
|
||||
|
||||
type LobsterPetMode = "idle" | "busy" | "offline";
|
||||
|
||||
type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff";
|
||||
|
||||
type LobsterPetPaletteId =
|
||||
| "crimson"
|
||||
| "coral"
|
||||
| "teal"
|
||||
| "violet"
|
||||
| "ink"
|
||||
| "blue"
|
||||
| "gold"
|
||||
| "calico"
|
||||
| "abyss"
|
||||
| "ghost"
|
||||
| "split"
|
||||
| "retro";
|
||||
|
||||
type LobsterPetPalette = {
|
||||
id: LobsterPetPaletteId;
|
||||
shell: string;
|
||||
claw: string;
|
||||
};
|
||||
|
||||
type LobsterPetAccessory = "none" | "crown" | "sprout" | "patch" | "santa" | "pumpkin" | "party";
|
||||
|
||||
type LobsterPetAntennae = "perky" | "droopy";
|
||||
|
||||
type LobsterPetBuild = "round" | "squat" | "slender";
|
||||
|
||||
type LobsterPetClawSize = "dainty" | "regular" | "mighty";
|
||||
|
||||
type LobsterPetLook = {
|
||||
palette: LobsterPetPalette;
|
||||
scale: number;
|
||||
accessory: LobsterPetAccessory;
|
||||
antennae: LobsterPetAntennae;
|
||||
side: "left" | "right";
|
||||
spotPct: number;
|
||||
facing: 1 | -1;
|
||||
personality: LobsterPetPersonalityId;
|
||||
blinkDelayS: number;
|
||||
build: LobsterPetBuild;
|
||||
clawSize: LobsterPetClawSize;
|
||||
tailFan: boolean;
|
||||
};
|
||||
|
||||
type ActProfile = {
|
||||
// [min, max] delay before the next act.
|
||||
delayMs: [number, number];
|
||||
@@ -385,20 +365,6 @@ function isLobsterLogoLoad(seed: number): boolean {
|
||||
return mulberry32((seed ^ 0x1063) >>> 0)() < 0.12;
|
||||
}
|
||||
|
||||
type LobsterLogoVisitPhase = "in" | "leaving" | "out";
|
||||
|
||||
export type LobsterLogoVisitDetail = {
|
||||
phase: LobsterLogoVisitPhase;
|
||||
// A null look on a non-"out" phase means "hide the logo, render no
|
||||
// stand-in": a ledge visit scared the brand mark away.
|
||||
look: LobsterPetLook | null;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
// Fired on the pet host whenever the logo stand-in phase changes; the
|
||||
// sidebar owns the brand slot, so the swap renders there, not here.
|
||||
export const LOBSTER_LOGO_VISIT_EVENT = "openclaw-lobster-logo-visit";
|
||||
|
||||
type LobsterPasserKind = "stranger" | "crab";
|
||||
|
||||
type LobsterPasserPlan = {
|
||||
@@ -461,15 +427,6 @@ function isLobsterNightTime(now: Date = new Date()): boolean {
|
||||
return hour >= 22 || hour < 6;
|
||||
}
|
||||
|
||||
function fnv1a(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
@@ -496,14 +453,6 @@ function randomBetween(rng: () => number, min: number, max: number): number {
|
||||
return min + rng() * (max - min);
|
||||
}
|
||||
|
||||
// One salt per page load: revisiting the UI re-rolls every session's lobster,
|
||||
// while re-renders within a load stay stable for a given session key.
|
||||
const LOAD_SALT = Math.trunc(Math.random() * 0xffffffff);
|
||||
|
||||
export function lobsterPetSeed(sessionKey: string): number {
|
||||
return (fnv1a(sessionKey) ^ LOAD_SALT) >>> 0;
|
||||
}
|
||||
|
||||
export function createLobsterPetLook(seed: number, now: Date = new Date()): LobsterPetLook {
|
||||
const rng = mulberry32(seed);
|
||||
const palette = pickWeighted(rng, PALETTES);
|
||||
@@ -555,52 +504,6 @@ export function createLobsterPetLook(seed: number, now: Date = new Date()): Lobs
|
||||
};
|
||||
}
|
||||
|
||||
type LobsterRunOutcome = "ok" | "error" | "aborted";
|
||||
|
||||
// The most recently active session with a terminal status decides how the
|
||||
// pet reacts when the busy state clears: failures earn sympathy, not cheers.
|
||||
export function resolveLobsterRunOutcome(
|
||||
sessions:
|
||||
| ReadonlyArray<{
|
||||
status?: "running" | "done" | "failed" | "killed" | "timeout";
|
||||
endedAt?: number | null;
|
||||
lastActivityAt?: number | null;
|
||||
updatedAt?: number | null;
|
||||
}>
|
||||
| null
|
||||
| undefined,
|
||||
): LobsterRunOutcome {
|
||||
let latest: { at: number; outcome: LobsterRunOutcome } | null = null;
|
||||
for (const row of sessions ?? []) {
|
||||
if (!row.status || row.status === "running") {
|
||||
continue;
|
||||
}
|
||||
// endedAt is the run-completion timestamp; activity/updated stamps also
|
||||
// move on unrelated events (reads, renames) and only serve as fallbacks.
|
||||
const at = row.endedAt ?? row.lastActivityAt ?? row.updatedAt ?? 0;
|
||||
if (!latest || at > latest.at) {
|
||||
const outcome: LobsterRunOutcome =
|
||||
row.status === "failed" || row.status === "timeout"
|
||||
? "error"
|
||||
: row.status === "killed"
|
||||
? "aborted"
|
||||
: "ok";
|
||||
latest = { at, outcome };
|
||||
}
|
||||
}
|
||||
return latest?.outcome ?? "ok";
|
||||
}
|
||||
|
||||
export function resolveLobsterPetMode(
|
||||
connected: boolean,
|
||||
sessions: ReadonlyArray<{ hasActiveRun?: boolean | null }> | null | undefined,
|
||||
): LobsterPetMode {
|
||||
if (!connected) {
|
||||
return "offline";
|
||||
}
|
||||
return sessions?.some((row) => row.hasActiveRun === true) ? "busy" : "idle";
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -904,6 +807,42 @@ export function renderLobsterSvg(
|
||||
`;
|
||||
}
|
||||
|
||||
class LobsterLogoStandIn extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ attribute: false }) visit: LobsterLogoVisitDetail | null = null;
|
||||
|
||||
override render() {
|
||||
const visit = this.visit;
|
||||
if (!visit?.look) {
|
||||
return nothing;
|
||||
}
|
||||
const look = visit.look;
|
||||
const classes = [
|
||||
"sidebar-brand__pet",
|
||||
`lobster-pet--palette-${look.palette.id}`,
|
||||
visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const style = [
|
||||
`--lob-shell:${look.palette.shell}`,
|
||||
`--lob-claw:${look.palette.claw}`,
|
||||
`--lob-blink-delay:${look.blinkDelayS}s`,
|
||||
`--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`,
|
||||
`--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`,
|
||||
`--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`,
|
||||
].join(";");
|
||||
return html`
|
||||
<span class=${classes} style=${style} title=${`${visit.name} · filling in for the logo`}
|
||||
>${renderLobsterSvg(look)}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
class LobsterPet extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
@@ -1835,6 +1774,10 @@ class LobsterPet extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-lobster-logo-standin")) {
|
||||
customElements.define("openclaw-lobster-logo-standin", LobsterLogoStandIn);
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-lobster-pet")) {
|
||||
customElements.define("openclaw-lobster-pet", LobsterPet);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ async function openSidebarTestPage() {
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page);
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.waitForFunction(() => Boolean(customElements.get("openclaw-lobster-pet")));
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
|
||||
@@ -232,13 +232,22 @@ describe("AppSidebar logo stand-in wiring", () => {
|
||||
);
|
||||
const logo = () => sidebar.querySelector(".sidebar-brand__logo");
|
||||
const standIn = () => sidebar.querySelector(".sidebar-brand__pet");
|
||||
const standInHost = sidebar.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
|
||||
"openclaw-lobster-logo-standin",
|
||||
);
|
||||
const settleStandIn = async () => {
|
||||
await sidebar.updateComplete;
|
||||
await standInHost?.updateComplete;
|
||||
};
|
||||
|
||||
expect(standInHost).not.toBeNull();
|
||||
await standInHost?.updateComplete;
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
expect(standIn()).toBeNull();
|
||||
|
||||
const look = createLobsterPetLook(70);
|
||||
dispatch({ phase: "in", look, name: "Pinchy" });
|
||||
await sidebar.updateComplete;
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true);
|
||||
const sprite = standIn();
|
||||
expect(sprite).not.toBeNull();
|
||||
@@ -247,23 +256,23 @@ describe("AppSidebar logo stand-in wiring", () => {
|
||||
expect(sprite?.querySelector(".lobster-pet__svg")).not.toBeNull();
|
||||
|
||||
dispatch({ phase: "leaving", look, name: "Pinchy" });
|
||||
await sidebar.updateComplete;
|
||||
await settleStandIn();
|
||||
expect(standIn()?.classList.contains("sidebar-brand__pet--leaving")).toBe(true);
|
||||
|
||||
dispatch({ phase: "out", look: null, name: null });
|
||||
await sidebar.updateComplete;
|
||||
await settleStandIn();
|
||||
expect(standIn()).toBeNull();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
|
||||
// A lookless scare phase hides the logo with no stand-in crab, and the
|
||||
// "out" edge restores it.
|
||||
dispatch({ phase: "in", look: null, name: null });
|
||||
await sidebar.updateComplete;
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true);
|
||||
expect(standIn()).toBeNull();
|
||||
|
||||
dispatch({ phase: "out", look: null, name: null });
|
||||
await sidebar.updateComplete;
|
||||
await settleStandIn();
|
||||
expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user