@elabs-ai/components-ai 4.0.0 → 4.1.0
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/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `errorComponent` Streamdown renders in place of a Mermaid diagram that
|
|
5
|
+
* failed (issue #33). Streamdown hands back the render failure as a plain
|
|
6
|
+
* STRING (`MermaidErrorComponentProps.error`, already reduced from whatever
|
|
7
|
+
* was thrown), so this distinguishes two different failures by matching that
|
|
8
|
+
* string rather than the original error object:
|
|
9
|
+
*
|
|
10
|
+
* - **The optional `mermaid` peer is not installed.** This is a CAPABILITY
|
|
11
|
+
* GAP, not something wrong with the diagram or the app — the same class as
|
|
12
|
+
* `@elabs-ai/components-viewer`'s `parser-missing`
|
|
13
|
+
* (@.claude/rules/viewer-components.md "A capability gap is not a
|
|
14
|
+
* failure"). Renders the neutral `StatePanel kind="empty"`, naming the
|
|
15
|
+
* package to install, announced `role="status"`, with no retry action
|
|
16
|
+
* (re-installing a dependency does not happen by clicking a button in the
|
|
17
|
+
* page).
|
|
18
|
+
* - **Anything else is a genuine render/syntax failure** — a real error, so
|
|
19
|
+
* it gets `StatePanel kind="error"` (`role="alert"`, set internally by
|
|
20
|
+
* `StatePanel`) wired to Streamdown's own `retry()`, which increments the
|
|
21
|
+
* render attempt and re-runs the diagram.
|
|
22
|
+
*/
|
|
23
|
+
import { Button, StatePanel, isModuleNotFoundMessage, useLocale } from "@elabs-ai/components-ui";
|
|
24
|
+
import { EyeOffIcon } from "lucide-react";
|
|
25
|
+
|
|
26
|
+
export interface MermaidErrorPanelProps {
|
|
27
|
+
chart: string;
|
|
28
|
+
error: string;
|
|
29
|
+
retry: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function MermaidErrorPanel({ error, retry }: MermaidErrorPanelProps) {
|
|
33
|
+
const { t } = useLocale();
|
|
34
|
+
|
|
35
|
+
if (isModuleNotFoundMessage(error)) {
|
|
36
|
+
const feature = t("ai.mermaid.feature");
|
|
37
|
+
return (
|
|
38
|
+
<div className="my-4" role="status" aria-live="polite">
|
|
39
|
+
<StatePanel
|
|
40
|
+
kind="empty"
|
|
41
|
+
// A dashed edge invites a drop; this panel accepts nothing. Solid —
|
|
42
|
+
// mirrors `@elabs-ai/components-viewer`'s `FileViewerError`.
|
|
43
|
+
className="border-solid"
|
|
44
|
+
icon={<EyeOffIcon aria-hidden="true" />}
|
|
45
|
+
title={t("ai.error.engineMissing", { feature })}
|
|
46
|
+
description={t("ai.error.engineMissingBody", { feature, packages: "mermaid" })}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="my-4">
|
|
54
|
+
<StatePanel
|
|
55
|
+
kind="error"
|
|
56
|
+
title={t("ai.mermaid.renderError")}
|
|
57
|
+
description={error}
|
|
58
|
+
actions={
|
|
59
|
+
<Button size="sm" variant="outline" onClick={retry}>
|
|
60
|
+
{t("ai.error.retry")}
|
|
61
|
+
</Button>
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/_persona-rive.tsx
CHANGED
|
@@ -19,17 +19,45 @@
|
|
|
19
19
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
20
20
|
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
21
21
|
import type { RiveParameters } from "@rive-app/react-webgl2";
|
|
22
|
-
import
|
|
22
|
+
// `import type` from an optional peer is safe HERE ONLY because this module is
|
|
23
|
+
// reached exclusively through `lazy(() => import("./_persona-rive"))` — it is
|
|
24
|
+
// never statically imported, so nothing in this file reaches the barrel's
|
|
25
|
+
// `.d.ts` declaration graph. `persona.tsx`'s PUBLIC types must never do this
|
|
26
|
+
// (issue #101) — see its module doc comment and `PersonaRiveEvent`/
|
|
27
|
+
// `PersonaRiveEventCallback`, which this module imports below and proves
|
|
28
|
+
// conformant against the real peer type at the bottom of this file.
|
|
29
|
+
// A NAMED import (`import { useRive } from "@rive-app/react-webgl2"`) is a
|
|
30
|
+
// static ESM binding a bundler must resolve at build time. Now that
|
|
31
|
+
// `@rive-app/react-webgl2` is a genuinely optional peer (issue #33), a
|
|
32
|
+
// consumer who has not installed it hits that resolution at the worst
|
|
33
|
+
// possible time: Vite's own optional-peer-dependency handling swaps in a
|
|
34
|
+
// build-time stub with no exports, and Rollup's static named-export check
|
|
35
|
+
// then fails the WHOLE APP BUILD — not a runtime error `LazyEngineBoundary`
|
|
36
|
+
// could ever see (confirmed against `fixtures/consumer-smoke`'s real Vite
|
|
37
|
+
// build). A namespace import defers every one of these to a plain property
|
|
38
|
+
// lookup, which Rollup does not statically validate, so the build always
|
|
39
|
+
// succeeds; a genuinely missing peer instead resolves the destructured hooks
|
|
40
|
+
// below to `undefined`, and the guard turns that into the render-phase throw
|
|
41
|
+
// `LazyEngineBoundary` (see `persona.tsx`) already catches.
|
|
42
|
+
import * as RiveModule from "@rive-app/react-webgl2";
|
|
43
|
+
import type { ReactNode } from "react";
|
|
44
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
45
|
+
|
|
46
|
+
import type { AssertAssignable } from "./_lazy-boundary-conformance";
|
|
47
|
+
import type { PersonaRiveEventCallback } from "./persona";
|
|
48
|
+
import type { PersonaSource, PersonaState } from "./persona-sources";
|
|
49
|
+
|
|
50
|
+
const {
|
|
23
51
|
useRive,
|
|
24
52
|
useStateMachineInput,
|
|
25
53
|
useViewModel,
|
|
26
54
|
useViewModelInstance,
|
|
27
55
|
useViewModelInstanceColor,
|
|
28
|
-
}
|
|
29
|
-
import type { ReactNode } from "react";
|
|
30
|
-
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
56
|
+
} = RiveModule;
|
|
31
57
|
|
|
32
|
-
|
|
58
|
+
if (!useRive) {
|
|
59
|
+
throw new Error("Cannot find module '@rive-app/react-webgl2'");
|
|
60
|
+
}
|
|
33
61
|
|
|
34
62
|
// Delays Rive initialization by one frame so that React Strict Mode's
|
|
35
63
|
// immediate unmount cycle never creates a WebGL2 context. Only the
|
|
@@ -139,12 +167,12 @@ PersonaWithoutModel.displayName = "PersonaWithoutModel";
|
|
|
139
167
|
|
|
140
168
|
export interface PersonaRiveProps {
|
|
141
169
|
className?: string;
|
|
142
|
-
onLoad?:
|
|
143
|
-
onLoadError?:
|
|
144
|
-
onPause?:
|
|
145
|
-
onPlay?:
|
|
170
|
+
onLoad?: PersonaRiveEventCallback;
|
|
171
|
+
onLoadError?: PersonaRiveEventCallback;
|
|
172
|
+
onPause?: PersonaRiveEventCallback;
|
|
173
|
+
onPlay?: PersonaRiveEventCallback;
|
|
146
174
|
onReady?: () => void;
|
|
147
|
-
onStop?:
|
|
175
|
+
onStop?: PersonaRiveEventCallback;
|
|
148
176
|
source: PersonaSource;
|
|
149
177
|
/** Resolved `.riv` URL — `Persona`'s `src` override, or the variant default. */
|
|
150
178
|
src: string;
|
|
@@ -250,3 +278,27 @@ const PersonaRive = ({
|
|
|
250
278
|
};
|
|
251
279
|
|
|
252
280
|
export default PersonaRive;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Conformance assertion (issue #101): compile-time proof that the owned
|
|
284
|
+
* `PersonaRiveEventCallback` (declared in `persona.tsx`, structurally free of
|
|
285
|
+
* `@rive-app/react-webgl2`/`@rive-app/webgl2`) is still assignable to the REAL
|
|
286
|
+
* `RiveParameters["onLoad"]` — and, since `onLoad`/`onLoadError`/`onPause`/
|
|
287
|
+
* `onPlay`/`onStop` all share Rive's one `EventCallback` type, this single
|
|
288
|
+
* check covers all five. `AssertAssignable`'s type parameter is constrained
|
|
289
|
+
* (`TOwned extends TReal`), so if a future Rive release changes `Event`'s
|
|
290
|
+
* shape in a way `PersonaRiveEvent` no longer satisfies, this line fails to
|
|
291
|
+
* typecheck — caught by `pnpm --filter @elabs-ai/components-ai typecheck`
|
|
292
|
+
* locally, never shipped as a silent mismatch to a consumer.
|
|
293
|
+
*
|
|
294
|
+
* `AssertAssignable` itself is shared with `_audio-player-media-chrome.tsx`
|
|
295
|
+
* via `_lazy-boundary-conformance.ts` — see that module's doc comment for
|
|
296
|
+
* what this check can and cannot prove (it is one-directional: it cannot
|
|
297
|
+
* catch the owned type being NARROWER than the real one, which is exactly
|
|
298
|
+
* what `PersonaRiveEvent["data"]` is — see the CHANGELOG's "Breaking (types)"
|
|
299
|
+
* entry).
|
|
300
|
+
*/
|
|
301
|
+
export type _PersonaRiveEventCallbackConformance = AssertAssignable<
|
|
302
|
+
PersonaRiveEventCallback,
|
|
303
|
+
NonNullable<RiveParameters["onLoad"]>
|
|
304
|
+
>;
|
package/src/_streamdown-i18n.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
36
36
|
import type { StreamdownTranslations } from "streamdown";
|
|
37
37
|
import { buildCodeBlockTheme } from "./_code-block-theme";
|
|
38
38
|
import { lazyMermaid } from "./_lazy-mermaid";
|
|
39
|
+
import { MermaidErrorPanel } from "./_mermaid-error-panel";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Reactive replacement for `@streamdown/code`'s pre-configured `code` export
|
|
@@ -47,7 +48,7 @@ import { lazyMermaid } from "./_lazy-mermaid";
|
|
|
47
48
|
*
|
|
48
49
|
* Streamdown's dual-theme mechanism (`createCodePlugin({ themes: [light, dark] })`)
|
|
49
50
|
* expects exactly TWO themes and picks between them purely via the `.dark` CSS
|
|
50
|
-
* selector — but brand-ui ships
|
|
51
|
+
* selector — but brand-ui ships EVERY theme, not just Shiki's light/dark,
|
|
51
52
|
* and any dark-declaring theme matches `.dark` too (see `_code-block-theme.ts`). Passing a real
|
|
52
53
|
* light/dark PAIR would still force such a theme into the dark slot. Instead
|
|
53
54
|
* this pins BOTH slots to `buildCodeBlockTheme()` — the SAME brand-token-derived
|
|
@@ -95,6 +96,25 @@ export function useStreamdownPlugins() {
|
|
|
95
96
|
return useMemo(() => ({ cjk, code, math, mermaid: lazyMermaid }), [code]);
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The top-level Streamdown `mermaid` prop (distinct from `plugins.mermaid`,
|
|
101
|
+
* the LAZY `DiagramPlugin` `useStreamdownPlugins()` wires above) — this is
|
|
102
|
+
* where a FAILED render is caught (issue #33). Streamdown hands its
|
|
103
|
+
* `errorComponent` a plain STRING describing what went wrong;
|
|
104
|
+
* `MermaidErrorPanel` decides whether that string names a missing optional
|
|
105
|
+
* peer (`mermaid` not installed — a capability gap, `StatePanel kind="empty"`)
|
|
106
|
+
* or a genuine diagram render failure (`StatePanel kind="error"`, with retry).
|
|
107
|
+
*
|
|
108
|
+
* A stable module-level object, not a fresh one per render: Streamdown only
|
|
109
|
+
* re-reads `mermaid.errorComponent` when this reference changes, and
|
|
110
|
+
* `MermaidErrorPanel` never varies at runtime. Exported as a hook to match
|
|
111
|
+
* `useStreamdownPlugins()`'s call-site shape.
|
|
112
|
+
*/
|
|
113
|
+
const MERMAID_OPTIONS = { errorComponent: MermaidErrorPanel };
|
|
114
|
+
export function useStreamdownMermaidOptions() {
|
|
115
|
+
return MERMAID_OPTIONS;
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
/*
|
|
99
119
|
* The translation MAP moved down to `@elabs-ai/components-ui`
|
|
100
120
|
* (`lib/streamdown-translations.ts`) when `@elabs-ai/components-viewer`
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime half of the #36 fix (the type-level `Omit` is the other half — see
|
|
3
|
+
* `MarkdownViewProps`/`MessageResponseProps`).
|
|
4
|
+
*
|
|
5
|
+
* Streamdown installs its sanitiser chain (`rehype-raw` → `rehype-sanitize` →
|
|
6
|
+
* `rehype-harden`) as the DEFAULT VALUE of its `rehypePlugins` prop — a plain
|
|
7
|
+
* JS default parameter. Supplying the prop REPLACES the whole chain; there is
|
|
8
|
+
* no merge (verified against `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`:
|
|
9
|
+
* `xt = { raw, sanitize, harden }; gn = Object.values(xt)` is the default, and
|
|
10
|
+
* the component signature reads `rehypePlugins: a = gn`).
|
|
11
|
+
*
|
|
12
|
+
* `MarkdownView`/`MessageResponse` render UNTRUSTED, model-authored markdown,
|
|
13
|
+
* so that prop may never reach `<Streamdown>` — not even through a plain
|
|
14
|
+
* JavaScript consumer, an `as any`/`as never` cast, or a wider
|
|
15
|
+
* `{...spreadProps}` object a type-level `Omit` cannot see at runtime. `Omit`
|
|
16
|
+
* closes the TypeScript surface; this function closes the runtime one. Both
|
|
17
|
+
* are required — see issue #36.
|
|
18
|
+
*
|
|
19
|
+
* `remarkPlugins` is deliberately NOT stripped (PR #74 review, round 1). It is
|
|
20
|
+
* the same *shape* of prop — a consumer array replaces Streamdown's own remark
|
|
21
|
+
* defaults (`gfm`, `codeMeta`) rather than merging with them — but it is NOT a
|
|
22
|
+
* sanitiser override: the remark stage runs strictly UPSTREAM of the rehype
|
|
23
|
+
* chain, and Streamdown derives its rehype list without reading `remarkPlugins`
|
|
24
|
+
* at all. Anything a remark plugin injects — a raw `html` mdast node, a
|
|
25
|
+
* `data.hName`/`hChildren` hast element, an `onerror` via `hProperties`, a
|
|
26
|
+
* `javascript:` link URL, a smuggled `raw` hast child — still passes through
|
|
27
|
+
* `rehype-raw` → `rehype-sanitize` → `rehype-harden` before it can reach the
|
|
28
|
+
* DOM. Measured with this strip removed: all five channels executed and none
|
|
29
|
+
* produced a `<script>`, an `[onerror]` attribute or a `javascript:` href,
|
|
30
|
+
* while the identical payload injected AFTER the sanitiser did. Stripping it
|
|
31
|
+
* therefore removed real capability (remark-directive, footnotes, custom
|
|
32
|
+
* syntax) and closed nothing.
|
|
33
|
+
*
|
|
34
|
+
* If a consumer needs to widen what survives SANITISATION, `allowedTags` /
|
|
35
|
+
* `literalTagContent` are the supported seam: they MERGE into the sanitize
|
|
36
|
+
* schema instead of replacing the pipeline outright.
|
|
37
|
+
*
|
|
38
|
+
* THIS LIST HAS A PARITY COUNTERPART, AND EDITING ONE SIDE FAILS CI (#75).
|
|
39
|
+
* `scripts/check-sanitizer-passthrough.mjs` declares the same key set as
|
|
40
|
+
* `SAFE_RENDERERS[streamdown].dangerousProps` and reads THIS array literal back
|
|
41
|
+
* out of this file, asserting set-equality in BOTH directions — so removing a
|
|
42
|
+
* key here (the runtime stops stripping it) and removing a key there (the gate
|
|
43
|
+
* stops looking for it) are each a red build, and so is renaming the constant
|
|
44
|
+
* or rewriting it into a shape the gate cannot parse. Change the key set in
|
|
45
|
+
* both places, in the same commit, or don't change it.
|
|
46
|
+
*/
|
|
47
|
+
const SANITIZER_OVERRIDE_KEYS = ["rehypePlugins"] as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deletes `rehypePlugins` off `props` in place (if present) and warns in dev.
|
|
51
|
+
* Call this BEFORE spreading `props` onto `<Streamdown>`.
|
|
52
|
+
*
|
|
53
|
+
* Takes a plain object rather than a typed `MarkdownViewProps`/
|
|
54
|
+
* `MessageResponseProps` on purpose: the whole point is to catch a value that
|
|
55
|
+
* reached this call with a wider runtime shape than its type says (a `.tsx`
|
|
56
|
+
* consumer with no type checking at all, a force-cast, a spread of a bigger
|
|
57
|
+
* object) — a parameter typed to the post-`Omit` props would only accept
|
|
58
|
+
* values TypeScript already believes are safe.
|
|
59
|
+
*/
|
|
60
|
+
export function stripSanitizerOverrides(props: object): void {
|
|
61
|
+
const record = props as Record<string, unknown>;
|
|
62
|
+
for (const key of SANITIZER_OVERRIDE_KEYS) {
|
|
63
|
+
if (key in record) {
|
|
64
|
+
if (process.env.NODE_ENV !== "production") {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[@elabs-ai/components-ai] \`${key}\` is not a supported prop on this component: ` +
|
|
67
|
+
"it would replace Streamdown's default sanitiser chain (rehype-raw → " +
|
|
68
|
+
"rehype-sanitize → rehype-harden) and let untrusted markdown execute " +
|
|
69
|
+
"script in the host page. The value was ignored. Use `allowedTags` / " +
|
|
70
|
+
"`literalTagContent` to widen sanitisation instead — see issue #36.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
delete record[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The CONVERSE of `SANITIZER_OVERRIDE_KEYS`: the plugin slots that deliberately
|
|
80
|
+
* stay reachable even though they land in the DOM *after* — or entirely outside
|
|
81
|
+
* — Streamdown's sanitiser chain (#76).
|
|
82
|
+
*
|
|
83
|
+
* - **`math.rehypePlugin` is APPENDED to the END of the rehype pipeline**, i.e.
|
|
84
|
+
* after `rehype-raw` → `rehype-sanitize` → `rehype-harden` (verified against
|
|
85
|
+
* `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`). Its output is never
|
|
86
|
+
* re-sanitised.
|
|
87
|
+
* - **`mermaid` never enters the rehype/remark pipeline at all.** Its
|
|
88
|
+
* `getMermaid().render()` result is written via `dangerouslySetInnerHTML`
|
|
89
|
+
* (streamdown's only such sink). brand-ui's own default pins mermaid's
|
|
90
|
+
* `securityLevel: "strict"` (`_lazy-mermaid.ts`).
|
|
91
|
+
*
|
|
92
|
+
* `cjk` (remark-stage, re-sanitised downstream), `code` (feeds `shikiTheme`
|
|
93
|
+
* only) and `renderers` (ordinary React components) are NOT trust-bearing and
|
|
94
|
+
* are deliberately absent — a warning that fired on them would be noise
|
|
95
|
+
* consumers learn to ignore.
|
|
96
|
+
*
|
|
97
|
+
* **This is not a live vulnerability and must not be described as one.**
|
|
98
|
+
* Supplying an executable `Pluggable` takes code in the consuming app's own
|
|
99
|
+
* bundle, and an app author who can do that can already run code. The real,
|
|
100
|
+
* non-hypothetical hazard is MISCONFIGURATION — most sharply KaTeX's `trust`
|
|
101
|
+
* option (needed for anything beyond `singleDollarTextMath`/`errorColor`),
|
|
102
|
+
* which silently disables KaTeX's own sanitisation for model-authored content
|
|
103
|
+
* the consumer never intended to trust. Hence: WARN, never strip. Stripping
|
|
104
|
+
* would break the legitimate trusted use and is the breaking change #76
|
|
105
|
+
* explicitly rejects — `markdown-view.test.tsx`/`message.test.tsx` pin the slot
|
|
106
|
+
* OPEN so a future "hardening" fails loudly.
|
|
107
|
+
*/
|
|
108
|
+
const TRUSTED_PLUGIN_SLOT_ADVICE =
|
|
109
|
+
"This is a supported, TRUSTED-CODE seam — the plugin still runs and nothing was " +
|
|
110
|
+
"dropped — so treat it as code you ship: it can put arbitrary markup in the page. " +
|
|
111
|
+
"If you only needed KaTeX options, prefer `createMathPlugin({ singleDollarTextMath, " +
|
|
112
|
+
"errorColor })` and leave KaTeX's `trust` option off for model-authored content. " +
|
|
113
|
+
"See docs/CSP-AND-NETWORK.md and issue #76.";
|
|
114
|
+
|
|
115
|
+
const isPluginObject = (value: unknown): value is Record<string, unknown> =>
|
|
116
|
+
typeof value === "object" && value !== null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Dev-only `console.warn` when a consumer's `plugins` override replaces one of
|
|
120
|
+
* the two slots that are NOT upstream of the sanitiser. Call it where the
|
|
121
|
+
* consumer's overrides are merged over the internal defaults, passing both.
|
|
122
|
+
*
|
|
123
|
+
* **Reference-equality comparator, deliberately — not truthiness, not a deep
|
|
124
|
+
* compare.** `useStreamdownPlugins()` returns a FRESH `code` object on every
|
|
125
|
+
* theme change but a STABLE `math`/`mermaid`, so a truthiness check would fire
|
|
126
|
+
* on every theme flip and a deep-equality check would stay silent for a consumer
|
|
127
|
+
* who rebuilds an equivalent-looking object each render. For `math` the
|
|
128
|
+
* comparison narrows further to the `rehypePlugin` member itself, so changing
|
|
129
|
+
* only `remarkPlugin`/`getStyles` (upstream of, or outside, the DOM sink) stays
|
|
130
|
+
* silent.
|
|
131
|
+
*
|
|
132
|
+
* Warns; never mutates. `stripSanitizerOverrides` is the other half of the
|
|
133
|
+
* boundary — the props that must never be reachable at all.
|
|
134
|
+
*/
|
|
135
|
+
export function warnOnTrustedPluginSlots(overrides: object | undefined, internal: object): void {
|
|
136
|
+
if (process.env.NODE_ENV === "production") return;
|
|
137
|
+
if (!overrides) return;
|
|
138
|
+
const consumer = overrides as Record<string, unknown>;
|
|
139
|
+
const defaults = internal as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
if (isPluginObject(consumer.math)) {
|
|
142
|
+
const theirs = consumer.math.rehypePlugin;
|
|
143
|
+
const ours = isPluginObject(defaults.math) ? defaults.math.rehypePlugin : undefined;
|
|
144
|
+
if (theirs !== ours) {
|
|
145
|
+
console.warn(
|
|
146
|
+
"[@elabs-ai/components-ai] `plugins.math.rehypePlugin` is appended to the END of " +
|
|
147
|
+
"Streamdown's rehype pipeline, so it runs AFTER the sanitiser chain " +
|
|
148
|
+
"(rehype-raw → rehype-sanitize → rehype-harden) and whatever it emits is never " +
|
|
149
|
+
`re-sanitised. ${TRUSTED_PLUGIN_SLOT_ADVICE}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isPluginObject(consumer.mermaid) && consumer.mermaid !== defaults.mermaid) {
|
|
155
|
+
// The message describes React's `dangerouslySetInnerHTML` in prose rather than
|
|
156
|
+
// naming the identifier: `pnpm csp-sinks:check` preserves string literals on
|
|
157
|
+
// purpose (a sink hidden in a string is still a sink), so spelling the prop out
|
|
158
|
+
// inside this warning makes the gate read THIS file as a new HTML-assigning
|
|
159
|
+
// module. Comments like this one are stripped by that gate, so the name is safe
|
|
160
|
+
// here and in the TSDoc above — just not inside a runtime string.
|
|
161
|
+
console.warn(
|
|
162
|
+
"[@elabs-ai/components-ai] `plugins.mermaid` never enters the rehype pipeline at " +
|
|
163
|
+
"all: its rendered SVG is injected as raw HTML through React's dangerous " +
|
|
164
|
+
"inner-HTML prop, i.e. after and outside the sanitiser chain " +
|
|
165
|
+
"(rehype-raw → rehype-sanitize → rehype-harden). A " +
|
|
166
|
+
"replacement plugin must sanitise its own output — brand-ui's default pins " +
|
|
167
|
+
`mermaid's strict security level. ${TRUSTED_PLUGIN_SLOT_ADVICE}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { AgentEvent } from "./agent-event";
|
|
3
|
+
import { AgentStep, AgentTimeline } from "./agent-timeline";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "AI/AgentEvent",
|
|
7
|
+
component: AgentEvent,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: "padded",
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The CHAT lifecycle/guard event line; the console skin is `Terminal/TerminalEventLine`, and both read the same `AgentEventPhase` / `AgentEventOutcome` / `CheckSummary` model from `@elabs-ai/components-ui` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). It is an `AgentStep` VARIANT on the existing `AI/AgentTimeline` rail, not a second spine: the same ordered list, distinguished by glyph and label.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ["autodocs"],
|
|
18
|
+
} satisfies Meta<typeof AgentEvent>;
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
// A lifecycle event with no gated action — an AgentStep variant on the SAME
|
|
23
|
+
// rail, distinguished from an ordinary step by glyph + label only (#109).
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<AgentTimeline className="max-w-prose">
|
|
27
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
28
|
+
</AgentTimeline>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// One hook firing before a tool call and one firing after — the event-level
|
|
33
|
+
// `phase` ("before" | "after" | "lifecycle") is a separate, wider prop than
|
|
34
|
+
// any individual check's own `phase`.
|
|
35
|
+
export const BeforeAndAfterHooks: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<AgentTimeline className="max-w-prose">
|
|
38
|
+
<AgentEvent label="pre_tool_use" phase="before" outcome="ok" durationMs={42} />
|
|
39
|
+
<AgentEvent label="post_tool_use" phase="after" outcome="ok" durationMs={128} />
|
|
40
|
+
</AgentTimeline>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// checks as a count summary — "passed/ran" rendered as plain text.
|
|
45
|
+
export const CheckSummaryCount: Story = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<AgentTimeline className="max-w-prose">
|
|
48
|
+
<AgentEvent
|
|
49
|
+
label="pre_tool_use"
|
|
50
|
+
phase="before"
|
|
51
|
+
outcome="ok"
|
|
52
|
+
durationMs={210}
|
|
53
|
+
checks={{ ran: 4, passed: 4 }}
|
|
54
|
+
/>
|
|
55
|
+
</AgentTimeline>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// checks as individual verdicts — a blocked event whose gate had a failing
|
|
60
|
+
// check. Pass/fail never rides colour alone: each row pairs a distinct icon
|
|
61
|
+
// with visible "Passed"/"Failed" text (see accessibility.md's greyscale test).
|
|
62
|
+
export const BlockedWithFailingCheck: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<AgentTimeline className="max-w-prose">
|
|
65
|
+
<AgentEvent
|
|
66
|
+
label="pre_tool_use"
|
|
67
|
+
phase="before"
|
|
68
|
+
outcome="blocked"
|
|
69
|
+
durationMs={340}
|
|
70
|
+
checks={[
|
|
71
|
+
{ label: "policy: no secrets", ok: true, durationMs: 12 },
|
|
72
|
+
{ label: "policy: write scope", ok: false, detail: "path escapes workspace root" },
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
</AgentTimeline>
|
|
76
|
+
),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// A failed hook alongside ordinary AgentStep entries, proving AgentEvent
|
|
80
|
+
// rides the same <ol> rail as any other step — no second spine (#109 AC1).
|
|
81
|
+
export const WithinAgentTimeline: Story = {
|
|
82
|
+
name: "Alongside AgentStep on one rail",
|
|
83
|
+
render: () => (
|
|
84
|
+
<AgentTimeline className="max-w-prose">
|
|
85
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
86
|
+
<AgentStep status="complete" name="Searched financial filings" summary="3 documents" />
|
|
87
|
+
<AgentEvent
|
|
88
|
+
label="post_tool_use"
|
|
89
|
+
phase="after"
|
|
90
|
+
outcome="failed"
|
|
91
|
+
durationMs={95}
|
|
92
|
+
checks={[{ label: "output schema", ok: false, detail: "missing required field: total" }]}
|
|
93
|
+
/>
|
|
94
|
+
<AgentEvent label="stop" phase="lifecycle" outcome="ok" durationMs={64200} />
|
|
95
|
+
</AgentTimeline>
|
|
96
|
+
),
|
|
97
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { AgentEvent } from "./agent-event";
|
|
4
|
+
import { AgentStep, AgentTimeline } from "./agent-timeline";
|
|
5
|
+
|
|
6
|
+
describe("AgentEvent (#109)", () => {
|
|
7
|
+
it("renders on the same <ol> rail as an AgentStep — no second spine", () => {
|
|
8
|
+
render(
|
|
9
|
+
<AgentTimeline data-testid="timeline">
|
|
10
|
+
<AgentStep name="Queried finance.revenue" status="complete" />
|
|
11
|
+
<AgentEvent label="pre_tool_use" outcome="ok" />
|
|
12
|
+
</AgentTimeline>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByTestId("timeline").tagName).toBe("OL");
|
|
15
|
+
const items = screen.getAllByRole("listitem");
|
|
16
|
+
expect(items).toHaveLength(2);
|
|
17
|
+
// The AgentEvent item carries the same data-slot as any other li in this
|
|
18
|
+
// rail's list structure, and is reachable via getAllByRole("listitem")
|
|
19
|
+
// exactly like AgentStep — i.e. one list, not a nested second rail.
|
|
20
|
+
expect(items[1]).toHaveAttribute("data-slot", "agent-event");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps outcome onto the existing closed Status — no new status value", () => {
|
|
24
|
+
const { rerender } = render(
|
|
25
|
+
<AgentTimeline>
|
|
26
|
+
<AgentEvent label="stop" outcome="ok" />
|
|
27
|
+
</AgentTimeline>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "complete");
|
|
30
|
+
|
|
31
|
+
rerender(
|
|
32
|
+
<AgentTimeline>
|
|
33
|
+
<AgentEvent label="pre_tool_use" outcome="blocked" />
|
|
34
|
+
</AgentTimeline>,
|
|
35
|
+
);
|
|
36
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "denied");
|
|
37
|
+
|
|
38
|
+
rerender(
|
|
39
|
+
<AgentTimeline>
|
|
40
|
+
<AgentEvent label="post_tool_use" outcome="failed" />
|
|
41
|
+
</AgentTimeline>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByRole("listitem")).toHaveAttribute("data-status", "failed");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders the label and, when given, the event-level phase as visible text", () => {
|
|
47
|
+
render(
|
|
48
|
+
<AgentTimeline>
|
|
49
|
+
<AgentEvent label="user_prompt_submit" phase="lifecycle" outcome="ok" />
|
|
50
|
+
</AgentTimeline>,
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByText("user_prompt_submit")).toBeInTheDocument();
|
|
53
|
+
// The phase renders as its own data-slot part, distinct from the
|
|
54
|
+
// per-check `phase` field on CheckResult (never the same prop/slot), and
|
|
55
|
+
// carries real, non-empty visible text.
|
|
56
|
+
const phaseNode = document.querySelector('[data-slot="agent-event-phase"]');
|
|
57
|
+
expect(phaseNode).not.toBeNull();
|
|
58
|
+
expect(phaseNode?.textContent?.trim()).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders no phase part when phase is omitted", () => {
|
|
62
|
+
render(
|
|
63
|
+
<AgentTimeline>
|
|
64
|
+
<AgentEvent label="post_tool_use" outcome="ok" />
|
|
65
|
+
</AgentTimeline>,
|
|
66
|
+
);
|
|
67
|
+
expect(document.querySelector('[data-slot="agent-event-phase"]')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders a failed check distinguishably from a passed one in ACCESSIBLE TEXT, not only a colour class", () => {
|
|
71
|
+
render(
|
|
72
|
+
<AgentTimeline>
|
|
73
|
+
<AgentEvent
|
|
74
|
+
label="post_tool_use"
|
|
75
|
+
outcome="failed"
|
|
76
|
+
checks={[
|
|
77
|
+
{ label: "eslint", ok: true },
|
|
78
|
+
{ label: "tsc", ok: false, detail: "2 errors" },
|
|
79
|
+
]}
|
|
80
|
+
/>
|
|
81
|
+
</AgentTimeline>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const passRow = screen.getByText("eslint").closest('[data-slot="agent-event-check"]');
|
|
85
|
+
const failRow = screen.getByText("tsc").closest('[data-slot="agent-event-check"]');
|
|
86
|
+
expect(passRow).not.toBeNull();
|
|
87
|
+
expect(failRow).not.toBeNull();
|
|
88
|
+
|
|
89
|
+
// data-ok is a machine attribute — not itself sufficient (invisible to AT).
|
|
90
|
+
expect(passRow).toHaveAttribute("data-ok", "true");
|
|
91
|
+
expect(failRow).toHaveAttribute("data-ok", "false");
|
|
92
|
+
|
|
93
|
+
// The ACCESSIBLE TEXT differs between the two rows: the status-word node's
|
|
94
|
+
// text content is not the same string for a pass vs a fail. This holds
|
|
95
|
+
// whether `t()` resolves a registered translation or falls back to the
|
|
96
|
+
// raw key, so the assertion does not hardcode "Passed"/"Failed" copy that
|
|
97
|
+
// this package does not own.
|
|
98
|
+
const passStatus = passRow?.querySelector('[data-slot="agent-event-check-status"]');
|
|
99
|
+
const failStatus = failRow?.querySelector('[data-slot="agent-event-check-status"]');
|
|
100
|
+
expect(passStatus?.textContent).toBeTruthy();
|
|
101
|
+
expect(failStatus?.textContent).toBeTruthy();
|
|
102
|
+
expect(passStatus?.textContent).not.toBe(failStatus?.textContent);
|
|
103
|
+
|
|
104
|
+
// The failing check's detail line is also visible, real text (not only color).
|
|
105
|
+
expect(screen.getByText("2 errors")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders a CheckSummary as passed/ran text instead of a row list", () => {
|
|
109
|
+
// Numeric interpolation into the localized "passed/ran" copy is exercised
|
|
110
|
+
// by the shared `t()`/messages machinery elsewhere (this package does not
|
|
111
|
+
// own `messages.ts` — see the message keys reported alongside this
|
|
112
|
+
// component). Here we assert the STRUCTURAL contract: a count summary
|
|
113
|
+
// renders one text node in the summary slot, never the per-row list.
|
|
114
|
+
render(
|
|
115
|
+
<AgentTimeline>
|
|
116
|
+
<AgentEvent label="pre_tool_use" outcome="ok" checks={{ ran: 4, passed: 3 }} />
|
|
117
|
+
</AgentTimeline>,
|
|
118
|
+
);
|
|
119
|
+
const summary = document.querySelector('[data-slot="agent-event-checks-summary"]');
|
|
120
|
+
expect(summary).not.toBeNull();
|
|
121
|
+
expect(summary?.textContent?.trim()).toBeTruthy();
|
|
122
|
+
expect(document.querySelector('[data-slot="agent-event-checks"]')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders duration via the shared formatElapsed, not a second formatter", () => {
|
|
126
|
+
render(
|
|
127
|
+
<AgentTimeline>
|
|
128
|
+
<AgentEvent label="stop" outcome="ok" durationMs={8000} />
|
|
129
|
+
</AgentTimeline>,
|
|
130
|
+
);
|
|
131
|
+
// formatElapsed(8000) === "8.0s" (packages/ui/src/lib/format-duration.ts)
|
|
132
|
+
expect(screen.getByText("8.0s")).toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("merges className and spreads props on the underlying li, like AgentStep", () => {
|
|
136
|
+
render(
|
|
137
|
+
<AgentTimeline>
|
|
138
|
+
<AgentEvent label="stop" className="custom-event" aria-label="event" />
|
|
139
|
+
</AgentTimeline>,
|
|
140
|
+
);
|
|
141
|
+
const li = screen.getByRole("listitem");
|
|
142
|
+
expect(li).toHaveClass("custom-event");
|
|
143
|
+
expect(li).toHaveAttribute("aria-label", "event");
|
|
144
|
+
});
|
|
145
|
+
});
|