Repository navigation
Expand file tree
/
Copy pathvite.config.ts
More file actions
executable file
·345 lines (331 loc) · 15.2 KB
/
Copy pathvite.config.ts
File metadata and controls
executable file
·345 lines (331 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
import { playwright } from "@vitest/browser-playwright";
import react from "@vitejs/plugin-react";
import { defaultExclude, defineConfig } from "vitest/config";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { defaultClientConditions, defaultServerConditions } from "vite";
import type { Plugin } from "vite";
import {
BUILD_TARGET,
SOURCE_CONDITION,
scopedClassName,
} from "./vite.shared.ts";
import { bootScript } from "./src/bootDocument.ts";
/**
* Injects the theme and locale boot script into every shell, in dev and build.
* Blocking and classic on purpose: type module, defer or async would each run it
* after first paint and fail silently. "head" rather than "head-prepend" keeps
* the charset meta in the first 1024 bytes.
*/
function bootDocumentScript(): Plugin {
return {
name: "yart-boot-script",
transformIndexHtml: {
order: "pre",
handler() {
return [{ tag: "script", children: bootScript(), injectTo: "head" }];
},
},
};
}
/**
* Adds a Content-Security-Policy to the built shell.
*
* Delivered as a meta element because the app ships as a static bundle to a
* host whose response headers it does not control. That costs three directives
* a header would carry: frame-ancestors, report-uri and sandbox are all ignored
* in a policy delivered this way, so clickjacking stays outside what this can
* express.
*
* It hashes the script the boot plugin injects. A host setting its own policy
* must add that hash to script-src, or the script is dropped silently.
*
* Build only, so the dev server is unaffected. It serves styles as injected
* style elements, which this policy does not allow and which the built page
* never contains.
*/
function contentSecurityPolicy(): Plugin {
return {
name: "yart-content-security-policy",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html) {
const inline = [
...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g),
];
// A length check narrows neither the match nor its capture group, so
// the guard reads the group itself. Hashing an undefined would name a
// script the document does not carry, and the browser refuses the real
// one, which brings back the flash the inline script exists to prevent.
const source = inline[0]?.[1];
if (inline.length !== 1 || source === undefined) {
throw new Error(
`expected exactly one inline script to hash, found ${inline.length}`,
);
}
const digest = createHash("sha256")
.update(source, "utf8")
.digest("base64");
return [
{
tag: "meta",
// Before the script it names, because a policy delivered this way
// governs only what follows it in the document.
injectTo: "head-prepend",
attrs: {
"http-equiv": "Content-Security-Policy",
content: [
// Everything the page does not do is denied by the default, so
// a directive below is a statement that the page needs it.
"default-src 'none'",
`script-src 'sha256-${digest}' 'self'`,
"style-src 'self'",
"img-src 'self'",
// The dataset, which is the only request the running page makes.
"connect-src 'self'",
"manifest-src 'self'",
"base-uri 'none'",
"form-action 'none'",
].join("; "),
},
},
];
},
},
};
}
/**
* Adds a preload for the dataset asset to the built shell.
*
* The dataset is requested by a module that cannot run until the entry chunk
* has downloaded and parsed, so the largest thing the page needs is the last
* thing it asks for. The link below moves that request to the shell, where the
* preload scanner reaches it immediately and the two downloads overlap.
*
* The name is content-hashed and exists only once the bundle does, so this reads
* it off the build instead of index.html carrying it by hand. A shell carrying a
* stale hash would preload a file that is not there, pay for the request and
* still fetch the real one afterwards, so the lookup throws when it finds
* anything but one match.
*
* The lookup is per entry chunk, because context.bundle is the whole build and a
* name filter over it matches the same asset for both shells.
*
* importedAssets is attributed to the chunk that imports the asset, so a dataset
* module landing in the shared chunk would leave the entry's set empty and throw
* on a count of zero. Each is reached from exactly one entry today.
*/
function preloadDataset(): Plugin {
return {
name: "yart-preload-dataset",
apply: "build",
transformIndexHtml: {
order: "post",
handler(_html, context) {
const datasets = [
...(context.chunk?.viteMetadata?.importedAssets ?? []),
].filter((fileName) => fileName.endsWith(".json"));
// A length check does not narrow an index access under
// noUncheckedIndexedAccess, so the guard rides on a destructured
// binding instead of on the index at the use site.
const [dataset] = datasets;
if (!dataset || datasets.length !== 1) {
throw new Error(
`expected exactly one emitted dataset asset, found ${datasets.length}`,
);
}
return [
{
tag: "link",
injectTo: "head",
attrs: {
rel: "preload",
as: "fetch",
// Required for an as="fetch" preload to be reused. Without it the
// asset downloads a second time, which here costs more than the
// preload saves.
crossorigin: "anonymous",
// Written relative, because the base this build uses is relative
// and a tag injected at this point is past the rewrite that would
// otherwise apply it.
href: `./${dataset}`,
},
},
];
},
},
};
}
// https://vitejs.dev/config/
export default defineConfig({
// What a default import from a CommonJS module resolves to changed in this
// major. It is module.exports when the importer is .mjs or .mts, or the
// closest manifest declares the module type, or the importee does not mark
// itself as an ES module, and module.exports.default otherwise. This manifest
// declares the module type, so the second condition fires for every file
// under src/.
//
// Nothing the browser bundle contains is reached by that change. The view
// library and its DOM renderer are the CommonJS-only packages src/ depends on,
// and they are never default-imported; every import of them here is named. The
// pre-bundled set was read out of the optimizer's own output directory and is
// exactly those two, the DOM client entry, the two icon subpaths and the two
// JSX runtimes.
//
// Test-only packages are default-imported in places, and none of them reaches
// that set, because the app optimizer scans the entry graph rather than the
// test files. The accessibility engine, axe-core, in the two axe test files,
// ships no exports map and no module type, so it resolves as CommonJS and the
// third condition fires for it. The CSS processor, postcss, in the token
// guard, resolves through its own exports map to lib/postcss.mjs, so no
// interop rule applies to it at all.
//
// That measurement is why legacy.inconsistentCjsInterop, the deprecated
// opt-out back to the previous behavior, is deliberately declined here. The
// evidence it is not needed is the whole suite staying green across the bump,
// coverage included.
// The plugin is taken at 6.x and taken bare. Its three peers other than the
// bundler are every one of them optional, so none of them is installed by
// taking it: oxc-transform-react is the Rust port of React Compiler, reached
// through the compiler option, and @rolldown/plugin-babel together with
// babel-plugin-react-compiler is the Babel route to the same adoption through
// the exported reactCompilerPreset. So what is declined here is an
// experimental compiler. The JSX transform is Oxc's, arrives with the plugin
// itself and needs no peer at all, which is also why this major drops the
// refresh runtime out of the tree instead of adding to it. Babel is still
// installed, at @babel/core, but it arrives through the lint plugin's
// dependency edge and not through this one, and was here before this bump as
// well. Adopting the compiler is a change of its own with
// its own gate run, so the option stays unset and the peers stay uninstalled.
plugins: [
react(),
bootDocumentScript(),
preloadDataset(),
contentSecurityPolicy(),
],
// Relative, because one build has to serve from two addresses: the root, which
// is where `vite preview` serves it and therefore where both browser suites
// drive it, and /YART/, the repository subpath GitHub Pages serves a project
// site from. A literal base would move the preview address out from under the
// suites, and reading the base off an environment variable would leave the
// built artifact the pipeline measures different from the one it publishes.
//
// Safe here because the app writes its own address from window.location, so
// nothing in the tree assumes the root.
base: "./",
// Custom conditions replace Vite's defaults, so the defaults are spread back.
resolve: { conditions: [SOURCE_CONDITION, ...defaultClientConditions] },
ssr: {
resolve: { conditions: [SOURCE_CONDITION, ...defaultServerConditions] },
},
css: { modules: { generateScopedName: scopedClassName } },
build: {
target: BUILD_TARGET,
rollupOptions: {
// Declaring an input replaces the implicit single-shell one, so the
// original shell has to be named here or it stops being built. Both stay
// at the repository root, because a nested shell would resolve the
// relative asset prefix this build emits one directory too deep.
input: {
index: resolve(import.meta.dirname, "index.html"),
movies: resolve(import.meta.dirname, "movies.html"),
},
},
},
test: {
coverage: {
// Declared once at root level, because the runner rejects coverage options
// on a project. That is also how the gate ends up measured over the
// deterministic jsdom suite alone, since the only command that asks for
// coverage is the one scoped to that project.
provider: "v8",
// lcov for the Sonar import, text so a local run says the same thing the
// gate will. The default reporters write html into coverage/ as well,
// which is noise for a directory CI only reads one file out of.
reporter: ["text", "lcov"],
// Coverage is reported over the application source alone. Without this the
// report covers only files a test happened to import, so deleting the last
// test that touched a module would raise the percentage.
include: ["src/**/*.{ts,tsx}"],
// Four patterns and no named file. Three of them name artifacts that
// never execute. The fourth, src/test/**, names the shared scaffolding,
// which does run on every pass of this suite and is deliberately not
// measured, because it is support code for the tests and not code the
// product ships. That makes it the one directory holding executing code
// outside the gate, so eslint.config.js forbids importing it from
// anything that is not itself a test. An entry naming an application
// source file is how a coverage gate stops measuring the code it exists
// to measure.
exclude: [
"src/**/*.test.{ts,tsx}",
// Type-level assertions, settled by the compiler and never run.
"src/**/*.test-d.ts",
"src/test/**",
"src/**/*.d.ts",
],
// Without this the number is only a report, and a change that drops
// coverage merges green with the drop recorded in a log nobody reads.
thresholds: { 100: true },
},
projects: [
{
// Everything that runs without an engine. This is the suite the coverage
// gate measures and the one a clean install can run, so it is named
// explicitly, because a second project turns an unfiltered run into a
// browser launch.
extends: true,
test: {
name: "jsdom",
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
// Supplying exclude replaces the runner's own default instead of adding
// to it, so the spread keeps the dependency directory out of
// collection. The first added pattern is the browser project's whole
// input. The second is the other runner's whole input. Both runners
// match the same spec filenames from the same project root, so without
// it this one collects the end-to-end specs and each of them fails on
// import with a message about being called from a configuration file.
exclude: [...defaultExclude, "src/**/*.browser.test.tsx", "e2e/**"],
},
},
{
// A real engine, for the checks that need layout and paint. It shares the
// root plugin list through extends, without which the JSX here never
// transforms, and it takes no setup file, because the shared setup stubs
// the media query this project exists to exercise for real, and stubs the
// dataset fetch down to a fixture that would leave the paged table three
// pages long.
extends: true,
test: {
name: "browser",
include: ["src/**/*.browser.test.tsx"],
setupFiles: [],
browser: {
enabled: true,
headless: true,
// The runner's own default is a phone-sized window, at which the
// table overflows its scroll container and the last column is
// clipped. The contrast rule then reports every cell in that column
// as undecided, because a partially obscured element has no
// determinable background. A desktop window is the layout this table
// is built for and the one in which the rule can reach a verdict.
viewport: { width: 1280, height: 900 },
// A factory in this major version. The bare string throws while the
// projects are still resolving, before a single test is collected.
provider: playwright(),
// The pipeline downloads chromium-headless-shell alone, and this
// launch resolves to exactly that, because Playwright routes a
// headless launch that names no channel to the shell. Naming a
// channel, or turning headless off for a local debugging run, asks
// for a binary CI never fetched. src/toolchain.test.ts holds the two
// files together, so that edit shows up as a red test here instead
// of a missing executable in the pipeline, whose failure would say
// nothing about accessibility.
instances: [{ browser: "chromium" }],
},
},
},
],
},
});