From c3e353ce004f4fb4b0537cb5462c40098435f1db Mon Sep 17 00:00:00 2001
From: AUDO
Date: Fri, 7 Aug 2026 23:46:33 +0800
Subject: [PATCH 1/2] fix(environments): keep local custom/ media out of every
production build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The strip plugin gated on AVATAR_SHIP=1, which only dist:win set, so
`npm run build` and `npm run desktop` still expanded the eager glob over
src/assets/environments/custom/ and hashed contributor trial media into
dist/. Invert the default: anything that emits a bundle drops custom/,
keyed on command === 'serve' so --mode cannot flip it, with
AVATAR_INCLUDE_CUSTOM=1 as the deliberate opt-in. AVATAR_SHIP is removed.
The rewrite also failed silently — String.replace returns its input when
the pattern misses, and the plugin returned that as if it had worked, so
renaming or reformatting the glob would have quietly restored the leak.
It now errors and fails the build.
Add scripts/custom-envs.test.mjs. A clean checkout has an empty custom/,
so CI's production build stays green even with the plugin deleted; the
build is not a regression test for this on its own. Five tests drive the
real plugin over the real environments.js, and two plant a file and run
an actual build, which is what catches media reaching the bundle by a
route other than that one glob.
Closes #13
Co-Authored-By: Claude Opus 5
---
CHANGELOG.md | 4 +
CONTRIBUTING.md | 2 +-
README.md | 2 +-
avatar/package.json | 4 +-
avatar/scripts/custom-envs.test.mjs | 147 ++++++++++++++++++++++++++++
avatar/vite.config.js | 62 +++++++-----
docs/development/project-layout.md | 2 +-
docs/environments.md | 6 +-
8 files changed, 197 insertions(+), 32 deletions(-)
create mode 100644 avatar/scripts/custom-envs.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd235ea..f05c525 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Fixed
+
+- Local `custom/` environment media is dropped from **every** production build, not just `dist:win` — `npm run build` and `npm run desktop` no longer hash contributor trial GIFs into `dist/`. Only the dev server reads the folder; `AVATAR_INCLUDE_CUSTOM=1` opts a production build back in. Replaces the `AVATAR_SHIP=1` flag, which is removed. A reformat or rename of the glob now fails the build instead of silently bundling the folder, and `npm test` covers the embargo (`scripts/custom-envs.test.mjs`) — a clean checkout has an empty `custom/`, so a green build proves nothing on its own. (#13)
+
## [0.6.0] — 2026-08-07
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 29089ca..b599095 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -112,7 +112,7 @@ Docs-only PRs still need a clear description; changelog entry optional unless th
- If a feature is **Electron-only**, say so in UI copy and docs; do not silently no-op in a confusing way when possible.
- If you fix something in the renderer, smoke-test **`dev:desktop`** for overlay/audio when the area touches those systems.
-- Shipping builds use `AVATAR_SHIP=1` on `dist:win` so local `custom/` environment GIFs are **not** packaged — do not “fix” shipping by committing trial media into `custom/`.
+- Every production build (`build`, `desktop`, `dist:win`) drops local `custom/` environment media from the bundle; only the dev server keeps it. Use `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` if you deliberately want trial media in a production build — and do not “fix” shipping by committing trial media into `custom/`.
### Settings and persistence
diff --git a/README.md b/README.md
index 242976a..31462f0 100644
--- a/README.md
+++ b/README.md
@@ -141,7 +141,7 @@ Gear → **Animations** → **Default** (greeting, then motion loop). Catalog: [
-**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default (local only; not shipped in the installer).
+**From source (contributors):** trial files can also go in `avatar/src/assets/environments/custom/` while Directories is Default — picked up by the dev server only, and left out of every production build.
## Documentation
diff --git a/avatar/package.json b/avatar/package.json
index d07582b..e26b0c4 100644
--- a/avatar/package.json
+++ b/avatar/package.json
@@ -15,8 +15,8 @@
"desktop": "npm run build && cross-env NODE_ENV=production electron .",
"build": "vite build",
"icons": "node scripts/make-icons.mjs",
- "test": "node --test electron/*.test.cjs",
- "dist:win": "npm run icons && cross-env AVATAR_SHIP=1 vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis",
+ "test": "node --test electron/*.test.cjs scripts/*.test.mjs",
+ "dist:win": "npm run icons && vite build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win nsis",
"lint": "eslint . --max-warnings=0",
"preview": "vite preview"
},
diff --git a/avatar/scripts/custom-envs.test.mjs b/avatar/scripts/custom-envs.test.mjs
new file mode 100644
index 0000000..49f2d9f
--- /dev/null
+++ b/avatar/scripts/custom-envs.test.mjs
@@ -0,0 +1,147 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+import viteConfig from '../vite.config.js';
+
+// A clean checkout has an empty custom/, so a build alone can never show that
+// the embargo still works — it would pass just as green with the plugin
+// deleted. These tests run the real plugin over the real environments.js
+// instead, which is what actually decides whether trial media gets bundled.
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const environmentsId = path.join(here, '..', 'src', 'config', 'environments.js');
+const environmentsSource = fs.readFileSync(environmentsId, 'utf8');
+
+/** Rollup hands `transform` a plugin context; only `error` matters here. */
+const rollupContext = {
+ error(message) {
+ throw new Error(typeof message === 'string' ? message : message.message);
+ },
+};
+
+/** @param {{ command: string, mode?: string }} env */
+function transformEnvironments(env, code = environmentsSource) {
+ const { plugins } = viteConfig({ mode: 'production', ...env });
+ const plugin = plugins
+ .flat(Infinity)
+ .find((entry) => entry && entry.name === 'avatar-strip-custom-envs');
+ assert.ok(plugin, 'vite.config.js no longer registers avatar-strip-custom-envs');
+ return plugin.transform.call(rollupContext, code, environmentsId);
+}
+
+function withoutOptIn(context) {
+ const previous = process.env.AVATAR_INCLUDE_CUSTOM;
+ delete process.env.AVATAR_INCLUDE_CUSTOM;
+ context.after(() => {
+ if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM;
+ else process.env.AVATAR_INCLUDE_CUSTOM = previous;
+ });
+}
+
+test('a production build strips the custom/ glob', (context) => {
+ withoutOptIn(context);
+
+ const result = transformEnvironments({ command: 'build' });
+
+ assert.ok(result, 'expected the plugin to rewrite environments.js');
+ assert.match(result.code, /const customModules = \{\};/);
+ assert.doesNotMatch(result.code, /import\.meta\.glob/);
+});
+
+test('the dev server keeps the custom/ glob', (context) => {
+ withoutOptIn(context);
+
+ assert.equal(transformEnvironments({ command: 'serve' }), null);
+});
+
+test('AVATAR_INCLUDE_CUSTOM=1 opts a build back in', (context) => {
+ withoutOptIn(context);
+ process.env.AVATAR_INCLUDE_CUSTOM = '1';
+
+ assert.equal(transformEnvironments({ command: 'build' }), null);
+});
+
+test('a build fails rather than silently bundling custom/ media', (context) => {
+ withoutOptIn(context);
+ const renamed = environmentsSource.replace(
+ 'const customModules = import.meta.glob(',
+ 'const customEnvModules = import.meta.glob(',
+ );
+ assert.notEqual(renamed, environmentsSource, 'fixture no longer matches environments.js');
+
+ assert.throws(() => transformEnvironments({ command: 'build' }, renamed), {
+ message: /custom\/ media would be bundled/,
+ });
+});
+
+test('other modules are left alone', (context) => {
+ withoutOptIn(context);
+
+ const other = path.join(here, '..', 'src', 'config', 'userSettings.js');
+ const { plugins } = viteConfig({ command: 'build', mode: 'production' });
+ const plugin = plugins
+ .flat(Infinity)
+ .find((entry) => entry && entry.name === 'avatar-strip-custom-envs');
+
+ assert.equal(plugin.transform.call(rollupContext, environmentsSource, other), null);
+});
+
+// The tests above assert the mechanism — that one glob in environments.js gets
+// rewritten. The invariant we actually care about is that no file in custom/
+// reaches the bundle, whatever route it might take there. Only a real build
+// shows that, so this pair pays ~10s to plant a file and go look for it.
+
+const projectRoot = path.join(here, '..');
+const viteBin = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js');
+const customDir = path.join(projectRoot, 'src', 'assets', 'environments', 'custom');
+
+/**
+ * Plants a uniquely named file in custom/ (leaving any local trial media
+ * alone), builds, and reports the emitted asset names.
+ * @param {import('node:test').TestContext} context
+ * @param {Record} env
+ */
+function buildWithProbeInCustom(context, env) {
+ const stem = `probe-${process.pid}-${Date.now()}`;
+ const probe = path.join(customDir, `${stem}.gif`);
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'avatar-custom-envs-'));
+ fs.writeFileSync(probe, Buffer.alloc(64 * 1024));
+ context.after(() => {
+ fs.rmSync(probe, { force: true });
+ fs.rmSync(outDir, { force: true, recursive: true });
+ });
+
+ execFileSync(process.execPath, [viteBin, 'build', '--outDir', outDir, '--emptyOutDir'], {
+ cwd: projectRoot,
+ env: { ...process.env, ...env },
+ stdio: 'pipe',
+ });
+
+ return { stem, assets: fs.readdirSync(path.join(outDir, 'assets')) };
+}
+
+test('a real build leaves custom/ media out of the bundle', { timeout: 180_000 }, (context) => {
+ withoutOptIn(context);
+
+ const { stem, assets } = buildWithProbeInCustom(context, {});
+
+ assert.deepEqual(
+ assets.filter((name) => name.startsWith(stem)),
+ [],
+ );
+});
+
+test('...and the same build does emit it when opted in', { timeout: 180_000 }, (context) => {
+ withoutOptIn(context);
+
+ // Without this control the test above passes just as green when the probe is
+ // never planted or the assertion looks in the wrong place.
+ const { stem, assets } = buildWithProbeInCustom(context, { AVATAR_INCLUDE_CUSTOM: '1' });
+
+ assert.equal(assets.filter((name) => name.startsWith(stem)).length, 1);
+});
diff --git a/avatar/vite.config.js b/avatar/vite.config.js
index b5c62fd..b568260 100644
--- a/avatar/vite.config.js
+++ b/avatar/vite.config.js
@@ -2,41 +2,53 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
- * Production / installer builds must not bundle local custom/ trial GIFs.
- * Dev keeps the eager glob so dropping files into custom/ still works.
+ * Local `custom/` trial media is contributor-only and must never reach a
+ * production bundle — `build`, `desktop` and `dist:win` all drop it. The dev
+ * server keeps the eager glob so dropping files into custom/ still works; set
+ * AVATAR_INCLUDE_CUSTOM=1 to keep them in a build on purpose.
+ * @param {boolean} includeCustom
*/
-function stripCustomEnvsForShip() {
- const ship = process.env.AVATAR_SHIP === '1';
+function stripCustomEnvs(includeCustom) {
return {
name: 'avatar-strip-custom-envs',
transform(code, id) {
- if (!ship) return null;
+ if (includeCustom) return null;
const norm = id.replace(/\\/g, '/');
if (!norm.endsWith('/config/environments.js')) return null;
if (!code.includes('import.meta.glob')) return null;
- return {
- code: code.replace(
- /const customModules = import\.meta\.glob\([\s\S]*?\);/,
- 'const customModules = {};',
- ),
- map: null,
- };
+ const stripped = code.replace(
+ /const customModules = import\.meta\.glob\([\s\S]*?\);/,
+ 'const customModules = {};',
+ );
+ // A silent no-op here would quietly ship every file in custom/, so a
+ // rename or reformat of the glob must break the build, not the embargo.
+ if (stripped === code) {
+ this.error(
+ 'avatar-strip-custom-envs: the customModules glob in config/environments.js no longer ' +
+ 'matches this plugin, so custom/ media would be bundled. Update the pattern.',
+ );
+ }
+ return { code: stripped, map: null };
},
};
}
-export default defineConfig({
- base: './',
- assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'],
- plugins: [react(), stripCustomEnvsForShip()],
- server: {
- watch: {
- // `npm run thumbs` writes generated portraits into this directory while
- // the dev server is running. Watching it means each write invalidates the
- // module graph, reloads the generator, and starts the whole run again —
- // an endless loop that also interrupts in-flight VRM loads. These are
- // build assets; they never need hot reload.
- ignored: ['**/src/assets/avatars/thumbs/**'],
+export default defineConfig(({ command }) => {
+ // `serve` is the dev server; anything that emits a bundle drops custom/.
+ const includeCustom = command === 'serve' || process.env.AVATAR_INCLUDE_CUSTOM === '1';
+ return {
+ base: './',
+ assetsInclude: ['**/*.vrm', '**/*.vrma', '**/*.gif'],
+ plugins: [react(), stripCustomEnvs(includeCustom)],
+ server: {
+ watch: {
+ // `npm run thumbs` writes generated portraits into this directory while
+ // the dev server is running. Watching it means each write invalidates the
+ // module graph, reloads the generator, and starts the whole run again —
+ // an endless loop that also interrupts in-flight VRM loads. These are
+ // build assets; they never need hot reload.
+ ignored: ['**/src/assets/avatars/thumbs/**'],
+ },
},
- },
+ };
});
diff --git a/docs/development/project-layout.md b/docs/development/project-layout.md
index 5cb3bdc..03de6d7 100644
--- a/docs/development/project-layout.md
+++ b/docs/development/project-layout.md
@@ -43,7 +43,7 @@ End users personalize via **Settings → Directories** and **VRoid Hub** (see [U
| `npm run dist:win` | Contributors | Build Windows NSIS installer (local `desktop-setup/` output) |
| `npm run build` | CI / web | Production Vite bundle |
| `npm run lint` | Contributors / CI | ESLint (`src/`, `electron/**/*.cjs`, scripts; `--max-warnings=0`) |
-| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) |
+| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) and build-tooling tests (`scripts/*.test.mjs`, two of which run a real `vite build`, so the suite takes ~10s) |
| `npm run thumbs` | Contributors | Re-render committed avatar portraits into `src/assets/avatars/thumbs/` |
### Continuous integration
diff --git a/docs/environments.md b/docs/environments.md
index 3ec5f1b..b181ee4 100644
--- a/docs/environments.md
+++ b/docs/environments.md
@@ -66,12 +66,14 @@ Built-ins are **never** replaced by a custom env directory (unlike avatars).
The `custom/` folder is for **local trial media** when Directories is still Default. Files inside it are **not** committed to git (the empty folder is kept via `.gitkeep`).
-**Shipped Windows builds** do not pack local Custom media. Prefer Settings → Directories for installer users.
+**Production builds** (`npm run build`, `npm run desktop`, `npm run dist:win`) do not pack local Custom media — only the dev server reads this folder. Prefer Settings → Directories for installer users.
1. Drop media into `avatar/src/assets/environments/custom/` (`.gif`, `.png`, `.jpg`, `.jpeg`).
-2. Restart Vite / Electron so Vite’s glob picks them up.
+2. Restart Vite (`npm run dev` / `npm run dev:desktop`) so Vite’s glob picks them up.
3. Gear → Appearance → Environments → open **Custom**.
+To check trial media against a production bundle, opt in explicitly — `npx cross-env AVATAR_INCLUDE_CUSTOM=1 npm run build` (works in PowerShell, `cmd` and bash).
+
While Custom is open, built-in thumbs (Stars / Code / Bloom / None) hide so the library has room; close Custom to show them again. Scroll the Custom grid; Color fade stays visible underneath.
From 090e4f00e7adc80c152cf495c3f545fccf3368b9 Mon Sep 17 00:00:00 2001
From: AUDO
Date: Sat, 8 Aug 2026 00:05:20 +0800
Subject: [PATCH 2/2] test(environments): assert the bundle is identical
whatever custom/ holds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The build tests asserted only that the planted probes were absent, which
leaves room for custom/ influencing the bundle without its files being
copied — different chunking, different content hashes. Compare two builds
populated with different probe sets instead, over a manifest of every
emitted file and its md5, so the promise being checked is the one the
docs make: what sits in custom/ makes no difference to a production
bundle.
Three builds shared across four assertions rather than one build each, so
the stronger check costs one extra build. Probe stems now depend only on
the set label: the opt-in build reuses the plain build's filenames, which
leaves the flag as the only variable between them and makes the control
fail when the embargo is broken (it did not before).
Surface vite's stderr when a build fails, since stdio: 'pipe' otherwise
leaves a CI failure here with nothing to go on.
Co-Authored-By: Claude Opus 5
---
avatar/scripts/custom-envs.test.mjs | 150 +++++++++++++++++++++-------
docs/development/project-layout.md | 2 +-
2 files changed, 115 insertions(+), 37 deletions(-)
diff --git a/avatar/scripts/custom-envs.test.mjs b/avatar/scripts/custom-envs.test.mjs
index 49f2d9f..536b818 100644
--- a/avatar/scripts/custom-envs.test.mjs
+++ b/avatar/scripts/custom-envs.test.mjs
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import test from 'node:test';
+import test, { after, before, describe } from 'node:test';
import { fileURLToPath } from 'node:url';
import viteConfig from '../vite.config.js';
@@ -92,56 +93,133 @@ test('other modules are left alone', (context) => {
});
// The tests above assert the mechanism — that one glob in environments.js gets
-// rewritten. The invariant we actually care about is that no file in custom/
-// reaches the bundle, whatever route it might take there. Only a real build
-// shows that, so this pair pays ~10s to plant a file and go look for it.
+// rewritten. What we actually promise is stronger and only a real build can
+// show it: what sits in custom/ makes no difference to the bundle at all.
+// Three builds, shared by the assertions below, are enough to pin that down.
const projectRoot = path.join(here, '..');
const viteBin = path.join(projectRoot, 'node_modules', 'vite', 'bin', 'vite.js');
const customDir = path.join(projectRoot, 'src', 'assets', 'environments', 'custom');
+/** Deliberately different in count, extension and size from `probeSetB`. */
+const probeSetA = [
+ ['a1.gif', 64 * 1024],
+ ['a2.png', 512 * 1024],
+];
+const probeSetB = [
+ ['b1.gif', 1024],
+ ['b2.jpg', 3 * 1024 * 1024],
+ ['b3.jpeg', 128 * 1024],
+];
+
+/** Every emitted file as `relative/path md5`, sorted — not just asset names. */
+function manifestOf(dir) {
+ /** @type {string[]} */
+ const entries = [];
+ const walk = (current) => {
+ for (const name of fs.readdirSync(current)) {
+ const full = path.join(current, name);
+ if (fs.statSync(full).isDirectory()) walk(full);
+ else {
+ const hash = createHash('md5').update(fs.readFileSync(full)).digest('hex');
+ entries.push(`${path.relative(dir, full).replace(/\\/g, '/')} ${hash}`);
+ }
+ }
+ };
+ walk(dir);
+ return entries.sort();
+}
+
/**
- * Plants a uniquely named file in custom/ (leaving any local trial media
- * alone), builds, and reports the emitted asset names.
- * @param {import('node:test').TestContext} context
+ * Plants a named set of files in custom/ (leaving any local trial media
+ * alone), builds, removes them again, and returns the manifest. The stem
+ * depends only on `label`, so two builds of the same set differ by nothing but
+ * their env — otherwise a comparison between them would also be reading
+ * unrelated filename noise.
+ * @param {string} label
+ * @param {[string, number][]} probeSet
* @param {Record} env
*/
-function buildWithProbeInCustom(context, env) {
- const stem = `probe-${process.pid}-${Date.now()}`;
- const probe = path.join(customDir, `${stem}.gif`);
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'avatar-custom-envs-'));
- fs.writeFileSync(probe, Buffer.alloc(64 * 1024));
- context.after(() => {
- fs.rmSync(probe, { force: true });
- fs.rmSync(outDir, { force: true, recursive: true });
- });
-
- execFileSync(process.execPath, [viteBin, 'build', '--outDir', outDir, '--emptyOutDir'], {
- cwd: projectRoot,
- env: { ...process.env, ...env },
- stdio: 'pipe',
+function buildWithProbesInCustom(label, probeSet, env) {
+ const stem = `probe-${process.pid}-${label}`;
+ const planted = probeSet.map(([suffix, size]) => {
+ const file = path.join(customDir, `${stem}-${suffix}`);
+ fs.writeFileSync(file, Buffer.alloc(size));
+ return file;
});
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'avatar-custom-envs-'));
- return { stem, assets: fs.readdirSync(path.join(outDir, 'assets')) };
+ try {
+ execFileSync(process.execPath, [viteBin, 'build', '--outDir', outDir, '--emptyOutDir'], {
+ cwd: projectRoot,
+ env: { ...process.env, ...env },
+ stdio: 'pipe',
+ });
+ } catch (cause) {
+ // stdio: 'pipe' keeps the build quiet when it works; when it does not, the
+ // reason has to reach the CI log or this failure is undiagnosable.
+ throw new Error(`vite build failed for probe set ${label}:\n${cause.stderr}`, { cause });
+ } finally {
+ for (const file of planted) fs.rmSync(file, { force: true });
+ }
+
+ return { stem, outDir, manifest: manifestOf(outDir) };
}
-test('a real build leaves custom/ media out of the bundle', { timeout: 180_000 }, (context) => {
- withoutOptIn(context);
+describe('a real build', { timeout: 600_000 }, () => {
+ /** @type {ReturnType} */
+ let plainA;
+ /** @type {ReturnType} */
+ let plainB;
+ /** @type {ReturnType} */
+ let optedInA;
+
+ before(() => {
+ const previous = process.env.AVATAR_INCLUDE_CUSTOM;
+ delete process.env.AVATAR_INCLUDE_CUSTOM;
+ try {
+ plainA = buildWithProbesInCustom('a', probeSetA, {});
+ plainB = buildWithProbesInCustom('b', probeSetB, {});
+ optedInA = buildWithProbesInCustom('a', probeSetA, { AVATAR_INCLUDE_CUSTOM: '1' });
+ } finally {
+ if (previous === undefined) delete process.env.AVATAR_INCLUDE_CUSTOM;
+ else process.env.AVATAR_INCLUDE_CUSTOM = previous;
+ }
+ });
- const { stem, assets } = buildWithProbeInCustom(context, {});
+ after(() => {
+ for (const built of [plainA, plainB, optedInA]) {
+ if (built) fs.rmSync(built.outDir, { force: true, recursive: true });
+ }
+ });
- assert.deepEqual(
- assets.filter((name) => name.startsWith(stem)),
- [],
- );
-});
+ test('leaves custom/ media out of the bundle', () => {
+ assert.deepEqual(
+ plainA.manifest.filter((entry) => entry.includes(plainA.stem)),
+ [],
+ );
+ });
-test('...and the same build does emit it when opted in', { timeout: 180_000 }, (context) => {
- withoutOptIn(context);
+ test('emits that same media when opted in', () => {
+ // Without this control the test above passes just as green when the probes
+ // are never planted or the assertion looks in the wrong place.
+ assert.equal(
+ optedInA.manifest.filter((entry) => entry.includes(optedInA.stem)).length,
+ probeSetA.length,
+ );
+ });
- // Without this control the test above passes just as green when the probe is
- // never planted or the assertion looks in the wrong place.
- const { stem, assets } = buildWithProbeInCustom(context, { AVATAR_INCLUDE_CUSTOM: '1' });
+ test('is byte-identical whatever custom/ holds', () => {
+ // Stronger than "our probes are absent": two different populations of
+ // custom/ must produce the same files with the same contents, so nothing
+ // in there can influence chunking or content hashes either.
+ assert.deepEqual(plainA.manifest, plainB.manifest);
+ });
- assert.equal(assets.filter((name) => name.startsWith(stem)).length, 1);
+ test('and that comparison would notice if it were not', () => {
+ // Same probe set, same filenames, same manifest function — the only thing
+ // that differs is the opt-in, so a difference here can only mean custom/
+ // reached the bundle. Proves the comparison above can actually see one.
+ assert.notDeepEqual(optedInA.manifest, plainA.manifest);
+ });
});
diff --git a/docs/development/project-layout.md b/docs/development/project-layout.md
index 03de6d7..aba9fa2 100644
--- a/docs/development/project-layout.md
+++ b/docs/development/project-layout.md
@@ -43,7 +43,7 @@ End users personalize via **Settings → Directories** and **VRoid Hub** (see [U
| `npm run dist:win` | Contributors | Build Windows NSIS installer (local `desktop-setup/` output) |
| `npm run build` | CI / web | Production Vite bundle |
| `npm run lint` | Contributors / CI | ESLint (`src/`, `electron/**/*.cjs`, scripts; `--max-warnings=0`) |
-| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) and build-tooling tests (`scripts/*.test.mjs`, two of which run a real `vite build`, so the suite takes ~10s) |
+| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`) and build-tooling tests (`scripts/*.test.mjs`, which run three real `vite build`s, so the suite takes ~15s) |
| `npm run thumbs` | Contributors | Re-render committed avatar portraits into `src/assets/avatars/thumbs/` |
### Continuous integration