ui-soxo-bootstrap-core 2.6.61 → 2.6.62-dev.2

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.
@@ -17,6 +17,7 @@ Incorrect versioning or incorrect tags will break the publish pipeline — follo
17
17
  - Publishing via GitHub Release UI
18
18
  - How GitHub Action Detects Release Type
19
19
  - Summary Table
20
+ - CI/CD Authentication (Trusted Publishing)
20
21
  - Common Mistakes & Fixes
21
22
 
22
23
  ---
@@ -255,17 +256,14 @@ npm publish --tag dev
255
256
 
256
257
  # ⚙️ How GitHub Action Detects Release Type
257
258
 
258
- If version contains `dev`:
259
+ The workflow reads the `version` field from `package.json` at publish time:
259
260
 
260
- ```
261
- npm publish --tag dev
262
- ```
261
+ | Condition | Command | Result |
262
+ | ---------------------------- | ------------------------------------------------------- | ---------------------------------- |
263
+ | Version contains `-dev` | `npm publish --provenance --access public --tag dev` | Publishes to the `dev` dist-tag |
264
+ | Version has no `-dev` suffix | `npm publish --provenance --access public --tag latest` | Publishes to the `latest` dist-tag |
263
265
 
264
- Otherwise:
265
-
266
- ```
267
- npm publish
268
- ```
266
+ The workflow also enforces that the GitHub release tag matches `v<version>` from `package.json` and fails the run immediately if they diverge — this prevents the most common publish failure described below.
269
267
 
270
268
  ---
271
269
 
@@ -283,6 +281,37 @@ npm publish
283
281
 
284
282
  ---
285
283
 
