mgv-backoffice 1.43.0 → 1.43.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.43.0",
3
+ "version": "1.43.2",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -1,241 +1,241 @@
1
- <script setup lang="ts">
2
- import { onMounted, reactive, ref } from 'vue'
3
- import BaseButton from './BaseButton.vue'
4
- import BaseInput from './BaseInput.vue'
5
- import BaseRow from './BaseRow.vue'
6
- import BaseSpinner from './BaseSpinner.vue'
7
- import { BaseButtonEnum } from '../enums/BaseButtonEnum'
8
-
9
- // One service's API-credentials card (extracted from TradeAutomation's
10
- // Settings page, where three venue blocks shared this exact shape). Owns the
11
- // form state, the has-secret handling, the save-validation ladder and the
12
- // template; parameterised by the service-specific bits (labels, copy,
13
- // endpoints, default URLs). Load/save results are EMITTED — the parent owns
14
- // toasts / error banners.
15
-
16
- // The non-secret credentials view the service's config endpoint returns. The
17
- // secret is never returned — `hasSecret` reports whether one is stored.
18
- export interface CredentialsView {
19
- keyId: string | null
20
- baseUrl: string
21
- dataUrl: string
22
- hasSecret: boolean
23
- }
24
-
25
- export interface CredentialsUpdate {
26
- keyId: string
27
- // Omitted when blank so the server keeps the currently-stored secret.
28
- secretKey?: string
29
- baseUrl: string
30
- dataUrl: string
31
- }
32
-
33
- const props = withDefaults(
34
- defineProps<{
35
- // Card heading, e.g. "Alpaca API"; subtitle renders under it when set.
36
- title: string
37
- subtitle?: string
38
- // Unique prefix for the input ids so the label/for pairs stay unambiguous
39
- // when several of these cards render on one page.
40
- idPrefix: string
41
- // Service-specific labels / placeholders / messages.
42
- keyLabel?: string
43
- keyPlaceholder?: string
44
- secretLabel?: string
45
- // Placeholder while NO secret is stored (a stored one shows the dots).
46
- secretPlaceholder?: string
47
- // Help line while a secret IS stored.
48
- secretSetHint?: string
49
- // Help line while no secret is stored (e.g. which API permissions the key
50
- // needs). Override the `no-secret-hint` slot instead when markup is needed.
51
- permissionsHint?: string
52
- requiredKeyMessage?: string
53
- requiredSecretMessage?: string
54
- savedMessage?: string
55
- saveLabel?: string
56
- // Service endpoints + the URLs the form falls back to when the server has
57
- // none stored yet.
58
- fetchFn: () => Promise<CredentialsView>
59
- updateFn: (body: CredentialsUpdate) => Promise<CredentialsView>
60
- defaults: { baseUrl: string; dataUrl: string }
61
- }>(),
62
- {
63
- subtitle: '',
64
- keyLabel: 'API Key',
65
- keyPlaceholder: '',
66
- secretLabel: 'Secret Key',
67
- secretPlaceholder: 'Enter secret key',
68
- secretSetHint: 'A secret is currently set. Leave blank to keep it.',
69
- permissionsHint: '',
70
- requiredKeyMessage: 'API key is required.',
71
- requiredSecretMessage: 'Secret key is required.',
72
- savedMessage: 'Credentials saved.',
73
- saveLabel: 'Save credentials',
74
- },
75
- )
76
-
77
- const emit = defineEmits<{
78
- // Save outcomes → parent toasts.
79
- (e: 'saved', message: string): void
80
- (e: 'error', message: string): void
81
- // Initial/re-load failure. Payload is the raw error message ('' when there
82
- // is none) — the parent decides how loudly to surface it.
83
- (e: 'load-error', message: string): void
84
- }>()
85
-
86
- const saving = ref(false)
87
- const hasSecret = ref(false)
88
-
89
- const form = reactive({
90
- keyId: '',
91
- secretKey: '',
92
- baseUrl: props.defaults.baseUrl,
93
- dataUrl: props.defaults.dataUrl,
94
- })
95
-
96
- async function load() {
97
- try {
98
- const cfg = await props.fetchFn()
99
- form.keyId = cfg.keyId ?? ''
100
- form.baseUrl = cfg.baseUrl || props.defaults.baseUrl
101
- form.dataUrl = cfg.dataUrl || props.defaults.dataUrl
102
- form.secretKey = '' // never returned by the API
103
- hasSecret.value = cfg.hasSecret
104
- } catch (e: unknown) {
105
- emit('load-error', e instanceof Error ? e.message : '')
106
- }
107
- }
108
-
109
- function isBlank(v: string): boolean {
110
- return !v || !v.trim()
111
- }
112
-
113
- async function save() {
114
- // Re-entrancy guard: a save may trigger a LIVE credential validation
115
- // against the service, so a double-click must not fire two concurrent PUTs.
116
- // Paired with the button's isDisable below — this is the belt to its braces.
117
- if (saving.value) return
118
- if (isBlank(form.keyId)) {
119
- emit('error', props.requiredKeyMessage)
120
- return
121
- }
122
- if (!hasSecret.value && isBlank(form.secretKey)) {
123
- emit('error', props.requiredSecretMessage)
124
- return
125
- }
126
- if (isBlank(form.baseUrl) || isBlank(form.dataUrl)) {
127
- emit('error', 'Base URL and Data URL are required.')
128
- return
129
- }
130
- saving.value = true
131
- try {
132
- const applied = await props.updateFn({
133
- keyId: form.keyId.trim(),
134
- // Omit when blank so the server keeps the stored secret.
135
- secretKey: isBlank(form.secretKey) ? undefined : form.secretKey.trim(),
136
- baseUrl: form.baseUrl.trim(),
137
- dataUrl: form.dataUrl.trim(),
138
- })
139
- hasSecret.value = applied.hasSecret
140
- form.secretKey = ''
141
- emit('saved', props.savedMessage)
142
- } catch (e: unknown) {
143
- emit('error', e instanceof Error ? e.message : 'Failed to save')
144
- } finally {
145
- saving.value = false
146
- }
147
- }
148
-
149
- onMounted(load)
150
-
151
- // The parent's Reload button can re-pull every card in parallel via this.
152
- defineExpose({ load })
153
-
154
- // One-off styling on top of BaseInput's shared field skin.
155
- const inputClass = 'placeholder:text-slate-400 dark:placeholder:text-slate-500 font-mono'
156
- </script>
157
-
158
- <template>
159
- <BaseRow class="!p-6">
160
- <div class="flex items-center justify-between mb-4">
161
- <div>
162
- <h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
163
- <p v-if="subtitle" class="text-xs text-slate-500 dark:text-slate-400">
164
- {{ subtitle }}
165
- </p>
166
- </div>
167
- <span v-if="saving" class="inline-flex items-center gap-2 text-xs text-slate-500">
168
- <BaseSpinner class="!h-4 !w-4" />
169
- Saving…
170
- </span>
171
- </div>
172
-
173
- <div class="grid gap-4 md:grid-cols-2">
174
- <div class="md:col-span-2">
175
- <label
176
- :for="`${idPrefix}-key-id`"
177
- class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
178
- >{{ keyLabel }}</label>
179
- <BaseInput
180
- :id="`${idPrefix}-key-id`"
181
- v-model="form.keyId"
182
- type="text"
183
- autocomplete="off"
184
- :placeholder="keyPlaceholder"
185
- :class="inputClass"
186
- />
187
- </div>
188
-
189
- <div class="md:col-span-2">
190
- <label
191
- :for="`${idPrefix}-secret`"
192
- class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
193
- >{{ secretLabel }}</label>
194
- <BaseInput
195
- :id="`${idPrefix}-secret`"
196
- v-model="form.secretKey"
197
- type="password"
198
- autocomplete="new-password"
199
- :placeholder="hasSecret ? '•••••••• (unchanged — type to replace)' : secretPlaceholder"
200
- :class="inputClass"
201
- />
202
- <p class="text-[11px] text-slate-500 mt-1">
203
- <template v-if="hasSecret">{{ secretSetHint }}</template>
204
- <template v-else><slot name="no-secret-hint">{{ permissionsHint }}</slot></template>
205
- </p>
206
- </div>
207
-
208
- <div class="md:col-span-2">
209
- <label
210
- :for="`${idPrefix}-base-url`"
211
- class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
212
- >Base URL (trading)</label>
213
- <BaseInput :id="`${idPrefix}-base-url`" v-model="form.baseUrl" type="text" :class="inputClass" />
214
- <!-- Service-specific extras under the Base URL field (e.g. live/paper
215
- shortcut buttons). Scoped so the parent can write into the form. -->
216
- <slot name="base-url-extra" :form="form" />
217
- </div>
218
-
219
- <div class="md:col-span-2">
220
- <label
221
- :for="`${idPrefix}-data-url`"
222
- class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
223
- >Data URL (market data)</label>
224
- <BaseInput :id="`${idPrefix}-data-url`" v-model="form.dataUrl" type="text" :class="inputClass" />
225
- </div>
226
-
227
- <div class="md:col-span-2">
228
- <BaseButton
229
- :color="BaseButtonEnum.GREEN"
230
- :description="saveLabel"
231
- :isDisable="saving"
232
- :isLoading="saving"
233
- @click="save"
234
- />
235
- </div>
236
- </div>
237
-
238
- <!-- Service-specific extras below the credentials form. -->
239
- <slot name="footer" />
240
- </BaseRow>
241
- </template>
1
+ <script setup lang="ts">
2
+ import { onMounted, reactive, ref } from 'vue'
3
+ import BaseButton from './BaseButton.vue'
4
+ import BaseInput from './BaseInput.vue'
5
+ import BaseRow from './BaseRow.vue'
6
+ import BaseSpinner from './BaseSpinner.vue'
7
+ import { BaseButtonEnum } from '../enums/BaseButtonEnum'
8
+
9
+ // One service's API-credentials card (extracted from TradeAutomation's
10
+ // Settings page, where three venue blocks shared this exact shape). Owns the
11
+ // form state, the has-secret handling, the save-validation ladder and the
12
+ // template; parameterised by the service-specific bits (labels, copy,
13
+ // endpoints, default URLs). Load/save results are EMITTED — the parent owns
14
+ // toasts / error banners.
15
+
16
+ // The non-secret credentials view the service's config endpoint returns. The
17
+ // secret is never returned — `hasSecret` reports whether one is stored.
18
+ export interface CredentialsView {
19
+ keyId: string | null
20
+ baseUrl: string
21
+ dataUrl: string
22
+ hasSecret: boolean
23
+ }
24
+
25
+ export interface CredentialsUpdate {
26
+ keyId: string
27
+ // Omitted when blank so the server keeps the currently-stored secret.
28
+ secretKey?: string
29
+ baseUrl: string
30
+ dataUrl: string
31
+ }
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ // Card heading, e.g. "Alpaca API"; subtitle renders under it when set.
36
+ title: string
37
+ subtitle?: string
38
+ // Unique prefix for the input ids so the label/for pairs stay unambiguous
39
+ // when several of these cards render on one page.
40
+ idPrefix: string
41
+ // Service-specific labels / placeholders / messages.
42
+ keyLabel?: string
43
+ keyPlaceholder?: string
44
+ secretLabel?: string
45
+ // Placeholder while NO secret is stored (a stored one shows the dots).
46
+ secretPlaceholder?: string
47
+ // Help line while a secret IS stored.
48
+ secretSetHint?: string
49
+ // Help line while no secret is stored (e.g. which API permissions the key
50
+ // needs). Override the `no-secret-hint` slot instead when markup is needed.
51
+ permissionsHint?: string
52
+ requiredKeyMessage?: string
53
+ requiredSecretMessage?: string
54
+ savedMessage?: string
55
+ saveLabel?: string
56
+ // Service endpoints + the URLs the form falls back to when the server has
57
+ // none stored yet.
58
+ fetchFn: () => Promise<CredentialsView>
59
+ updateFn: (body: CredentialsUpdate) => Promise<CredentialsView>
60
+ defaults: { baseUrl: string; dataUrl: string }
61
+ }>(),
62
+ {
63
+ subtitle: '',
64
+ keyLabel: 'API Key',
65
+ keyPlaceholder: '',
66
+ secretLabel: 'Secret Key',
67
+ secretPlaceholder: 'Enter secret key',
68
+ secretSetHint: 'A secret is currently set. Leave blank to keep it.',
69
+ permissionsHint: '',
70
+ requiredKeyMessage: 'API key is required.',
71
+ requiredSecretMessage: 'Secret key is required.',
72
+ savedMessage: 'Credentials saved.',
73
+ saveLabel: 'Save credentials',
74
+ },
75
+ )
76
+
77
+ const emit = defineEmits<{
78
+ // Save outcomes → parent toasts.
79
+ (e: 'saved', message: string): void
80
+ (e: 'error', message: string): void
81
+ // Initial/re-load failure. Payload is the raw error message ('' when there
82
+ // is none) — the parent decides how loudly to surface it.
83
+ (e: 'load-error', message: string): void
84
+ }>()
85
+
86
+ const saving = ref(false)
87
+ const hasSecret = ref(false)
88
+
89
+ const form = reactive({
90
+ keyId: '',
91
+ secretKey: '',
92
+ baseUrl: props.defaults.baseUrl,
93
+ dataUrl: props.defaults.dataUrl,
94
+ })
95
+
96
+ async function load() {
97
+ try {
98
+ const cfg = await props.fetchFn()
99
+ form.keyId = cfg.keyId ?? ''
100
+ form.baseUrl = cfg.baseUrl || props.defaults.baseUrl
101
+ form.dataUrl = cfg.dataUrl || props.defaults.dataUrl
102
+ form.secretKey = '' // never returned by the API
103
+ hasSecret.value = cfg.hasSecret
104
+ } catch (e: unknown) {
105
+ emit('load-error', e instanceof Error ? e.message : '')
106
+ }
107
+ }
108
+
109
+ function isBlank(v: string): boolean {
110
+ return !v || !v.trim()
111
+ }
112
+
113
+ async function save() {
114
+ // Re-entrancy guard: a save may trigger a LIVE credential validation
115
+ // against the service, so a double-click must not fire two concurrent PUTs.
116
+ // Paired with the button's isDisable below — this is the belt to its braces.
117
+ if (saving.value) return
118
+ if (isBlank(form.keyId)) {
119
+ emit('error', props.requiredKeyMessage)
120
+ return
121
+ }
122
+ if (!hasSecret.value && isBlank(form.secretKey)) {
123
+ emit('error', props.requiredSecretMessage)
124
+ return
125
+ }
126
+ if (isBlank(form.baseUrl) || isBlank(form.dataUrl)) {
127
+ emit('error', 'Base URL and Data URL are required.')
128
+ return
129
+ }
130
+ saving.value = true
131
+ try {
132
+ const applied = await props.updateFn({
133
+ keyId: form.keyId.trim(),
134
+ // Omit when blank so the server keeps the stored secret.
135
+ secretKey: isBlank(form.secretKey) ? undefined : form.secretKey.trim(),
136
+ baseUrl: form.baseUrl.trim(),
137
+ dataUrl: form.dataUrl.trim(),
138
+ })
139
+ hasSecret.value = applied.hasSecret
140
+ form.secretKey = ''
141
+ emit('saved', props.savedMessage)
142
+ } catch (e: unknown) {
143
+ emit('error', e instanceof Error ? e.message : 'Failed to save')
144
+ } finally {
145
+ saving.value = false
146
+ }
147
+ }
148
+
149
+ onMounted(load)
150
+
151
+ // The parent's Reload button can re-pull every card in parallel via this.
152
+ defineExpose({ load })
153
+
154
+ // One-off styling on top of BaseInput's shared field skin.
155
+ const inputClass = 'placeholder:text-slate-400 dark:placeholder:text-slate-500 font-mono'
156
+ </script>
157
+
158
+ <template>
159
+ <BaseRow class="!p-6">
160
+ <div class="flex items-center justify-between mb-4">
161
+ <div>
162
+ <h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
163
+ <p v-if="subtitle" class="text-xs text-slate-500 dark:text-slate-400">
164
+ {{ subtitle }}
165
+ </p>
166
+ </div>
167
+ <span v-if="saving" class="inline-flex items-center gap-2 text-xs text-slate-500">
168
+ <BaseSpinner class="!h-4 !w-4" />
169
+ Saving…
170
+ </span>
171
+ </div>
172
+
173
+ <div class="grid gap-4 md:grid-cols-[repeat(2,minmax(0,1fr))]">
174
+ <div class="md:col-span-2">
175
+ <label
176
+ :for="`${idPrefix}-key-id`"
177
+ class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
178
+ >{{ keyLabel }}</label>
179
+ <BaseInput
180
+ :id="`${idPrefix}-key-id`"
181
+ v-model="form.keyId"
182
+ type="text"
183
+ autocomplete="off"
184
+ :placeholder="keyPlaceholder"
185
+ :class="inputClass"
186
+ />
187
+ </div>
188
+
189
+ <div class="md:col-span-2">
190
+ <label
191
+ :for="`${idPrefix}-secret`"
192
+ class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
193
+ >{{ secretLabel }}</label>
194
+ <BaseInput
195
+ :id="`${idPrefix}-secret`"
196
+ v-model="form.secretKey"
197
+ type="password"
198
+ autocomplete="new-password"
199
+ :placeholder="hasSecret ? '•••••••• (unchanged — type to replace)' : secretPlaceholder"
200
+ :class="inputClass"
201
+ />
202
+ <p class="text-[11px] text-slate-500 mt-1">
203
+ <template v-if="hasSecret">{{ secretSetHint }}</template>
204
+ <template v-else><slot name="no-secret-hint">{{ permissionsHint }}</slot></template>
205
+ </p>
206
+ </div>
207
+
208
+ <div class="md:col-span-2">
209
+ <label
210
+ :for="`${idPrefix}-base-url`"
211
+ class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
212
+ >Base URL (trading)</label>
213
+ <BaseInput :id="`${idPrefix}-base-url`" v-model="form.baseUrl" type="text" :class="inputClass" />
214
+ <!-- Service-specific extras under the Base URL field (e.g. live/paper
215
+ shortcut buttons). Scoped so the parent can write into the form. -->
216
+ <slot name="base-url-extra" :form="form" />
217
+ </div>
218
+
219
+ <div class="md:col-span-2">
220
+ <label
221
+ :for="`${idPrefix}-data-url`"
222
+ class="block text-xs uppercase tracking-wide text-slate-500 mb-1"
223
+ >Data URL (market data)</label>
224
+ <BaseInput :id="`${idPrefix}-data-url`" v-model="form.dataUrl" type="text" :class="inputClass" />
225
+ </div>
226
+
227
+ <div class="md:col-span-2">
228
+ <BaseButton
229
+ :color="BaseButtonEnum.GREEN"
230
+ :description="saveLabel"
231
+ :isDisable="saving"
232
+ :isLoading="saving"
233
+ @click="save"
234
+ />
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Service-specific extras below the credentials form. -->
239
+ <slot name="footer" />
240
+ </BaseRow>
241
+ </template>
@@ -29,8 +29,8 @@ describe('BaseDetailList', () => {
29
29
  })
