Files
iptvnator/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts
T
4grayandClaude Opus 5 0334296f15 fix(electron-backend): make test suite and lint host-agnostic on Windows checkouts (#1176)
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts

Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:

- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
  multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
  path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
  executables, Homebrew Caskroom) with path.posix.join so simulated
  darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
  pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
  the POSIX shell on Linux (shallow match), so CI linted only a subset
  of files while Windows passed the literal pattern to ESLint and
  linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
  epg-worker.service and database.worker-connection, no-unsafe-finally
  in external-player-session-registry and embedded-mpv-native.service
  (rewritten as catch-swallow with identical semantics, pinned by new
  regression tests), intentional no-control-regex in the recording
  filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mirror the quoted-lint-glob convention into AGENTS.md

AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.

Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 23:05:22 +02:00

214 lines
7.1 KiB
TypeScript

import * as path from 'path';
import {
getNativeModuleSearchPaths,
loadNativeModuleFromSearchPaths,
registerNativeModuleSearchPaths,
resolveWorkerRuntimeBootstrap,
} from './worker-runtime-paths';
// The implementation joins candidate paths with the host path module, so the
// expected values are built with path.join too — hardcoded POSIX strings
// would fail on win32 checkouts.
function packagedWorkerPath(root: string, workerFilename: string): string {
return path.join(
root,
'dist',
'apps',
'electron-backend',
'workers',
workerFilename
);
}
function unpackedNodeModulesPaths(root: string): string[] {
return [
path.join(root, 'app.asar.unpacked', 'node_modules'),
path.join(
root,
'app.asar.unpacked',
'electron-backend',
'node_modules'
),
path.join(
root,
'app.asar.unpacked',
'dist',
'apps',
'electron-backend',
'node_modules'
),
];
}
describe('worker-runtime-paths', () => {
it('resolves the development worker path when the worker file exists', () => {
const developmentWorkerDir =
'/workspace/dist/apps/electron-backend/workers';
const workerPath = path.join(
developmentWorkerDir,
'database.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: false,
workerFilename: 'database.worker.js',
developmentWorkerDir,
fileExists: (filePath) => filePath === workerPath,
});
expect(bootstrap).toEqual({
workerPath,
workerPathCandidates: [workerPath],
});
});
it('prefers process.resourcesPath for packaged worker resolution', () => {
const resourcesPath = '/Applications/IPTVnator.app/Contents/Resources';
const resourcesWorkerPath = packagedWorkerPath(
resourcesPath,
'epg-parser.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: '/unused',
resourcesPath,
appPath:
'/Applications/IPTVnator.app/Contents/Resources/app.asar',
fileExists: (filePath) => filePath === resourcesWorkerPath,
});
expect(bootstrap.workerPath).toBe(resourcesWorkerPath);
expect(bootstrap.nativeModuleSearchPaths).toEqual(
unpackedNodeModulesPaths(resourcesPath)
);
});
it('falls back to appPath dirname when the resourcesPath candidate is missing', () => {
const appWorkerPath = packagedWorkerPath(
'/opt/IPTVnator/resources',
'database.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/tmp/runtime-resources',
appPath: '/opt/IPTVnator/resources/app.asar',
fileExists: (filePath) => filePath === appWorkerPath,
});
expect(bootstrap.workerPath).toBe(appWorkerPath);
expect(bootstrap.workerPathCandidates).toEqual([
packagedWorkerPath('/tmp/runtime-resources', 'database.worker.js'),
appWorkerPath,
]);
});
it('throws actionable errors when the worker file cannot be resolved', () => {
expect(() =>
resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/resources',
appPath: '/opt/IPTVnator/resources/app.asar',
fileExists: () => false,
})
).toThrow(
[
'Unable to resolve worker "database.worker.js".',
'Tried:',
`- ${packagedWorkerPath('/resources', 'database.worker.js')}`,
`- ${packagedWorkerPath(
'/opt/IPTVnator/resources',
'database.worker.js'
)}`,
].join('\n')
);
});
it('de-duplicates native module search paths when resource roots match', () => {
expect(
getNativeModuleSearchPaths({
resourcesPath: '/resources',
appPath: '/resources/app.asar',
})
).toEqual(unpackedNodeModulesPaths('/resources'));
});
it('loads native modules from the first working candidate path', () => {
const requireFactory = jest.fn((entryPath: string) => {
if (entryPath === path.join('/second', 'index.js')) {
return ((moduleName: string) => ({
moduleName,
entryPath,
})) as NodeRequire;
}
throw new Error('bad path');
});
const moduleValue = loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[Worker]',
searchPaths: ['/first', '/second'],
fileExists: (searchPath) => searchPath !== '/first-missing',
requireFactory,
});
expect(moduleValue).toEqual({
moduleName: 'better-sqlite3',
entryPath: path.join('/second', 'index.js'),
});
});
it('registers native module search paths in NODE_PATH and module globalPaths', () => {
const env: NodeJS.ProcessEnv = {};
const moduleApi = {
globalPaths: ['/global'],
};
const registeredPaths = registerNativeModuleSearchPaths(
['/first', '/second', '/global'],
{
env,
moduleApi,
}
);
expect(registeredPaths).toEqual(['/first', '/second', '/global']);
expect(env.NODE_PATH).toBe(
['/first', '/second', '/global'].join(path.delimiter)
);
expect(moduleApi.globalPaths).toEqual(['/first', '/second', '/global']);
});
it('throws aggregated errors when no native module candidate works', () => {
expect(() =>
loadNativeModuleFromSearchPaths({
moduleName: 'better-sqlite3',
loggerLabel: '[Worker]',
searchPaths: ['/missing', '/broken'],
fileExists: (searchPath) => searchPath === '/broken',
requireFactory: () => {
throw new Error('Cannot find module');
},
fallbackRequire: () => {
throw new Error('fallback failed');
},
})
).toThrow(
[
'[Worker] Unable to load native module "better-sqlite3".',
'Tried:',
'- /missing',
'- /broken',
'Failures:',
'- /missing (missing)',
'- /broken (Cannot find module)',
'- require(better-sqlite3) (fallback failed)',
].join('\n')
);
});
});