mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
build(release): reduce package size and report release sizes (#244)
This commit is contained in:
@@ -105,6 +105,7 @@ async function afterPack(context, deps = {}) {
|
||||
await stageLinuxAppImageSharedLibrary(context);
|
||||
await verifyMacOSWindowHelper(context);
|
||||
await stageBundledBunRuntime(context, deps);
|
||||
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
packager?: { appInfo?: { productFilename?: string } };
|
||||
},
|
||||
deps?: {
|
||||
auditPackage?: (context: { appOutDir: string }) => Promise<void>;
|
||||
stageBunRuntime?: (options: {
|
||||
appOutDir: string;
|
||||
platform: string;
|
||||
@@ -172,11 +173,12 @@ test('afterPack propagates Linux staging failures', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('afterPack preserves Linux staging and forwards the electron-builder target to Bun staging', async () => {
|
||||
test('afterPack stages Linux and Bun runtime assets before auditing the package', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-target');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-linux-arm64');
|
||||
const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY);
|
||||
const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY);
|
||||
const operations: string[] = [];
|
||||
let stagedOptions:
|
||||
| {
|
||||
appOutDir: string;
|
||||
@@ -200,10 +202,17 @@ test('afterPack preserves Linux staging and forwards the electron-builder target
|
||||
{
|
||||
stageBunRuntime: async (options) => {
|
||||
stagedOptions = options;
|
||||
operations.push('stage-bun');
|
||||
},
|
||||
auditPackage: async (context) => {
|
||||
assert.equal(context.appOutDir, appOutDir);
|
||||
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
|
||||
operations.push('audit');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(operations, ['stage-bun', 'audit']);
|
||||
assert.deepEqual(stagedOptions, {
|
||||
appOutDir,
|
||||
platform: 'linux',
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const asar = require('@electron/asar');
|
||||
const { Arch } = require('builder-util');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const currentReports = new Set();
|
||||
const REQUIRED_APP_FILES = [
|
||||
'package.json',
|
||||
'LICENSE',
|
||||
'config.example.jsonc',
|
||||
'dist/main-entry.js',
|
||||
'dist/main.js',
|
||||
'dist/preload.js',
|
||||
'dist/preload-settings.js',
|
||||
'dist/preload-syncui.js',
|
||||
'dist/preload-stats.js',
|
||||
'dist/preload-jellyfin-setup.js',
|
||||
'dist/fonts/MPLUS1[wght].ttf',
|
||||
'stats/dist/index.html',
|
||||
'vendor/texthooker-ui/docs/index.html',
|
||||
...['renderer', 'settings', 'syncui'].flatMap((ui) => [
|
||||
`dist/${ui}/index.html`,
|
||||
`dist/${ui}/style.css`,
|
||||
`dist/${ui}/${ui}.js`,
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
'yomitan/lib/resvg.wasm',
|
||||
'launcher/subminer',
|
||||
'plugin/subminer/main.lua',
|
||||
'plugin/subminer.conf',
|
||||
'assets/SubMiner.png',
|
||||
'assets/SubMiner-square.png',
|
||||
'assets/themes/subminer.rasi',
|
||||
'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'CHANGELOG.md',
|
||||
];
|
||||
|
||||
// Do not follow framework symlinks or count ASAR unpacked entries twice.
|
||||
function listFiles(root, prefix = '') {
|
||||
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
|
||||
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink()) return [];
|
||||
if (entry.isDirectory()) return listFiles(root, name);
|
||||
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
|
||||
});
|
||||
}
|
||||
|
||||
function listAppFiles(archive) {
|
||||
return asar.listPackage(archive).flatMap((entry) => {
|
||||
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
|
||||
const stat = asar.statFile(archive, name);
|
||||
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function verifyAppPath(name, platform, arch) {
|
||||
const allowedRoots = new Set([
|
||||
'dist',
|
||||
'node_modules',
|
||||
'stats',
|
||||
'vendor',
|
||||
'package.json',
|
||||
'LICENSE',
|
||||
'config.example.jsonc',
|
||||
]);
|
||||
assert(allowedRoots.has(name.split('/')[0]), `Unexpected app file: ${name}`);
|
||||
assert(!name.endsWith('.map'), `Packaged source map: ${name}`);
|
||||
assert(!/\.(?:[cm]?ts|tsx)$/.test(name), `Packaged TypeScript: ${name}`);
|
||||
assert(!/\.(?:test|spec)\./.test(name), `Packaged test: ${name}`);
|
||||
assert(
|
||||
!/(?:^|\/)(?:tests?|__tests__|fixtures?|__fixtures__)\//.test(name),
|
||||
`Packaged test or fixture directory: ${name}`,
|
||||
);
|
||||
assert(!/^dist\/.*\.test\./.test(name), `Packaged test: ${name}`);
|
||||
assert(!/^dist\/(launcher|scripts)\//.test(name), `Duplicate helper: ${name}`);
|
||||
assert(!/^dist\/(renderer|settings|syncui)\/fonts\//.test(name), `Duplicate font: ${name}`);
|
||||
assert(!name.startsWith('stats/') || name.startsWith('stats/dist/'), `Stats source: ${name}`);
|
||||
assert(
|
||||
!name.startsWith('vendor/') || name.startsWith('vendor/texthooker-ui/docs/'),
|
||||
`Vendor source: ${name}`,
|
||||
);
|
||||
if (name.startsWith('node_modules/koffi/')) {
|
||||
assert.equal(platform, 'win32', `Koffi shipped on ${platform}`);
|
||||
assert(!/^node_modules\/koffi\/(src|vendor|doc)\//.test(name), `Koffi build files: ${name}`);
|
||||
if (name.endsWith('.node')) {
|
||||
assert.equal(name, `node_modules/koffi/build/koffi/win32_${arch}/koffi.node`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyContents(archive, resources, platform, arch) {
|
||||
const entries = listAppFiles(archive);
|
||||
const names = new Set(entries.map((entry) => entry.path));
|
||||
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
|
||||
for (const name of REQUIRED_RESOURCES) {
|
||||
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
|
||||
}
|
||||
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
|
||||
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
|
||||
const libsqlPlatform = {
|
||||
linux: `linux-${arch}-gnu`,
|
||||
darwin: `darwin-${arch}`,
|
||||
win32: `win32-${arch}-msvc`,
|
||||
}[platform];
|
||||
const libsqlBinary = `node_modules/@libsql/${libsqlPlatform}/index.node`;
|
||||
assert(names.has(libsqlBinary), `Missing SQLite native binary: ${libsqlBinary}`);
|
||||
for (const name of names) {
|
||||
if (name.startsWith('node_modules/@libsql/') && name.endsWith('.node')) {
|
||||
assert.equal(name, libsqlBinary, `Foreign SQLite binary: ${name}`);
|
||||
}
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
for (const name of [
|
||||
'index.js',
|
||||
'package.json',
|
||||
'LICENSE.txt',
|
||||
`build/koffi/win32_${arch}/koffi.node`,
|
||||
]) {
|
||||
assert(names.has(`node_modules/koffi/${name}`), `Missing Windows FFI file: ${name}`);
|
||||
}
|
||||
}
|
||||
for (const name of listFiles(path.join(resources, 'assets'))) {
|
||||
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
|
||||
}
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
|
||||
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function auditPackage(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = Arch[context.arch];
|
||||
const key = `${platform}-${arch}`;
|
||||
const appRoot =
|
||||
platform === 'darwin'
|
||||
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
|
||||
: context.appOutDir;
|
||||
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
|
||||
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
|
||||
const files = listFiles(appRoot);
|
||||
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const report = {
|
||||
version: context.packager.appInfo.version,
|
||||
platform,
|
||||
arch,
|
||||
unpackedBytes,
|
||||
appDirectory: path.relative(context.outDir, appRoot),
|
||||
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
|
||||
artifacts: [],
|
||||
};
|
||||
const output = path.join(context.outDir, `package-size-${key}.json`);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
currentReports.add(output);
|
||||
console.log(
|
||||
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
|
||||
);
|
||||
}
|
||||
|
||||
function artifactKind(name) {
|
||||
if (name.endsWith('-mac.zip')) return 'mac.zip';
|
||||
if (name.endsWith('-win.zip')) return 'win.zip';
|
||||
const extension = path.extname(name).slice(1);
|
||||
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
|
||||
}
|
||||
|
||||
function compareSizes(report, previous) {
|
||||
assert.equal(previous.platform, report.platform);
|
||||
assert.equal(previous.arch, report.arch);
|
||||
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
|
||||
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
|
||||
return {
|
||||
version: previous.version,
|
||||
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
|
||||
artifacts: report.artifacts.flatMap((artifact) => {
|
||||
const old = previousArtifacts.find(
|
||||
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
|
||||
);
|
||||
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Runs after signing and installer creation, before release upload.
|
||||
async function afterAllArtifactBuild(result) {
|
||||
const reports = [];
|
||||
for (const reportPath of currentReports) {
|
||||
const filename = path.basename(reportPath);
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
const key = `${report.platform}-${report.arch}`;
|
||||
const files = listFiles(path.join(result.outDir, report.appDirectory));
|
||||
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
|
||||
report.artifacts = result.artifactPaths.flatMap((file) => {
|
||||
const kind = artifactKind(file);
|
||||
if (!kind) return [];
|
||||
const bytes = fs.statSync(file).size;
|
||||
return [{ name: path.basename(file), kind, bytes }];
|
||||
});
|
||||
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
|
||||
if (fs.existsSync(previousPath)) {
|
||||
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
|
||||
report.comparison = compareSizes(report, previous);
|
||||
}
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const summary = [
|
||||
`### Package size: ${key}`,
|
||||
'',
|
||||
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
|
||||
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
|
||||
report.comparison
|
||||
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
|
||||
: 'No previous size report available.',
|
||||
'',
|
||||
].join('\n');
|
||||
console.log(summary);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
||||
reports.push(reportPath);
|
||||
}
|
||||
assert(reports.length > 0, 'No package size reports generated by afterPack');
|
||||
return reports;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auditPackage,
|
||||
verifyContents,
|
||||
verifyAppPath,
|
||||
listFiles,
|
||||
listAppFiles,
|
||||
compareSizes,
|
||||
default: afterAllArtifactBuild,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync, createReadStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createPackageFromStreams } from '@electron/asar';
|
||||
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
|
||||
import config from '../package.json';
|
||||
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
|
||||
|
||||
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
|
||||
const root = process.cwd();
|
||||
const fileStat = statSync('package.json');
|
||||
for (const platform of ['linux', 'mac', 'win'] as const) {
|
||||
const matchers = getFileMatchers(
|
||||
{ files: [{ filter: config.build.files }] },
|
||||
'files',
|
||||
'/tmp/subminer-filter-output',
|
||||
{
|
||||
defaultSrc: root,
|
||||
globalOutDir: path.join(root, 'release'),
|
||||
customBuildOptions: { files: config.build[platform].files },
|
||||
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
||||
},
|
||||
);
|
||||
assert(matchers);
|
||||
// This is builder's default for an exclusion-only platform matcher.
|
||||
for (const matcher of matchers) {
|
||||
if (matcher.containsOnlyIgnore()) matcher.prependPattern('**/*');
|
||||
}
|
||||
const included = (name: string) =>
|
||||
matchers.some((matcher) => matcher.createFilter()(path.join(root, name), fileStat));
|
||||
for (const name of [
|
||||
'dist/main-entry.js',
|
||||
'dist/fonts/MPLUS1[wght].ttf',
|
||||
'stats/dist/index.html',
|
||||
'vendor/texthooker-ui/docs/index.html',
|
||||
'package.json',
|
||||
]) {
|
||||
assert(included(name), `${platform} must ship ${name}`);
|
||||
}
|
||||
for (const name of [
|
||||
'.agents/skills/test.md',
|
||||
'src/main.ts',
|
||||
'scripts/build-yomitan.mjs',
|
||||
'docs-site/index.md',
|
||||
'dist/main.js.map',
|
||||
'dist/main.test.js',
|
||||
'dist/nested/source.ts',
|
||||
'dist/nested/__tests__/helper.js',
|
||||
'stats/dist/nested/fixtures/data.json',
|
||||
'vendor/texthooker-ui/docs/nested/component.tsx',
|
||||
'dist/launcher/subminer',
|
||||
'dist/settings/fonts/MPLUS1[wght].ttf',
|
||||
'vendor/subminer-yomitan/ext/manifest.json',
|
||||
]) {
|
||||
assert(!included(name), `${platform} must exclude ${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('dependency filters keep only the target Windows Koffi binary', () => {
|
||||
const root = process.cwd();
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
for (const platform of ['linux', 'mac', 'win'] as const) {
|
||||
const patterns = [
|
||||
'**/*',
|
||||
...config.build.files.filter((name) => name.startsWith('!')),
|
||||
...config.build[platform].files.filter((name) => name.startsWith('!')),
|
||||
];
|
||||
const filter = new FileMatcher(
|
||||
root,
|
||||
'/tmp/subminer-filter-output',
|
||||
(value) => value.replaceAll('${arch}', arch),
|
||||
patterns,
|
||||
).createFilter();
|
||||
const included = (name: string) =>
|
||||
filter(path.join(root, 'node_modules', name), statSync('package.json'));
|
||||
assert(included('@libsql/win32-x64-msvc/index.node'));
|
||||
assert(!included('axios/dist/axios.js.map'));
|
||||
assert(!included('koffi/src/koffi/src/ffi.c'));
|
||||
assert(!included('agent-base/src/index.ts'));
|
||||
assert(!included('@discordjs/rest/dist/index.d.mts'));
|
||||
assert(!included('example/lib/tests/helper.js'));
|
||||
for (const target of [
|
||||
'win32_x64',
|
||||
'win32_arm64',
|
||||
'linux_x64',
|
||||
'darwin_arm64',
|
||||
'openbsd_x64',
|
||||
]) {
|
||||
assert.equal(
|
||||
included(`koffi/build/koffi/${target}/koffi.node`),
|
||||
platform === 'win' && target === `win32_${arch}`,
|
||||
`${platform}/${arch}: ${target}`,
|
||||
);
|
||||
}
|
||||
assert.equal(included('koffi/index.js'), platform === 'win');
|
||||
assert.equal(included('koffi/LICENSE.txt'), platform === 'win');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('content audit rejects development files beneath approved roots', () => {
|
||||
for (const root of ['dist', 'stats/dist', 'vendor/texthooker-ui/docs', 'node_modules/example']) {
|
||||
for (const suffix of [
|
||||
'nested/source.ts',
|
||||
'nested/component.tsx',
|
||||
'nested/types.d.mts',
|
||||
'nested/source.cts',
|
||||
'nested/__tests__/helper.js',
|
||||
'nested/tests/helper.js',
|
||||
'nested/test/helper.js',
|
||||
'nested/__fixtures__/data.json',
|
||||
'nested/fixtures/data.json',
|
||||
'nested/fixture/data.json',
|
||||
'nested/component.spec.js',
|
||||
'nested/component.test.cjs',
|
||||
]) {
|
||||
assert.throws(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'), /Packaged/);
|
||||
}
|
||||
for (const suffix of ['nested/runtime.js', 'nested/style.css', 'nested/data.json']) {
|
||||
assert.doesNotThrow(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('archive inventory handles native files without counting them twice on disk', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
|
||||
try {
|
||||
const input = path.join(root, 'input');
|
||||
const output = path.join(root, 'output');
|
||||
mkdirSync(input);
|
||||
mkdirSync(output);
|
||||
writeFileSync(path.join(input, 'main.js'), 'hello');
|
||||
writeFileSync(path.join(input, 'native.node'), 'native');
|
||||
const archive = path.join(output, 'app.asar');
|
||||
await createPackageFromStreams(
|
||||
archive,
|
||||
['main.js', 'native.node'].map((name) => ({
|
||||
path: name,
|
||||
type: 'file',
|
||||
unpacked: name.endsWith('.node'),
|
||||
stat: statSync(path.join(input, name)),
|
||||
streamGenerator: () => createReadStream(path.join(input, name)),
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(listAppFiles(archive), [
|
||||
{ path: 'main.js', bytes: 5 },
|
||||
{ path: 'native.node', bytes: 6 },
|
||||
]);
|
||||
assert.equal(
|
||||
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
|
||||
statSync(archive).size + 6,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('size comparison tolerates older reports without artifact measurements', () => {
|
||||
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
|
||||
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
|
||||
assert.deepEqual(compareSizes(current, previous), {
|
||||
version: '0.19.6',
|
||||
unpackedDeltaBytes: -20,
|
||||
artifacts: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
|
||||
.artifacts,
|
||||
[{ kind: 'AppImage', deltaBytes: -10 }],
|
||||
);
|
||||
assert.throws(
|
||||
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
|
||||
/Invalid previous size report/,
|
||||
);
|
||||
});
|
||||
@@ -29,10 +29,6 @@ function copyFile(sourcePath, outputPath) {
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
|
||||
}
|
||||
|
||||
@@ -102,6 +98,10 @@ function buildMacosHelper() {
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const resources = process.argv[2];
|
||||
if (!resources) throw new Error('Usage: bun run test:package <resources-directory>');
|
||||
const require = createRequire(import.meta.url);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-package-smoke-'));
|
||||
const env = { ...process.env, SUBMINER_PACKAGE_SMOKE_DATA: profile };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Run with the pinned Electron runtime against a finished app's resources folder.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const assert = require('node:assert/strict');
|
||||
const { once } = require('node:events');
|
||||
|
||||
const resources = path.resolve(process.argv[2]);
|
||||
const archive = path.join(resources, 'app.asar');
|
||||
const isolatedData = process.env.SUBMINER_PACKAGE_SMOKE_DATA;
|
||||
assert(
|
||||
isolatedData && fs.existsSync(isolatedData),
|
||||
'Use bun run test:package to create an isolated profile',
|
||||
);
|
||||
app.setPath('userData', isolatedData);
|
||||
app.disableHardwareAcceleration();
|
||||
app.on('window-all-closed', () => {});
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('Package smoke timed out');
|
||||
app.exit(1);
|
||||
}, 60_000);
|
||||
|
||||
async function smoke() {
|
||||
await app.whenReady();
|
||||
const packagedRequire = createRequire(path.join(archive, 'package.json'));
|
||||
const Database = packagedRequire('libsql');
|
||||
const database = new Database(':memory:');
|
||||
assert.equal(database.prepare('select 42 as answer').get().answer, 42);
|
||||
database.close();
|
||||
if (process.platform === 'win32') {
|
||||
const win32 = packagedRequire('./dist/window-trackers/win32.js');
|
||||
assert(Array.isArray(win32.findMpvWindows().matches));
|
||||
}
|
||||
const { Texthooker } = packagedRequire('./dist/core/services/texthooker.js');
|
||||
const texthooker = new Texthooker();
|
||||
const server = texthooker.start(0);
|
||||
assert(server, 'Packaged texthooker assets could not be found');
|
||||
try {
|
||||
await once(server, 'listening');
|
||||
const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
|
||||
assert.equal(response.status, 200);
|
||||
assert((await response.text()).includes('<html'));
|
||||
} finally {
|
||||
texthooker.stop();
|
||||
}
|
||||
const extension = await session.defaultSession.extensions.loadExtension(
|
||||
path.join(resources, 'yomitan'),
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
|
||||
if (details.error !== 'net::ERR_ABORTED')
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
});
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
preload: path.join(archive, 'dist', ui === 'renderer' ? 'preload.js' : `preload-${ui}.js`),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await win.loadFile(
|
||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
||||
);
|
||||
if (ui !== 'stats') {
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
}
|
||||
} finally {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
smoke()
|
||||
.then(() => {
|
||||
clearTimeout(timeout);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(timeout);
|
||||
app.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user