284
+ # 🔐 CI/CD Authentication (Trusted Publishing)
285
+
286
+ As of npm's 2025 policy changes, classic automation tokens (`NPM_TOKEN`) are deprecated. This repo now authenticates to npm via **OIDC Trusted Publishing** — GitHub Actions exchanges a short-lived OIDC token for a publish token at run time, so **no secret is stored in the repository**.
287
+
288
+ ## What this means for developers
289
+
290
+ Nothing. You still follow the same flow: `npm version` → push tag → create GitHub Release. The auth happens transparently in CI.
291
+
292
+ ## What this means for maintainers
293
+
294
+ The first-time setup on npmjs.com must be done once per package:
295
+
296
+ 1. Log in to [npmjs.com](https://www.npmjs.com) → open the package (`ui-soxo-bootstrap-core`) → **Settings**.
297
+ 2. Under **Trusted Publisher**, click **Add trusted publisher** and fill in:
298
+ - Publisher: **GitHub Actions**
299
+ - Organization or user: `soxo-tech`
300
+ - Repository: `bootstrap-core`
301
+ - Workflow filename: `npm-publish.yml`
302
+ - Environment name: *(leave blank)*
303
+ 3. Save. Any old `NPM_TOKEN` repository secret can be removed.
304
+
305
+ ## Runtime requirements
306
+
307
+ The workflow runs on Node 20 and upgrades npm to the latest CLI (`npm install -g npm@latest`) because OIDC trusted publishing requires **npm ≥ 11.5.1**. The `--provenance` flag attaches a verifiable build attestation to every published version, visible on the npmjs.com package page.
308
+
309
+ ## If publish fails with `403 Forbidden` or `ENEEDAUTH`
310
+
311
+ The trusted publisher config on npmjs.com no longer matches the workflow. Check that org, repo, and workflow filename match exactly — including case.
312
+
313
+ ---
314
+
286
315
  # ⚠️ Common Mistakes & Fixes
287
316
 
288
317
  | Mistake | Issue | Fix |
@@ -224,4 +224,4 @@ const SignatureCanvasComponent = ({
224
224
  );
225
225
  };
226
226
 
227
- export default SignatureCanvasComponent;
227
+ export default SignatureCanvasComponent;
@@ -2,7 +2,7 @@
2
2
  *
3
3
  * Global header component
4
4
  */
5
- import { useContext, useEffect, useRef, useState } from 'react';
5
+ import { useContext, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { motion, useAnimation } from 'framer-motion';
7
7
  import { boxVariants } from './animations';
8
8
  import { GlobalContext, GlobalProvider } from './../../Store';
@@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next';
21
21
  import SettingsUtil from '../../../utils/settings.utils';
22
22
  import SpotlightSearch from '../spotlight-search/spotlight-search.component';
23
23
  import LicenseAlert from '../../../components/license-management/license-alert';
24
+ import SupportDesk from '../support-desk/support-desk';
25
+ import { resolveSupportConfig } from '../support-desk/support-desk.utils';
24
26
 
25
27
  const { Title } = Typography;
26
28
  function GlobalHeaderContent({ loading, appSettings, children, isConnected, history, modules = [], sidemenu = [], reload, meta = {},
@@ -32,6 +34,9 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
32
34
  const { isMobile, user = { locations: [] }, kiosk, state, settings } = useContext(GlobalContext);
33
35
  const [visible, setVisible] = useState(false);
34
36
  const helpDeskSetting = settings?.HELPATR || {};
37
+ // Memoised: the drawer's effects depend on this object, so it must keep its identity across renders.
38
+ const supportConfig = useMemo(() => resolveSupportConfig(helpDeskSetting, user), [helpDeskSetting, user]);
39
+ const [supportOpen, setSupportOpen] = useState(false);
35
40
  // Variable to handle toggling of menu
36
41
  const [collapsed, setCollapsed] = useState(false);
37
42
  // varibale handle branch switcher
@@ -203,23 +208,16 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
203
208
  <Button onClick={reload} icon={<ReloadOutlined />} type="default" size={'small'}></Button>
204
209
  {/* Reload Button Ends */}
205
210
  {/* Help-desk-btn */}
206
- {helpDeskSetting?.showSupportBtn
207
- ? (() => {
208
- return (
209
- <Tooltip title={helpDeskSetting?.toolTipText} overlayClassName="modern-tooltip">
210
- <span>
211
- <Button
212
- onClick={() => window.open(helpDeskSetting?.helpDeskLink ?? '', '_blank')}
213
- type="default"
214
- size="small"
215
- >
216
- {helpDeskSetting?.buttonText || 'Help'}
217
- </Button>
218
- </span>
219
- </Tooltip>
220
- );
221
- })()
222
- : null}
211
+ {/* Shown only when the support settings are complete, so the button never does nothing */}
212
+ {helpDeskSetting?.showSupportBtn && supportConfig.enabled ? (
213
+ <Tooltip title={helpDeskSetting?.toolTipText} overlayClassName="modern-tooltip">
214
+ <span>
215
+ <Button onClick={() => setSupportOpen(true)} type="default" size="small">
216
+ {helpDeskSetting?.buttonText || 'Help'}
217
+ </Button>
218
+ </span>
219
+ </Tooltip>
220
+ ) : null}
223
221
  {/** Switch Languages starts */}
224
222
  {process.env.REACT_APP_ENABLE_LANGUAGE_SWITCHER ? <LanguageSwitcher /> : null}
225
223
  {/** Switch Languages ends */}
@@ -244,7 +242,11 @@ function GlobalHeaderContent({ loading, appSettings, children, isConnected, hist
244
242
 
245
243
 
246
244
 
247
- {licAlert && licenseData && (
245
+ {supportConfig.enabled && supportOpen ? (
246
+ <SupportDesk open={supportOpen} onClose={() => setSupportOpen(false)} config={supportConfig} title={helpDeskSetting?.buttonText || 'Support'} />
247
+ ) : null}
248
+
249
+ {licAlert && licenseData && (
248
250
  <div
249
251
  style={{
250
252
  top: 0,
@@ -290,4 +292,4 @@ function ProfileAvatar() {
290
292
  {/* {user.name} */}
291
293
  </Link>
292
294
  );
293
- }
295
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * "My Tickets" tab. Only the logged in user's tickets are returned: the encrypted token
3
+ * carries their email and the Helpdesk filters on it. Nothing is filtered on the client.
4
+ *
5
+ * If the browser blocks the API call (CORS) the Helpdesk's own page is shown in an iframe.
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+ import { Button, DatePicker, Empty, Input, Result, Select, Table, Tag, Typography, message } from 'antd';
9
+ import { ClearOutlined, ExportOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
10
+ import {
11
+ PAGE_SIZE,
12
+ PRIORITY_COLORS,
13
+ STATUS_COLORS,
14
+ buildTicketsPageUrl,
15
+ fetchTickets,
16
+ formatDate,
17
+ formatDateTime,
18
+ normalize,
19
+ openInNewTab,
20
+ toPlainText,
21
+ } from '../../support-desk.utils';
22
+
23
+ const { Text } = Typography;
24
+
25
+ // ==============================|| STATE ||============================== //
26
+
27
+ const SEARCH_DEBOUNCE_MS = 350;
28
+
29
+ /**
30
+ * @param {object} config result of resolveSupportConfig
31
+ * @param {boolean} active load only while the tab is visible
32
+ */
33
+ function useTickets(config, active) {
34
+ const [search, setSearch] = useState('');
35
+ const [debouncedSearch, setDebouncedSearch] = useState('');
36
+ const [status, setStatusState] = useState();
37
+ const [priority, setPriorityState] = useState();
38
+ const [range, setRangeState] = useState(null);
39
+ const [page, setPage] = useState(1);
40
+
41
+ const [tickets, setTickets] = useState([]);
42
+ const [total, setTotal] = useState(0);
43
+ const [options, setOptions] = useState({ statuses: [], priorities: [] });
44
+
45
+ const [loading, setLoading] = useState(false);
46
+ const [error, setError] = useState('');
47
+ const [fallbackUrl, setFallbackUrl] = useState('');
48
+
49
+ // The newest request wins; older ones are cancelled.
50
+ const controllerRef = useRef(null);
51
+
52
+ useEffect(() => {
53
+ if (search === debouncedSearch) return undefined;
54
+
55
+ const timer = setTimeout(() => {
56
+ setDebouncedSearch(search);
57
+ setPage(1);
58
+ }, SEARCH_DEBOUNCE_MS);
59
+ return () => clearTimeout(timer);
60
+ }, [search, debouncedSearch]);
61
+
62
+ const load = useCallback(async () => {
63
+ if (controllerRef.current) controllerRef.current.abort();
64
+ const controller = new AbortController();
65
+ controllerRef.current = controller;
66
+
67
+ setLoading(true);
68
+ setError('');
69
+
70
+ try {
71
+ const result = await fetchTickets(
72
+ config,
73
+ {
74
+ page,
75
+ page_size: PAGE_SIZE,
76
+ search: debouncedSearch,
77
+ status,
78
+ priority,
79
+ date_from: range?.[0]?.format('YYYY-MM-DD'),
80
+ date_to: range?.[1]?.format('YYYY-MM-DD'),
81
+ },
82
+ controller.signal
83
+ );
84
+
85
+ setTickets(result.tickets);
86
+ setTotal(result.total);
87
+ // Keep the last known options so the dropdowns do not empty when a filter returns no rows.
88
+ setOptions((prev) => ({
89
+ statuses: result.statuses.length ? result.statuses : prev.statuses,
90
+ priorities: result.priorities.length ? result.priorities : prev.priorities,
91
+ }));
92
+ } catch (err) {
93
+ if (err.code === 'aborted') return;
94
+
95
+ // Blocked by CORS: show the Helpdesk's own page instead of a dead tab.
96
+ if (err.code === 'blocked') {
97
+ try {
98
+ setFallbackUrl(await buildTicketsPageUrl(config));
99
+ return;
100
+ } catch (e) {
101
+ /* fall through to the error state */
102
+ }
103
+ }
104
+
105
+ setError(err.message || 'Please try again in a moment.');
106
+ setTickets([]);
107
+ setTotal(0);
108
+ } finally {
109
+ if (controllerRef.current === controller) setLoading(false);
110
+ }
111
+ }, [config, page, debouncedSearch, status, priority, range]);
112
+
113
+ useEffect(() => {
114
+ if (active) load();
115
+ }, [active, load]);
116
+
117
+ // Cancel anything in flight when the drawer closes.
118
+ useEffect(
119
+ () => () => {
120
+ if (controllerRef.current) controllerRef.current.abort();
121
+ controllerRef.current = null;
122
+ },
123
+ []
124
+ );
125
+
126
+ const withFirstPage = (setter) => (value) => {
127
+ setter(value);
128
+ setPage(1);
129
+ };
130
+
131
+ const hasFilters = Boolean(search || status || priority || range);
132
+
133
+ const clearFilters = () => {
134
+ setSearch('');
135
+ setDebouncedSearch('');
136
+ setStatusState(undefined);
137
+ setPriorityState(undefined);
138
+ setRangeState(null);
139
+ setPage(1);
140
+ };
141
+
142
+ return {
143
+ filters: { search, status, priority, range },
144
+ setSearch,
145
+ setStatus: withFirstPage(setStatusState),
146
+ setPriority: withFirstPage(setPriorityState),
147
+ setRange: withFirstPage(setRangeState),
148
+ clearFilters,
149
+ hasFilters,
150
+ options,
151
+ page,
152
+ setPage,
153
+ tickets,
154
+ total,
155
+ loading,
156
+ error,
157
+ fallbackUrl,
158
+ reload: load,
159
+ };
160
+ }
161
+
162
+ // ==============================|| FILTERS ||============================== //
163
+
164
+ function TicketFilters({ tickets }) {
165
+ const { filters, options, hasFilters, setSearch, setStatus, setPriority, setRange, clearFilters } = tickets;
166
+
167
+ return (
168
+ <div className="support-filters">
169
+ <Input
170
+ allowClear
171
+ className="support-filters__search"
172
+ prefix={<SearchOutlined />}
173
+ placeholder="Search by ticket ID or subject"
174
+ value={filters.search}
175
+ onChange={(event) => setSearch(event.target.value)}
176
+ />
177
+ <Select allowClear placeholder="Status" value={filters.status} onChange={setStatus} className="support-filters__select">
178
+ {options.statuses.map((item) => (
179
+ <Select.Option key={item} value={item}>
180
+ {item}
181
+ </Select.Option>
182
+ ))}
183
+ </Select>
184
+ <Select allowClear placeholder="Priority" value={filters.priority} onChange={setPriority} className="support-filters__select">
185
+ {options.priorities.map((item) => (
186
+ <Select.Option key={item} value={item}>
187
+ {item}
188
+ </Select.Option>
189
+ ))}
190
+ </Select>
191
+ <DatePicker.RangePicker value={filters.range} onChange={setRange} className="support-filters__dates" />
192
+ <div className="support-filters__actions">
193
+ {hasFilters && (
194
+ <Button type="link" icon={<ClearOutlined />} onClick={clearFilters}>
195
+ Clear
196
+ </Button>
197
+ )}
198
+ </div>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ // ==============================|| TABLE ||============================== //
204
+
205
+ const columns = [
206
+ {
207
+ title: 'Ticket',
208
+ dataIndex: 'name',
209
+ width: 110,
210
+ render: (value) => <Text strong>{value}</Text>,
211
+ },
212
+ {
213
+ title: 'Subject',
214
+ dataIndex: 'subject',
215
+ render: (value, row) => {
216
+ const description = toPlainText(row.description);
217
+ return (
218
+ <div className="support-subject">
219
+ <div className="support-subject__title" title={value}>
220
+ {value || '-'}
221
+ </div>
222
+ {description && (
223
+ <div className="support-subject__text" title={description}>
224
+ {description}
225
+ </div>
226
+ )}
227
+ </div>
228
+ );
229
+ },
230
+ },
231
+ {
232
+ title: 'Status',
233
+ dataIndex: 'status',
234
+ width: 160,
235
+ render: (value) => <Tag color={STATUS_COLORS[normalize(value)] || 'default'}>{value || '-'}</Tag>,
236
+ },
237
+ {
238
+ title: 'Priority',
239
+ dataIndex: 'priority',
240
+ width: 100,
241
+ render: (value) => <Tag color={PRIORITY_COLORS[normalize(value)] || 'default'}>{value || '-'}</Tag>,
242
+ },
243
+ {
244
+ title: 'Created',
245
+ dataIndex: 'creation',
246
+ width: 120,
247
+ render: (value) => <span title={formatDateTime(value)}>{formatDate(value)}</span>,
248
+ },
249
+ ];
250
+
251
+ function TicketTable({ tickets, onRaiseTicket }) {
252
+ const { tickets: rows, total, page, setPage, loading, error, hasFilters, clearFilters, reload } = tickets;
253
+
254
+ if (error) {
255
+ return (
256
+ <Result
257
+ status="warning"
258
+ title="We couldn't load your tickets"
259
+ subTitle={error}
260
+ extra={
261
+ <Button type="primary" icon={<ReloadOutlined />} onClick={reload}>
262
+ Try again
263
+ </Button>
264
+ }
265
+ />
266
+ );
267
+ }
268
+
269
+ const empty = hasFilters ? (
270
+ <Empty description="No tickets match your filters">
271
+ <Button onClick={clearFilters}>Clear filters</Button>
272
+ </Empty>
273
+ ) : (
274
+ <Empty description="You haven't raised any tickets yet">
275
+ <Button type="primary" icon={<PlusOutlined />} onClick={onRaiseTicket}>
276
+ Raise a ticket
277
+ </Button>
278
+ </Empty>
279
+ );
280
+
281
+ return (
282
+ <Table
283
+ size="middle"
284
+ rowKey="name"
285
+ columns={columns}
286
+ dataSource={rows}
287
+ loading={loading}
288
+ scroll={{ x: 640 }}
289
+ locale={{ emptyText: loading ? <div style={{ height: 120 }} /> : empty }}
290
+ pagination={{
291
+ current: page,
292
+ pageSize: PAGE_SIZE,
293
+ total,
294
+ showSizeChanger: false,
295
+ hideOnSinglePage: true,
296
+ showTotal: (count, [from, to]) => `${from}-${to} of ${count} tickets`,
297
+ onChange: setPage,
298
+ }}
299
+ />
300
+ );
301
+ }
302
+
303
+ // ==============================|| MY TICKETS ||============================== //
304
+
305
+ export default function MyTickets({ config, active, onRaiseTicket }) {
306
+ const tickets = useTickets(config, active);
307
+
308
+ const handleOpenInNewTab = () =>
309
+ openInNewTab(() => buildTicketsPageUrl(config)).catch((err) => message.error(err?.message || 'Unable to open your tickets right now.'));
310
+
311
+ // Reload also works in fallback mode: it makes a new link, so the embedded page reloads.
312
+ const toolbar = (
313
+ <div className="support-toolbar">
314
+ <Button type="link" size="small" icon={<ReloadOutlined />} onClick={tickets.reload} disabled={tickets.loading}>
315
+ Reload
316
+ </Button>
317
+ <Button type="link" size="small" icon={<ExportOutlined />} onClick={handleOpenInNewTab}>
318
+ Open in new tab
319
+ </Button>
320
+ </div>
321
+ );
322
+
323
+ if (tickets.fallbackUrl) {
324
+ return (
325
+ <div className="support-tickets">
326
+ {toolbar}
327
+ <div className="support-tickets__frame">
328
+ <iframe title="My support tickets" src={tickets.fallbackUrl} className="support-frame" />
329
+ </div>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ return (
335
+ <div className="support-tickets">
336
+ {toolbar}
337
+ <TicketFilters tickets={tickets} />
338
+ <div className="support-tickets__body">
339
+ <TicketTable tickets={tickets} onRaiseTicket={onRaiseTicket} />
340
+ </div>
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * "Raise a Ticket" tab: the Helpdesk form in an iframe.
3
+ *
4
+ * The link is minted when the tab opens, so an idle session never holds an expired token.
5
+ *
6
+ * Frappe's session cookie is SameSite=Lax, so some browsers render the embedded form blank.
7
+ * If the form has not loaded after FORM_SLOW_MS we offer "Open in new tab", which opens a
8
+ * fresh link as a top level page.
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from 'react';
11
+ import { Alert, Button, Result, Spin, message } from 'antd';
12
+ import { ExportOutlined, ReloadOutlined } from '@ant-design/icons';
13
+ import { FORM_SLOW_MS, buildFormUrl, openInNewTab } from '../../support-desk.utils';
14
+
15
+ export default function RaiseTicket({ config }) {
16
+ const [url, setUrl] = useState('');
17
+ // minting -> loading (iframe requested) -> ready (iframe finished) | error
18
+ const [phase, setPhase] = useState('minting');
19
+ const [error, setError] = useState('');
20
+ const [slow, setSlow] = useState(false);
21
+
22
+ const requestRef = useRef(0);
23
+
24
+ const load = useCallback(async () => {
25
+ const requestId = requestRef.current + 1;
26
+ requestRef.current = requestId;
27
+
28
+ setPhase('minting');
29
+ setError('');
30
+ setSlow(false);
31
+
32
+ try {
33
+ const next = await buildFormUrl(config);
34
+ if (requestRef.current !== requestId) return;
35
+ setUrl(next);
36
+ setPhase('loading');
37
+ } catch (err) {
38
+ if (requestRef.current !== requestId) return;
39
+ setUrl('');
40
+ setError(err?.message || 'Unable to open the Support form right now.');
41
+ setPhase('error');
42
+ }
43
+ }, [config]);
44
+
45
+ useEffect(() => {
46
+ load();
47
+ // Ignore the result of a request that finishes after unmount.
48
+ return () => {
49
+ requestRef.current += 1;
50
+ };
51
+ }, [load]);
52
+
53
+ // Warn when the form is slow or blank.
54
+ useEffect(() => {
55
+ if (phase !== 'loading') return undefined;
56
+ const timer = setTimeout(() => setSlow(true), FORM_SLOW_MS);
57
+ return () => clearTimeout(timer);
58
+ }, [phase, url]);
59
+
60
+ const handleOpenInNewTab = () =>
61
+ openInNewTab(() => buildFormUrl(config)).catch((err) => message.error(err?.message || 'Unable to open the Support form right now.'));
62
+
63
+ if (phase === 'error') {
64
+ return (
65
+ <Result
66
+ status="warning"
67
+ title="We couldn't open the Support form"
68
+ subTitle={error}
69
+ extra={
70
+ <Button type="primary" icon={<ReloadOutlined />} onClick={load}>
71
+ Try again
72
+ </Button>
73
+ }
74
+ />
75
+ );
76
+ }
77
+
78
+ return (
79
+ <div className="support-raise">
80
+ <div className="support-raise__bar">
81
+ <span>Tell us about your issue and our support team will reply as soon as possible.</span>
82
+ <span>
83
+ <Button type="link" size="small" icon={<ReloadOutlined />} onClick={load} disabled={phase === 'minting'}>
84
+ Reload
85
+ </Button>
86
+ <Button type="link" size="small" icon={<ExportOutlined />} onClick={handleOpenInNewTab}>
87
+ Open in new tab
88
+ </Button>
89
+ </span>
90
+ </div>
91
+
92
+ {slow && phase === 'loading' && (
93
+ <Alert
94
+ banner
95
+ type="warning"
96
+ showIcon
97
+ message="The form is taking longer than usual. If it stays blank, open it in a new tab."
98
+ action={
99
+ <Button size="small" icon={<ReloadOutlined />} onClick={load}>
100
+ Reload
101
+ </Button>
102
+ }
103
+ />
104
+ )}
105
+
106
+ <div className="support-raise__frame">
107
+ {phase !== 'ready' && (
108
+ <div className="support-raise__loading">
109
+ <Spin tip="Preparing the Support form…" />
110
+ </div>
111
+ )}
112
+ {url && <iframe title="Support" src={url} allow="clipboard-write" className="support-frame" onLoad={() => setPhase('ready')} />}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { toPlainText } from './support-desk.utils';
5
+
6
+ describe('toPlainText', () => {
7
+ it('strips markup and collapses spaces', () => {
8
+ expect(toPlainText('<p>Hello <b>world</b></p>\n<p>again</p>')).toBe('Hello world again');
9
+ expect(toPlainText('')).toBe('');
10
+ expect(toPlainText(null)).toBe('');
11
+ });
12
+
13
+ it('does not load or run anything in the markup', async () => {
14
+ window.__pwned = false;
15
+ toPlainText('<img src="x" onerror="window.__pwned = true"><script>window.__pwned = true</script>');
16
+
17
+ await new Promise((resolve) => setTimeout(resolve, 20));
18
+ expect(window.__pwned).toBe(false);
19
+ });
20
+ });