30
30
 
31
31
  it('switches the grid columns', () => {
32
- expect(mount(BaseDetailList, { props: { items: [], columns: 1 } }).classes()).not.toContain('sm:grid-cols-2')
33
- expect(mount(BaseDetailList, { props: { items: [] } }).classes()).toContain('sm:grid-cols-2')
34
- expect(mount(BaseDetailList, { props: { items: [], columns: 3 } }).classes()).toContain('lg:grid-cols-3')
32
+ expect(mount(BaseDetailList, { props: { items: [], columns: 1 } }).classes()).not.toContain('sm:grid-cols-[repeat(2,minmax(0,1fr))]')
33
+ expect(mount(BaseDetailList, { props: { items: [] } }).classes()).toContain('sm:grid-cols-[repeat(2,minmax(0,1fr))]')
34
+ expect(mount(BaseDetailList, { props: { items: [], columns: 3 } }).classes()).toContain('lg:grid-cols-[repeat(3,minmax(0,1fr))]')
35
35
  })
36
36
  })
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <dl class="grid grid-cols-1 gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-2' : columns === 3 ? 'sm:grid-cols-2 lg:grid-cols-3' : ''">
2
+ <dl class="grid grid-cols-1 gap-x-6 gap-y-4 text-left" :class="columns === 2 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))]' : columns === 3 ? 'sm:grid-cols-[repeat(2,minmax(0,1fr))] lg:grid-cols-[repeat(3,minmax(0,1fr))]' : ''">
3
3
  <div v-for="item in items" :key="item.label" class="min-w-0">
