@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +863 -0
  2. package/dist/index.d.mts +2236 -2688
  3. package/dist/index.d.ts +2236 -2688
  4. package/dist/index.js +591 -128
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +590 -127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +17 -10
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
@@ -1,413 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_request — Live approval instance.
7
- *
8
- * ADR-0019: opened by a flow's **Approval node** when the run reaches it; the
9
- * run suspends until a decision is recorded. The row's lifecycle:
10
- *
11
- * `pending` → (per-approver decisions) → `approved` | `rejected`
12
- * `pending` → recalled by submitter → `recalled`
13
- *
14
- * `flow_run_id` / `flow_node_id` tie the request back to the suspended run so a
15
- * decision can resume it; `current_step` mirrors the node id. `node_config_json`
16
- * snapshots the Approval node config (approvers / behaviour) the request was
17
- * opened with.
18
- *
19
- * `payload_json` captures a snapshot of the target record at submission
20
- * time — used by notifications so they can render before the record is
21
- * locked or changed.
22
- *
23
- * @namespace sys
24
- */
25
- export const SysApprovalRequest = ObjectSchema.create({
26
- name: 'sys_approval_request',
27
- label: 'Approval Request',
28
- pluralLabel: 'Approval Requests',
29
- icon: 'inbox',
30
- isSystem: true,
31
- managedBy: 'engine-owned',
32
- description: 'Live approval instance tracked per submission',
33
- displayNameField: 'id',
34
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
35
- titleFormat: '{process_name} · {record_id}',
36
- highlightFields: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
37
-
38
- // Curated built-in list views — render as segmented tabs in the console.
39
- // Filters use {current_user_id} substitution wired by the console.
40
- listViews: {
41
- my_pending: {
42
- type: 'grid',
43
- name: 'my_pending',
44
- label: 'My Pending',
45
- data: { provider: 'object', object: 'sys_approval_request' },
46
- columns: ['process_name', 'object_name', 'record_id', 'current_step', 'submitter_id', 'updated_at'],
47
- filter: [
48
- { field: 'status', operator: 'equals', value: 'pending' },
49
- { field: 'pending_approvers', operator: 'contains', value: '{current_user_id}' },
50
- ],
51
- sort: [{ field: 'updated_at', order: 'desc' }],
52
- pagination: { pageSize: 25 },
53
- emptyState: { title: 'No pending approvals', message: 'You\'re all caught up.' },
54
- },
55
- submitted_by_me: {
56
- type: 'grid',
57
- name: 'submitted_by_me',
58
- label: 'I Submitted',
59
- data: { provider: 'object', object: 'sys_approval_request' },
60
- columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'updated_at'],
61
- filter: [{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }],
62
- sort: [{ field: 'updated_at', order: 'desc' }],
63
- pagination: { pageSize: 25 },
64
- },
65
- completed: {
66
- type: 'grid',
67
- name: 'completed',
68
- label: 'Completed',
69
- data: { provider: 'object', object: 'sys_approval_request' },
70
- columns: ['process_name', 'object_name', 'record_id', 'status', 'submitter_id', 'completed_at'],
71
- filter: [{ field: 'status', operator: 'in', value: ['approved', 'rejected', 'recalled'] }],
72
- sort: [{ field: 'completed_at', order: 'desc' }],
73
- pagination: { pageSize: 25 },
74
- },
75
- all_requests: {
76
- type: 'grid',
77
- name: 'all_requests',
78
- label: 'All',
79
- data: { provider: 'object', object: 'sys_approval_request' },
80
- columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
81
- sort: [{ field: 'updated_at', order: 'desc' }],
82
- pagination: { pageSize: 50 },
83
- },
84
- },
85
-
86
- fields: {
87
- id: Field.text({ label: 'Request ID', required: true, readonly: true, group: 'System' }),
88
-
89
- organization_id: Field.lookup('sys_organization', {
90
- label: 'Organization',
91
- required: false,
92
- group: 'System',
93
- description: 'Tenant that owns this approval request (propagated from submitter context)',
94
- }),
95
-
96
- process_name: Field.text({
97
- label: 'Source',
98
- required: true,
99
- maxLength: 100,
100
- description: 'Origin of the request — `flow:<flowName|nodeId>` for node-driven approvals',
101
- group: 'Target',
102
- }),
103
-
104
- object_name: Field.text({
105
- label: 'Object',
106
- required: true,
107
- maxLength: 100,
108
- group: 'Target',
109
- }),
110
-
111
- record_id: Field.text({
112
- label: 'Record ID',
113
- required: true,
114
- maxLength: 100,
115
- group: 'Target',
116
- }),
117
-
118
- submitter_id: Field.lookup('sys_user', {
119
- label: 'Submitter',
120
- required: false,
121
- group: 'Target',
122
- }),
123
-
124
- submitter_comment: Field.textarea({
125
- label: 'Submitter Comment',
126
- required: false,
127
- group: 'Target',
128
- }),
129
-
130
- status: Field.select(
131
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
132
- // sent back for revision (ADR-0044) — terminal for this round.
133
- ['pending', 'approved', 'rejected', 'recalled', 'returned'],
134
- {
135
- label: 'Status',
136
- required: true,
137
- defaultValue: 'pending',
138
- description: 'Lifecycle state of the request',
139
- group: 'State',
140
- },
141
- ),
142
-
143
- current_step: Field.text({
144
- label: 'Current Step',
145
- required: false,
146
- maxLength: 100,
147
- description: 'Machine name of the step awaiting approval',
148
- group: 'State',
149
- }),
150
-
151
- current_step_index: Field.number({
152
- label: 'Current Step Index',
153
- required: false,
154
- defaultValue: 0,
155
- group: 'State',
156
- }),
157
-
158
- pending_approvers: Field.textarea({
159
- label: 'Pending Approvers',
160
- required: false,
161
- description: 'Comma-separated user ids who can act on the current step',
162
- group: 'State',
163
- }),
164
-
165
- payload_json: Field.textarea({
166
- label: 'Snapshot',
167
- required: false,
168
- description: 'Record snapshot at submission time',
169
- group: 'State',
170
- }),
171
-
172
- // ── ADR-0019: approval-as-flow-node correlation ──────────────────
173
- // When a request is opened by an Approval *node* (rather than a standalone
174
- // process), these tie it back to the suspended flow run so a decision can
175
- // resume it. Null for legacy process-driven requests.
176
- flow_run_id: Field.text({
177
- label: 'Flow Run',
178
- required: false,
179
- maxLength: 100,
180
- readonly: true,
181
- description: 'Suspended automation run id this request gates (ADR-0019). The decision resumes it.',
182
- group: 'State',
183
- }),
184
-
185
- flow_node_id: Field.text({
186
- label: 'Flow Node',
187
- required: false,
188
- maxLength: 100,
189
- readonly: true,
190
- description: 'Approval node id within the flow that opened this request (ADR-0019).',
191
- group: 'State',
192
- }),
193
-
194
- node_config_json: Field.textarea({
195
- label: 'Node Config',
196
- required: false,
197
- readonly: true,
198
- description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).',
199
- group: 'State',
200
- }),
201
-
202
- completed_at: Field.datetime({
203
- label: 'Completed At',
204
- required: false,
205
- group: 'State',
206
- }),
207
-
208
- created_at: Field.datetime({
209
- label: 'Created At',
210
- required: true,
211
- defaultValue: 'NOW()',
212
- readonly: true,
213
- group: 'System',
214
- }),
215
-
216
- updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
217
- },
218
-
219
- indexes: [
220
- // Look up "is there a pending request for this record?" — common
221
- // guard on submit and on edit-while-locked checks.
222
- { fields: ['object_name', 'record_id'] },
223
- { fields: ['status', 'object_name'] },
224
- // Status-windowed listings (escalation sweep, "All" tab ordering).
225
- // "My approvals" matching no longer scans this table: the service keeps
226
- // a normalized per-approver index in `sys_approval_approver` (#1745) and
227
- // resolves approver filters there; `pending_approvers` stays the
228
- // human-readable CSV source of truth only.
229
- { fields: ['status', 'updated_at'] },
230
- { fields: ['submitter_id', 'status'] },
231
- ],
232
-
233
- // Server-declared decision actions (objectui#2678 P2-4). The console's
234
- // generic action runtime renders and executes these wherever this object is
235
- // surfaced — the approvals inbox included — so new decision capabilities
236
- // (and their params) ship as metadata, not as hand-written buttons. Each
237
- // targets the existing approvals REST route; `{id}` resolves from the row
238
- // and `actorId` defaults to the caller server-side. The service remains the
239
- // authority on who may act; `visible` gates on the server-computed
240
- // per-viewer block (#3310): approver actions on `record.viewer.can_act`
241
- // (the caller is a current pending approver — same check the service
242
- // authorizes a decision with, so position/team approvers resolve correctly),
243
- // submitter actions on `record.viewer.is_submitter`. The core decision levers
244
- // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
245
- // (#3424) so a platform/tenant admin can rescue a request routed to an
246
- // unstaffed position — otherwise undecidable, locking the record forever — by
247
- // approving, rejecting, or reassigning it to a real approver. `viewer` is
248
- // attached by getRequest/listRequests; where it is absent the predicate fails
249
- // closed.
250
- actions: [
251
- {
252
- name: 'approval_approve',
253
- label: 'Approve',
254
- icon: 'check-circle',
255
- // Primary decision — the console renders this filled/highlighted so it
256
- // stands out from the secondary levers in the drawer's action bar,
257
- // matching the mobile card hierarchy (objectui#2762 P1-5).
258
- variant: 'primary',
259
- type: 'api',
260
- method: 'POST',
261
- target: '/api/v1/approvals/requests/{id}/approve',
262
- params: [
263
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
264
- // Decision attachments (#3266). The console renders `type:'file'` params
265
- // through the shared upload widget and POSTs the resolved `attachments:
266
- // string[]`; the decision route persists them on `sys_approval_action`.
267
- { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
268
- ],
269
- visible: 'record.viewer.can_act || record.viewer.can_override',
270
- locations: ['record_section', 'list_item'],
271
- successMessage: 'Approved.',
272
- refreshAfter: true,
273
- },
274
- {
275
- name: 'approval_reject',
276
- label: 'Reject',
277
- icon: 'x-circle',
278
- // Destructive decision — rendered in the console's danger styling so it
279
- // reads as the irreversible action it is (objectui#2762 P1-5).
280
- variant: 'danger',
281
- type: 'api',
282
- method: 'POST',
283
- target: '/api/v1/approvals/requests/{id}/reject',
284
- params: [
285
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
286
- { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
287
- ],
288
- visible: 'record.viewer.can_act || record.viewer.can_override',
289
- confirmText: 'Reject this request? A rejection is final for every approver.',
290
- locations: ['record_section', 'list_item'],
291
- successMessage: 'Rejected.',
292
- refreshAfter: true,
293
- },
294
- {
295
- name: 'approval_reassign',
296
- label: 'Reassign',
297
- icon: 'arrow-right-left',
298
- type: 'api',
299
- method: 'POST',
300
- target: '/api/v1/approvals/requests/{id}/reassign',
301
- params: [
302
- // Field-backed on `submitter_id` (the object's only `sys_user` lookup):
303
- // the console resolves its lookup config (`reference_to: sys_user`) so the
304
- // dialog renders a real user picker, while `name: 'to'` overrides the
305
- // request-body key to the `to` the reassign route expects. This is a
306
- // config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
307
- { field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
308
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
309
- ],
310
- visible: 'record.viewer.can_act || record.viewer.can_override',
311
- locations: ['record_section'],
312
- successMessage: 'Reassigned.',
313
- refreshAfter: true,
314
- },
315
-
316
- // ── Approver secondary decisions ────────────────────────────────
317
- // Send back for revision / request more info (ADR-0044). Both are approver
318
- // actions, so `visible` gates on `record.viewer.can_act` (a current pending
319
- // approver) — same as approve/reject. The service stays the authority.
320
- {
321
- name: 'approval_send_back',
322
- label: 'Send back',
323
- icon: 'corner-up-left',
324
- type: 'api',
325
- method: 'POST',
326
- target: '/api/v1/approvals/requests/{id}/revise',
327
- params: [
328
- { name: 'comment', label: 'Reason', type: 'textarea', required: false },
329
- ],
330
- visible: 'record.viewer.can_act',
331
- locations: ['record_section'],
332
- successMessage: 'Sent back for revision.',
333
- refreshAfter: true,
334
- },
335
- {
336
- name: 'approval_request_info',
337
- label: 'Request info',
338
- icon: 'help-circle',
339
- type: 'api',
340
- method: 'POST',
341
- target: '/api/v1/approvals/requests/{id}/request-info',
342
- params: [
343
- { name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
344
- ],
345
- visible: 'record.viewer.can_act',
346
- locations: ['record_section'],
347
- successMessage: 'Information requested.',
348
- refreshAfter: true,
349
- },
350
-
351
- // ── Submitter continuity actions ────────────────────────────────
352
- // Remind / recall (pending) and resubmit / recall (returned). These are the
353
- // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
354
- // (server-computed on the current viewer). The service re-checks ownership;
355
- // the predicate keeps a non-submitter from ever seeing a button they cannot
356
- // use.
357
- {
358
- name: 'approval_remind',
359
- label: 'Send reminder',
360
- icon: 'bell-ring',
361
- type: 'api',
362
- method: 'POST',
363
- target: '/api/v1/approvals/requests/{id}/remind',
364
- params: [
365
- { name: 'comment', label: 'Note', type: 'textarea', required: false },
366
- ],
367
- visible: 'record.status == "pending" && record.viewer.is_submitter',
368
- locations: ['record_section'],
369
- successMessage: 'Reminder sent.',
370
- refreshAfter: true,
371
- },
372
- {
373
- name: 'approval_recall',
374
- label: 'Recall',
375
- icon: 'undo-2',
376
- type: 'api',
377
- method: 'POST',
378
- target: '/api/v1/approvals/requests/{id}/recall',
379
- params: [
380
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
381
- ],
382
- // Recall applies while the request is live for the submitter — pending
383
- // (withdraw) or returned (abandon the revision instead of resubmitting).
384
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
385
- confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
386
- locations: ['record_section'],
387
- successMessage: 'Recalled.',
388
- refreshAfter: true,
389
- },
390
- {
391
- name: 'approval_resubmit',
392
- label: 'Resubmit',
393
- icon: 'refresh-cw',
394
- type: 'api',
395
- method: 'POST',
396
- target: '/api/v1/approvals/requests/{id}/resubmit',
397
- params: [
398
- { name: 'comment', label: 'What changed?', type: 'textarea', required: false },
399
- ],
400
- visible: 'record.status == "returned" && record.viewer.is_submitter',
401
- locations: ['record_section'],
402
- successMessage: 'Resubmitted.',
403
- refreshAfter: true,
404
- },
405
- ],
406
-
407
- enable: {
408
- // [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
409
- // (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
410
- // generic CRUD. Reads stay open.
411
- apiMethods: ['get', 'list'],
412
- },
413
- });
@@ -1,101 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_token — single-use actionable-link tokens (ADR-0043).
7
- *
8
- * One row per issued approve/reject link. Only the SHA-256 **hash** of the
9
- * raw token is stored — a database leak yields no usable links. A token is
10
- * dead once any of these holds: `consumed_at` set, `expires_at` passed, the
11
- * request left `pending`, or the bound approver no longer holds a slot
12
- * (the last two are re-checked at redemption, not materialized here).
13
- *
14
- * @namespace sys
15
- */
16
- export const SysApprovalToken = ObjectSchema.create({
17
- name: 'sys_approval_token',
18
- label: 'Approval Action Token',
19
- pluralLabel: 'Approval Action Tokens',
20
- icon: 'key',
21
- isSystem: true,
22
- managedBy: 'engine-owned',
23
- description: 'Single-use tokens behind actionable approval links',
24
- displayNameField: 'id',
25
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
26
-
27
- fields: {
28
- id: Field.text({ label: 'Token ID', required: true, readonly: true, group: 'System' }),
29
-
30
- organization_id: Field.lookup('sys_organization', {
31
- label: 'Organization',
32
- required: false,
33
- group: 'System',
34
- }),
35
-
36
- token_hash: Field.text({
37
- label: 'Token Hash',
38
- required: true,
39
- maxLength: 100,
40
- readonly: true,
41
- description: 'SHA-256 hex of the raw token — the raw value is never stored',
42
- group: 'Token',
43
- }),
44
-
45
- request_id: Field.text({
46
- label: 'Request',
47
- required: true,
48
- maxLength: 100,
49
- readonly: true,
50
- group: 'Token',
51
- }),
52
-
53
- action: Field.select(['approve', 'reject'], {
54
- label: 'Action',
55
- required: true,
56
- readonly: true,
57
- group: 'Token',
58
- }),
59
-
60
- approver_id: Field.text({
61
- label: 'Approver',
62
- required: true,
63
- maxLength: 200,
64
- readonly: true,
65
- description: 'Identity the token is bound to; the decision is audited as this approver',
66
- group: 'Token',
67
- }),
68
-
69
- expires_at: Field.datetime({
70
- label: 'Expires At',
71
- required: true,
72
- readonly: true,
73
- group: 'Lifecycle',
74
- }),
75
-
76
- consumed_at: Field.datetime({
77
- label: 'Consumed At',
78
- required: false,
79
- group: 'Lifecycle',
80
- }),
81
-
82
- created_at: Field.datetime({
83
- label: 'Created At',
84
- required: true,
85
- defaultValue: 'NOW()',
86
- readonly: true,
87
- group: 'System',
88
- }),
89
- },
90
-
91
- indexes: [
92
- { fields: ['token_hash'] },
93
- { fields: ['request_id'] },
94
- ],
95
-
96
- enable: {
97
- // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
98
- // consumed by the approval engine (SYSTEM_CTX), never via the data API.
99
- apiMethods: ['get', 'list'],
100
- },
101
- });
@@ -1,48 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
- //
3
- // Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
4
- // object-translation bundles must carry ONLY objects its extract config
5
- // (`scripts/i18n-extract.config.ts`) actually imports. When an object moves to
6
- // another package, its translations move with it — a leftover copy here
7
- // silently DIES on the next `os i18n extract` run, taking curated translations
8
- // with it (the sys_audit_log incident). This test turns that silent loss into a
9
- // red build: an object present in the bundle but not in the ownership list below
10
- // means either (a) the extract config gained an object — add it here — or (b) a
11
- // moved/removed object's keys were left behind — remove them from the bundles
12
- // (or migrate them to the owning package), then keep this list in sync.
13
- //
14
- // NB: this package defines more approval objects (sys_approval_approver,
15
- // sys_approval_token) that the extract config deliberately does NOT translate —
16
- // they are absent from both the config and the bundles, so they are correctly
17
- // out of scope here.
18
-
19
- import { describe, it, expect } from 'vitest';
20
- import { enObjects } from './en.objects.generated.js';
21
-
22
- // Objects the extract config (scripts/i18n-extract.config.ts) imports —
23
- // keep the two lists in sync when adding/moving objects.
24
- const OWNED_OBJECTS = new Set([
25
- 'sys_approval_request',
26
- 'sys_approval_action',
27
- 'sys_approval_delegation',
28
- ]);
29
-
30
- describe('objects translation bundle ownership (ADR-0029 D8)', () => {
31
- it('the en bundle contains no objects owned by other packages', () => {
32
- const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
33
- expect(
34
- strays,
35
- `bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
36
- 'their curated translations would be silently deleted on the next `os i18n extract`. ' +
37
- 'Remove the dead block from the four locale bundles, or add the object to the extract config + this list.',
38
- ).toEqual([]);
39
- });
40
-
41
- it('every owned object is present in the bundle (extract config regression)', () => {
42
- const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
43
- expect(
44
- missing,
45
- `objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
46
- ).toEqual([]);
47
- });
48
- });