4
4
  <dt class="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{{ item.label }}</dt>
5
5
  <dd class="mt-1 break-words text-sm text-slate-900 dark:text-slate-100" :class="item.mono ? 'font-mono' : ''">
@@ -18,6 +18,10 @@
18
18
  </template>
19
19
 
20
20
  <script setup lang="ts">
21
+ // Column classes use the arbitrary-value form on purpose (see
22
+ // noSharedResponsiveGrid.test.ts): plain responsive column utilities would ship
23
+ // in the lib CSS, which consumers import AFTER their own utilities, and override
24
+ // the consumers' larger-breakpoint grid columns.
21
25
  /**
22
26
  * Read-only label/value grid for detail pages — the "view" counterpart of a
23
27
  * form. Empty values render `emptyText` (—); `href` makes the value an
@@ -60,7 +60,7 @@ function onIntegerPaste(e: ClipboardEvent) {
60
60
  </script>
61
61
 
62
62
  <template>
63
- <div class="grid gap-3 md:grid-cols-2">
63
+ <div class="grid gap-3 md:grid-cols-[repeat(2,minmax(0,1fr))]">
64
64
  <div v-for="spec in specs" :key="spec.key">
65
65
  <label
66
66
  v-if="spec.type !== 'boolean'"
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ // Consumers import the lib CSS AFTER their own Tailwind utilities. A plain
6
+ // responsive column utility (breakpoint prefix + grid-cols + a number) in a lib
7
+ // source lands in that later stylesheet and overrides the consumer's own
8
+ // larger-breakpoint columns at every width (equal specificity → later rule
9
+ // wins). Lib sources must use the arbitrary-value form instead, e.g.
10
+ // grid-cols-[repeat(2,minmax(0,1fr))] behind the breakpoint prefix.
11
+ // Test files are excluded from Tailwind's scan (@source not in style.css), so
12
+ // they're skipped here too. No literal class tokens in this file on purpose.
13
+ const PLAIN = new RegExp(String.raw`\b(?:sm|md|lg|xl|2xl):grid-cols-\d+\b`, 'g')
14
+
15
+ function sources(dir: string): string[] {
16
+ return readdirSync(dir).flatMap((f) => {
17
+ const p = join(dir, f)
18
+ if (statSync(p).isDirectory()) return sources(p)
19
+ return /\.(vue|ts|css)$/.test(f) && !f.endsWith('.test.ts') ? [p] : []
20
+ })
21
+ }
22
+
23
+ describe('lib sources', () => {
24
+ it('use no plain responsive grid-cols-N utilities', () => {
25
+ const offenders = sources(join(__dirname, '..')).flatMap((p) =>
26
+ [...readFileSync(p, 'utf8').matchAll(PLAIN)].map((m) => `${p}: ${m[0]}`),
27
+ )
28
+ expect(offenders).toEqual([])
29
+ })
30
+ })