merge main and harden canonical media recovery

This commit is contained in:
Timo
2026-08-25 16:58:15 +02:00
81 changed files with 3422 additions and 1133 deletions
+44 -8
View File
@@ -9,12 +9,23 @@ npm run build:extension
npm run verify
npm run lint
npm run test:unit
npm run test:coverage
npm run release:gate -- 3.1.6 --candidate
```
- `npm run build:extension` runs `scripts/build-extension.cjs`.
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
- `npm run lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests.
- `npm run test:coverage` runs the same tests with the enforced coverage floor.
- `scripts/prepare-release.mjs` is an internal tag-workflow helper. It updates
every release-version source from the validated tag and accepts the tagged
commit timestamp for deterministic retries. Maintainers do not run it before
tagging.
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` is an optional
Linux/AMD64 parity diagnostic for release-code changes. It prepares the target
version only inside its isolated clone, then runs verification, browser E2E,
relay build, and health smoke. It is not required for Markdown-only changes.
## build-extension.cjs
@@ -59,9 +70,9 @@ npm run verify
It currently runs:
- Vitest unit tests.
- Server ops, route, WebSocket, and rate-limiter checks.
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
- Vitest unit tests with coverage thresholds for importable source modules.
- Server route and WebSocket integration checks.
- Episode parser, title privacy, host access, blacklist, names, rate limiting, audio settings, popup cooldown, and content-video-finder checks.
- JavaScript syntax checks for server and extension entry points.
- Extension and website locale coverage checks.
- ESLint.
@@ -72,19 +83,44 @@ It currently runs:
| Script | Purpose |
|:---|:---|
| `test-server-ops.mjs` | Health payload and admin metrics helpers |
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
| `test-episode-utils.mjs` | Episode-title extraction and comparison |
| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
| `test-names.mjs` | Generated username format and coverage |
| `test-content-video-finder.cjs` | Content-script video selection helpers |
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
| `test-website-locales.mjs` | Website locale coverage |
## Coverage Boundary
`vitest.config.mjs` covers importable modules executed by Vitest and enforces
both global and risk-specific per-module floors. Browser entry points
(`background.js`, `content.js`, and `popup.js`) and server process startup are
deliberately measured by extension E2E and integration tests instead of being
reported as zero-coverage unit code.
`scripts/check-coverage-inventory.mjs` additionally requires every JavaScript
source file to be classified as V8-covered or assigned to a named external
integration gate. New unclassified files fail `npm run verify`.
## Published Release Verification
Before publication, the release workflow validates the exact annotated SemVer
tag and required checks, prepares and validates every version source, pushes
the generated version commit directly to `main`, and checks out that exact
commit for all verification and builds. It then creates a draft release,
publishes and smoke-tests the relay image, and only afterwards makes the GitHub
Release public. The published-asset gate runs:
```bash
node scripts/verify-published-release.mjs vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync
```
The verifier requires the exact three release assets, validates SHA-256 hashes,
annotated-tag ancestry, Chrome/Firefox manifest versions and runtime injection,
archive parity, unsafe/development-only paths, and GitHub attestations. For a
local archive-only diagnosis, pass `--asset-dir PATH`; this deliberately skips
GitHub inventory and attestation checks.
## Do Not Break
- Keep scripts runnable from the repository root.
+8
View File
@@ -189,6 +189,14 @@ function copyExtensionFiles(targetDir, browserName) {
fs.writeFileSync(destPath, content);
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
} else if (item === 'canonical-media-state.js' || item === 'offline-media-intent.js') {
let content = fs.readFileSync(srcPath, 'utf8');
const sourceImport = "from '../shared/constants.js'";
if (!content.includes(sourceImport)) {
throw new Error(`CRITICAL: Source shared constants import missing in ${item}. Aborting build.`);
}
content = content.replace(sourceImport, "from './shared/constants.js'");
fs.writeFileSync(destPath, content);
} else if (item === 'popup.html') {
let content = fs.readFileSync(srcPath, 'utf8');
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
+60
View File
@@ -0,0 +1,60 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EXTERNALLY_GATED_SOURCES, VITEST_COVERAGE_INCLUDE } from './coverage-plan.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE_ROOTS = Object.freeze(['extension', 'scripts', 'server', 'shared', 'website']);
const SOURCE_EXTENSION = /\.(?:cjs|js|mjs)$/u;
const TEST_FILE = /\.test\.(?:cjs|js|mjs)$/u;
const GENERATED_OR_DEPENDENCY_DIRECTORIES = new Set(['extension/shared', 'server/node_modules', 'website/www']);
export function validateCoverageInventory(discoveredSources, coveredSources, externallyGatedSources) {
const discovered = new Set(discoveredSources);
const assignments = [...coveredSources, ...externallyGatedSources];
const assigned = new Set();
const duplicates = new Set();
for (const source of assignments) {
if (assigned.has(source)) duplicates.add(source);
assigned.add(source);
}
const unclassified = [...discovered].filter(source => !assigned.has(source)).sort();
const stale = [...assigned].filter(source => !discovered.has(source)).sort();
if (duplicates.size || unclassified.length || stale.length) {
const details = [];
if (duplicates.size) details.push(`assigned more than once: ${[...duplicates].sort().join(', ')}`);
if (unclassified.length) details.push(`unclassified sources: ${unclassified.join(', ')}`);
if (stale.length) details.push(`stale assignments: ${stale.join(', ')}`);
throw new Error(details.join('; '));
}
}
function collectSources(directory, output = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
const relativeDirectory = path.relative(repoRoot, absolutePath).split(path.sep).join('/');
if (!GENERATED_OR_DEPENDENCY_DIRECTORIES.has(relativeDirectory)) collectSources(absolutePath, output);
} else if (SOURCE_EXTENSION.test(entry.name) && !TEST_FILE.test(entry.name)) {
output.push(path.relative(repoRoot, absolutePath).split(path.sep).join('/'));
}
}
return output;
}
function main() {
const discoveredSources = SOURCE_ROOTS.flatMap(root => collectSources(path.join(repoRoot, root))).sort();
const externallyGatedSources = Object.values(EXTERNALLY_GATED_SOURCES).flat();
validateCoverageInventory(discoveredSources, VITEST_COVERAGE_INCLUDE, externallyGatedSources);
console.log(`Coverage inventory passed: ${VITEST_COVERAGE_INCLUDE.length} V8-covered, ${externallyGatedSources.length} externally gated`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (error) {
console.error(`Coverage inventory failed: ${error.message}`);
process.exitCode = 1;
}
}
+72
View File
@@ -0,0 +1,72 @@
export const VITEST_COVERAGE_INCLUDE = Object.freeze([
'server/chat.js',
'server/media-state.js',
'server/ops.js',
'server/rate-limiter.js',
'shared/blacklist.js',
'shared/invite-links.js',
'shared/names.js',
'extension/canonical-media-state.js',
'extension/chat-activity.js',
'extension/chat-crypto.js',
'extension/chat-format.js',
'extension/chat-session.js',
'extension/chat-wire.js',
'extension/episode-utils.js',
'extension/host-access.js',
'extension/media-frame-target.js',
'extension/offline-media-intent.js',
'extension/title-privacy.js',
'scripts/release-artifact-checks.mjs'
]);
// Exact list by design: adding a runtime/tooling module requires choosing its
// automated gate instead of silently leaving it unmeasured.
export const EXTERNALLY_GATED_SOURCES = Object.freeze({
'packed extension E2E': Object.freeze([
'extension/audio-options.js',
'extension/background.js',
'extension/bridge.js',
'extension/chat-overlay.js',
'extension/content.js',
'extension/i18n.js',
'extension/media-frame-monitor.js',
'extension/modules/tab-manager.js',
'extension/page-api-seek-overrides.js',
'extension/popup.js',
'extension/theme-init.js',
'shared/constants.js'
]),
'relay integration': Object.freeze([
'server/index.js'
]),
'release and repository integration': Object.freeze([
'scripts/build-extension.cjs',
'scripts/check-coverage-inventory.mjs',
'scripts/coverage-plan.mjs',
'scripts/prepare-release.mjs',
'scripts/release-local-gate.mjs',
'scripts/release-preflight.mjs',
'scripts/test-audio-settings.mjs',
'scripts/test-chat-settings.mjs',
'scripts/test-content-video-finder.cjs',
'scripts/test-locales.cjs',
'scripts/test-popup-refresh-cooldown.mjs',
'scripts/test-server-routes.mjs',
'scripts/test-server-ws.mjs',
'scripts/test-website-locales.mjs',
'scripts/test-website-theme.mjs',
'scripts/translate-locales-tool.cjs',
'scripts/validate-brand-names.cjs',
'scripts/verify-published-release.mjs',
'scripts/verify-release.mjs'
]),
'website build and contract checks': Object.freeze([
'website/app.js',
'website/build.cjs',
'website/flag-font-utils.cjs',
'website/lang-init.js',
'website/submit-indexnow.cjs',
'website/tools/subset-flag-font.mjs'
])
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { validateCoverageInventory } from './check-coverage-inventory.mjs';
describe('coverage inventory', () => {
it('accepts an exact, unique classification', () => {
expect(() => validateCoverageInventory(
['covered.js', 'browser.js'],
['covered.js'],
['browser.js']
)).not.toThrow();
});
it('rejects unclassified, stale, and duplicate assignments', () => {
expect(() => validateCoverageInventory(['new.js'], [], []))
.toThrow('unclassified sources: new.js');
expect(() => validateCoverageInventory([], ['deleted.js'], []))
.toThrow('stale assignments: deleted.js');
expect(() => validateCoverageInventory(['same.js'], ['same.js'], ['same.js']))
.toThrow('assigned more than once: same.js');
});
});
+93
View File
@@ -0,0 +1,93 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { versionFromTag } from './release-artifact-checks.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function replaceExactly(text, pattern, replacement, label) {
const matches = String(text).match(pattern);
if (!matches || matches.length !== 1) {
throw new Error(`${label} must contain exactly one release-version marker`);
}
return text.replace(pattern, replacement);
}
function writeJson(root, relativePath, update) {
const absolutePath = path.join(root, relativePath);
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
update(value);
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function updateText(root, relativePath, pattern, replacement, label) {
const absolutePath = path.join(root, relativePath);
const current = fs.readFileSync(absolutePath, 'utf8');
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
}
export function prepareRelease(version, date = new Date(), root = repoRoot) {
versionFromTag(`v${version}`);
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
writeJson(root, 'package.json', value => { value.version = version; });
writeJson(root, 'package-lock.json', value => {
value.version = version;
value.packages[''].version = version;
});
writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
writeJson(root, 'website/version.json', value => {
value.version = version;
value.date = timestamp;
});
updateText(
root,
'shared/constants.js',
/export const APP_VERSION = ["'][^"']+["'];/gu,
`export const APP_VERSION = "${version}";`,
'shared/constants.js'
);
updateText(
root,
'website/template.html',
/"softwareVersion": "[^"]+"/gu,
`"softwareVersion": "${version}"`,
'website/template.html'
);
updateText(
root,
'website/llms.txt',
/Current website release: .+/gu,
`Current website release: ${version}`,
'website/llms.txt'
);
updateText(
root,
'README.md',
/Release-v\d+\.\d+\.\d+-blue/gu,
`Release-v${version}-blue`,
'README.md release badge'
);
updateText(
root,
'README.md',
/New v\d+\.\d+\.\d+ Release!/gu,
`New v${version} Release!`,
'README.md release banner'
);
console.log(`Prepared release v${version} at ${timestamp}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
if (process.argv.length < 3 || process.argv.length > 4) {
throw new Error('Usage: prepare-release.mjs MAJOR.MINOR.PATCH [ISO_TIMESTAMP]');
}
const date = process.argv[3] ? new Date(process.argv[3]) : new Date();
if (Number.isNaN(date.getTime())) throw new Error(`Invalid release timestamp: ${process.argv[3]}`);
prepareRelease(process.argv[2], date);
} catch (error) {
console.error(`Release preparation failed: ${error.message}`);
process.exitCode = 1;
}
}
+90
View File
@@ -0,0 +1,90 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { validateReleaseSourceVersion } from './release-preflight.mjs';
import { prepareRelease, replaceExactly } from './prepare-release.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const temporaryDirectories = [];
const releaseSourcePaths = [
'README.md',
'extension/manifest.base.json',
'package.json',
'package-lock.json',
'shared/constants.js',
'website/llms.txt',
'website/template.html',
'website/version.json'
];
function createReleaseFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-prepare-release-'));
temporaryDirectories.push(root);
for (const relativePath of releaseSourcePaths) {
const target = path.join(root, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(repoRoot, relativePath), target);
}
return root;
}
function readFixture(root) {
return Object.fromEntries(releaseSourcePaths.map(relativePath => [
relativePath,
fs.readFileSync(path.join(root, relativePath), 'utf8')
]));
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe('release preparation helpers', () => {
it('replaces one and only one version marker', () => {
expect(replaceExactly('version=3.1.4', /version=\d+\.\d+\.\d+/gu, 'version=3.1.5', 'fixture'))
.toBe('version=3.1.5');
expect(replaceExactly('New v3.1.4 Release!', /New v\d+\.\d+\.\d+ Release!/gu, 'New v3.1.5 Release!', 'README.md release banner'))
.toBe('New v3.1.5 Release!');
expect(() => replaceExactly('none', /version=\d+/gu, 'version=4', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker');
expect(() => replaceExactly('version=1 version=2', /version=\d+/gu, 'version=3', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker');
});
it('updates and validates every release-version source, including both README markers', () => {
const root = createReleaseFixture();
prepareRelease('9.8.7', new Date('2030-04-05T06:07:08Z'), root);
expect(() => validateReleaseSourceVersion('9.8.7', root)).not.toThrow();
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('Release-v9.8.7-blue');
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('New v9.8.7 Release!');
expect(JSON.parse(fs.readFileSync(path.join(root, 'website/version.json'), 'utf8')).date)
.toBe('2030-04-05T06:07:08Z');
});
it('is deterministic when repeated with the tag timestamp and does not duplicate markers', () => {
const root = createReleaseFixture();
const timestamp = new Date('2031-02-03T04:05:06Z');
prepareRelease('9.8.7', timestamp, root);
const once = readFixture(root);
prepareRelease('9.8.7', timestamp, root);
expect(readFixture(root)).toEqual(once);
expect(once['README.md'].match(/Release-v9\.8\.7-blue/gu)).toHaveLength(1);
expect(once['README.md'].match(/New v9\.8\.7 Release!/gu)).toHaveLength(1);
});
it('rejects invalid versions before changing release sources', () => {
const root = createReleaseFixture();
const before = readFixture(root);
expect(() => prepareRelease('9.8.7;echo-unsafe', new Date('2030-01-01T00:00:00Z'), root))
.toThrow('vMAJOR.MINOR.PATCH');
expect(readFixture(root)).toEqual(before);
});
});
+123
View File
@@ -0,0 +1,123 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
export const RELEASE_ASSET_NAMES = Object.freeze([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS'
]);
const REQUIRED_ARCHIVE_ENTRIES = Object.freeze([
'manifest.json',
'background.js',
'content.js',
'popup.html',
'shared/constants.js'
]);
export function versionFromTag(tag) {
const match = /^v(\d+\.\d+\.\d+)$/u.exec(tag || '');
if (!match) throw new Error(`Release tag must match vMAJOR.MINOR.PATCH: ${tag || '<empty>'}`);
return match[1];
}
export function parseChecksumFile(text) {
const checksums = new Map();
for (const [index, rawLine] of String(text).split(/\r?\n/u).entries()) {
if (!rawLine.trim()) continue;
const match = /^([a-fA-F0-9]{64}) ([^/\\]+)$/u.exec(rawLine);
if (!match) throw new Error(`Invalid SHA256SUMS line ${index + 1}: ${rawLine}`);
const [, digest, filename] = match;
if (checksums.has(filename)) throw new Error(`Duplicate checksum entry: ${filename}`);
checksums.set(filename, digest.toLowerCase());
}
return checksums;
}
export async function sha256File(filePath) {
const hash = crypto.createHash('sha256');
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
return hash.digest('hex');
}
export function validateReleaseAssetNames(assetNames) {
const actual = [...new Set(assetNames)].sort();
const expected = [...RELEASE_ASSET_NAMES].sort();
if (actual.length !== assetNames.length) throw new Error('Release contains duplicate asset names');
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Release assets differ: expected ${expected.join(', ')}, got ${actual.join(', ')}`);
}
}
export function validateArchiveEntries(browserName, archiveEntries) {
if (!Array.isArray(archiveEntries)) throw new Error(`${browserName} archive entries must be an array`);
const seen = new Set();
const files = new Set();
for (const entry of archiveEntries) {
if (typeof entry !== 'string' || !entry) throw new Error(`${browserName} archive contains an invalid entry`);
if (seen.has(entry)) throw new Error(`${browserName} archive contains duplicate entry: ${entry}`);
seen.add(entry);
if (entry.startsWith('/')
|| /^[A-Za-z]:[\\/]/u.test(entry)
|| entry.includes('\\')
|| entry.includes('\0')
|| entry.split('/').includes('..')) {
throw new Error(`${browserName} archive contains unsafe path: ${entry}`);
}
if (entry.endsWith('/')) continue;
files.add(entry);
if (/\.test\.[cm]?js$/u.test(entry)
|| entry === 'manifest.base.json'
|| entry === '.DS_Store'
|| entry.endsWith('/.DS_Store')) {
throw new Error(`${browserName} archive contains development-only file: ${entry}`);
}
}
for (const required of REQUIRED_ARCHIVE_ENTRIES) {
if (!seen.has(required)) throw new Error(`${browserName} archive is missing ${required}`);
}
return [...files].sort();
}
export function validateManifest(browserName, manifest, expectedVersion) {
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error(`${browserName} manifest must be a JSON object`);
}
if (manifest.version !== expectedVersion) {
throw new Error(`${browserName} manifest version ${manifest.version || '<missing>'} does not match ${expectedVersion}`);
}
if (manifest.manifest_version !== 3) {
throw new Error(`${browserName} manifest must use Manifest V3`);
}
if (browserName === 'chrome') {
if (manifest.background?.service_worker !== 'background.js') {
throw new Error('Chrome manifest must use background.js as its service worker');
}
if (manifest.background?.type !== 'module') throw new Error('Chrome background must be an ES module');
if (manifest.browser_specific_settings?.gecko) {
throw new Error('Chrome manifest must not contain Firefox gecko settings');
}
} else if (browserName === 'firefox') {
if (!Array.isArray(manifest.background?.scripts)
|| manifest.background.scripts.length !== 1
|| manifest.background.scripts[0] !== 'background.js') {
throw new Error('Firefox manifest must use background.js as its background script');
}
if (manifest.background?.type !== 'module') throw new Error('Firefox background must be an ES module');
if (manifest.browser_specific_settings?.gecko?.id !== 'koalasync@koalastuff.net') {
throw new Error('Firefox manifest is missing the expected extension ID');
}
} else {
throw new Error(`Unsupported browser archive: ${browserName}`);
}
}
export function validateArchiveParity(chromeEntries, firefoxEntries) {
const chrome = [...chromeEntries].sort();
const firefox = [...firefoxEntries].sort();
if (JSON.stringify(chrome) !== JSON.stringify(firefox)) {
const chromeOnly = chrome.filter(entry => !firefox.includes(entry));
const firefoxOnly = firefox.filter(entry => !chrome.includes(entry));
throw new Error(`Archive contents differ; Chrome only: ${chromeOnly.join(', ') || '<none>'}; Firefox only: ${firefoxOnly.join(', ') || '<none>'}`);
}
}
+153
View File
@@ -0,0 +1,153 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
parseChecksumFile,
sha256File,
validateArchiveEntries,
validateArchiveParity,
validateManifest,
validateReleaseAssetNames,
versionFromTag
} from './release-artifact-checks.mjs';
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe('published release artifact checks', () => {
it('accepts semantic release tags and rejects ambiguous versions', () => {
expect(versionFromTag('v3.1.4')).toBe('3.1.4');
for (const invalid of ['3.1.4', 'v3.1', 'v3.1.4-beta', '', null]) {
expect(() => versionFromTag(invalid)).toThrow('vMAJOR.MINOR.PATCH');
}
});
it('parses strict sha256sum output and rejects duplicate or unsafe names', () => {
const digest = 'a'.repeat(64);
expect(parseChecksumFile(`${digest} koalasync-chrome.zip\n`).get('koalasync-chrome.zip')).toBe(digest);
expect(() => parseChecksumFile(`${digest} *koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
expect(() => parseChecksumFile(`${digest} ../koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
expect(() => parseChecksumFile(`${digest} chrome.zip\n${digest} chrome.zip`)).toThrow('Duplicate checksum');
});
it('computes file digests without platform-specific checksum commands', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-checksum-test-'));
temporaryDirectories.push(directory);
const filePath = path.join(directory, 'fixture.txt');
fs.writeFileSync(filePath, 'koalasync\n');
await expect(sha256File(filePath)).resolves.toBe('2ee7e74af89fb4f42d4fa1bcf93c588bf4460a62c8def5c254bba7b5ae6cd544');
});
it('requires the exact public release asset inventory', () => {
expect(() => validateReleaseAssetNames([
'koalasync-firefox.zip',
'SHA256SUMS',
'koalasync-chrome.zip'
])).not.toThrow();
expect(() => validateReleaseAssetNames(['koalasync-chrome.zip'])).toThrow('Release assets differ');
expect(() => validateReleaseAssetNames([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS',
'debug.log'
])).toThrow('Release assets differ');
expect(() => validateReleaseAssetNames([
'koalasync-chrome.zip',
'koalasync-firefox.zip',
'SHA256SUMS',
'SHA256SUMS'
])).toThrow('duplicate asset names');
});
it('rejects missing, duplicate, traversal, and development-only archive entries', () => {
const valid = ['manifest.json', 'background.js', 'content.js', 'popup.html', 'shared/constants.js'];
expect(validateArchiveEntries('chrome', valid)).toEqual([...valid].sort());
expect(validateArchiveEntries('chrome', [...valid, 'assets/'])).toEqual([...valid].sort());
expect(() => validateArchiveEntries('chrome', null)).toThrow('entries must be an array');
expect(() => validateArchiveEntries('chrome', [...valid, ''])).toThrow('invalid entry');
expect(() => validateArchiveEntries('chrome', valid.slice(1))).toThrow('missing manifest.json');
expect(() => validateArchiveEntries('chrome', [...valid, 'content.js'])).toThrow('duplicate entry');
expect(() => validateArchiveEntries('chrome', [...valid, '../secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, '../'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, 'C:/secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, '..\\secret'])).toThrow('unsafe path');
expect(() => validateArchiveEntries('chrome', [...valid, 'content.test.mjs'])).toThrow('development-only');
expect(() => validateArchiveEntries('chrome', [...valid, 'assets/.DS_Store'])).toThrow('development-only');
});
it('validates browser-specific manifests and version alignment', () => {
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).not.toThrow();
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'module' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).not.toThrow();
expect(() => validateManifest('chrome', {
version: '3.1.3',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).toThrow('does not match 3.1.4');
expect(() => validateManifest('chrome', null, '3.1.4')).toThrow('must be a JSON object');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 2,
background: { service_worker: 'background.js', type: 'module' }
}, '3.1.4')).toThrow('Manifest V3');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'wrong.js', type: 'module' }
}, '3.1.4')).toThrow('service worker');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'classic' }
}, '3.1.4')).toThrow('ES module');
expect(() => validateManifest('chrome', {
version: '3.1.4',
manifest_version: 3,
background: { service_worker: 'background.js', type: 'module' },
browser_specific_settings: { gecko: { id: 'unexpected@example.test' } }
}, '3.1.4')).toThrow('must not contain Firefox');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['wrong.js'], type: 'module' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).toThrow('background script');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'classic' },
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
}, '3.1.4')).toThrow('ES module');
expect(() => validateManifest('firefox', {
version: '3.1.4',
manifest_version: 3,
background: { scripts: ['background.js'], type: 'module' }
}, '3.1.4')).toThrow('expected extension ID');
expect(() => validateManifest('safari', {
version: '3.1.4',
manifest_version: 3
}, '3.1.4')).toThrow('Unsupported browser');
});
it('requires Chrome and Firefox to ship the same file set', () => {
expect(() => validateArchiveParity(['a', 'b'], ['b', 'a'])).not.toThrow();
expect(() => validateArchiveParity(['a', 'chrome-only'], ['a', 'firefox-only'])).toThrow(
'Chrome only: chrome-only; Firefox only: firefox-only'
);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
import {
parseCheckRuns,
validateRequiredChecks
} from './release-preflight.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function capture(command, args) {
return execFileSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
function run(command, args) {
execFileSync(command, args, {
cwd: repoRoot,
stdio: 'inherit'
});
}
export function parseGateArgs(args) {
const values = Array.from(args);
const candidate = values.includes('--candidate');
const positional = values.filter(value => value !== '--candidate');
if (positional.length !== 1) {
throw new Error('Usage: npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]');
}
const version = versionFromTag(`v${positional[0]}`);
return { version, candidate };
}
export function playwrightImageFromLock(lock) {
const version = lock?.packages?.['node_modules/@playwright/test']?.version;
if (!/^\d+\.\d+\.\d+$/u.test(version || '')) {
throw new Error('package-lock.json must pin node_modules/@playwright/test to an exact version');
}
return `mcr.microsoft.com/playwright:v${version}-noble`;
}
export function linuxGateCommand() {
return [
'git clone --no-local /src /work',
'cd /work',
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
'npm ci',
'npm ci --prefix server',
'npm run verify',
'npm run test:e2e'
].join(' && ');
}
export function parseRemoteMain(text) {
const match = /^([a-f0-9]{40})\trefs\/heads\/main\s*$/u.exec(String(text));
if (!match) throw new Error(`could not resolve origin main from: ${String(text).trim() || '<empty>'}`);
return match[1];
}
export function validateReleaseWorkflowContract(text) {
const workflow = String(text);
const image = 'ghcr.io/shik3i/koalasync';
if (!workflow.includes(`IMAGE: ${image}`)) {
throw new Error(`release workflow must define the lowercase canonical image ${image}`);
}
for (const reference of ['images: ${{ env.IMAGE }}', 'subject-name: ${{ env.IMAGE }}']) {
if (!workflow.includes(reference)) throw new Error(`release workflow must use ${reference}`);
}
if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/u.test(workflow)) {
throw new Error('release workflow must not derive a Docker image from case-preserving github.repository');
}
for (const marker of [
'prepare-release:',
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
'node scripts/release-preflight.mjs --sources "$VERSION"',
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
'git push origin HEAD:main',
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
]) {
if (!workflow.includes(marker)) {
throw new Error(`release workflow must preserve automatic tag versioning: ${marker}`);
}
}
const preparedCheckout = 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}';
if ((workflow.match(new RegExp(preparedCheckout.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'gu')) || []).length < 3) {
throw new Error('release workflow must use the prepared commit for verification and all release builds');
}
if (/git push origin HEAD:main\s*(?:\|\||;\s*true)/u.test(workflow)) {
throw new Error('release workflow must stop when the automatic main push fails');
}
return image;
}
function assertCleanTree() {
const status = capture('git', ['status', '--porcelain=v1']);
if (status) throw new Error(`release gate requires a clean working tree:\n${status}`);
}
function assertFinalMainChecks() {
const branch = capture('git', ['branch', '--show-current']);
if (branch !== 'main') throw new Error(`final release gate requires branch main, found ${branch || '<detached>'}`);
const head = capture('git', ['rev-parse', 'HEAD']);
const remoteMain = parseRemoteMain(capture('git', [
'ls-remote', '--exit-code', 'origin', 'refs/heads/main'
]));
if (head !== remoteMain) throw new Error(`HEAD ${head} does not match origin/main ${remoteMain}`);
const checksText = capture('gh', [
'api', `repos/Shik3i/KoalaSync/commits/${head}/check-runs`,
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]);
// Model the release workflow querying this commit while its own preflight
// check is still running. This exact state broke the first v3.1.5 attempt.
const checks = parseCheckRuns(`${checksText}\npreflight\t\tlocal://self-check`);
validateRequiredChecks(checks);
}
async function smokeRelayImage(image) {
const containerId = capture('docker', [
'run', '--detach', '--platform', 'linux/amd64', '--publish', '127.0.0.1::3000',
'--env', 'SERVER_SALT=release-local-gate-salt-with-more-than-thirty-two-chars',
image
]);
try {
const portOutput = capture('docker', ['port', containerId, '3000/tcp']);
const port = /:(\d+)$/u.exec(portOutput)?.[1];
if (!port) throw new Error(`could not resolve relay host port: ${portOutput}`);
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: globalThis.AbortSignal.timeout(1000)
});
if (response.ok) return;
} catch (_error) {
// Container is still starting.
}
await new Promise(resolve => setTimeout(resolve, 250));
}
run('docker', ['logs', containerId]);
throw new Error('relay container did not become healthy within 30 seconds');
} finally {
run('docker', ['rm', '--force', containerId]);
}
}
export async function runReleaseGate({ version, candidate }) {
assertCleanTree();
validateReleaseWorkflowContract(fs.readFileSync(
path.join(repoRoot, '.github/workflows/release.yml'), 'utf8'
));
if (!candidate) assertFinalMainChecks();
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'));
const playwrightImage = playwrightImageFromLock(lock);
run('docker', ['pull', '--platform', 'linux/amd64', playwrightImage]);
run('docker', [
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
'--env', `RELEASE_VERSION=${version}`,
'--volume', `${repoRoot}:/src:ro`, playwrightImage,
'bash', '-lc', linuxGateCommand()
]);
const relayImage = `koalasync:${version}-release-gate`;
run('docker', [
'build', '--platform', 'linux/amd64',
'--file', 'server/Dockerfile', '--tag', relayImage, '.'
]);
await smokeRelayImage(relayImage);
console.log(`Local ${candidate ? 'candidate' : 'final'} release gate passed for v${version}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
await runReleaseGate(parseGateArgs(process.argv.slice(2)));
} catch (error) {
console.error(`Local release gate failed: ${error.message}`);
process.exitCode = 1;
}
}
+83
View File
@@ -0,0 +1,83 @@
import fs from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
linuxGateCommand,
parseGateArgs,
parseRemoteMain,
playwrightImageFromLock,
validateReleaseWorkflowContract
} from './release-local-gate.mjs';
describe('local release gate contract', () => {
it('requires one exact version and makes candidate mode explicit', () => {
expect(parseGateArgs(['3.1.5'])).toEqual({ version: '3.1.5', candidate: false });
expect(parseGateArgs(['3.1.5', '--candidate'])).toEqual({ version: '3.1.5', candidate: true });
expect(() => parseGateArgs([])).toThrow('Usage: npm run release:gate');
expect(() => parseGateArgs(['3.1'])).toThrow('Release tag must match vMAJOR.MINOR.PATCH');
});
it('derives an exact official Playwright Linux image from the lockfile', () => {
expect(playwrightImageFromLock({
packages: { 'node_modules/@playwright/test': { version: '1.62.0' } }
})).toBe('mcr.microsoft.com/playwright:v1.62.0-noble');
expect(() => playwrightImageFromLock({ packages: {} })).toThrow('must pin');
});
it('extracts main only from the exact remote branch record', () => {
const sha = '0123456789abcdef0123456789abcdef01234567';
expect(parseRemoteMain(`${sha}\trefs/heads/main\n`)).toBe(sha);
expect(() => parseRemoteMain('')).toThrow('could not resolve origin main');
expect(() => parseRemoteMain(`${sha}\trefs/heads/not-main`)).toThrow('could not resolve origin main');
});
it('requires one lowercase registry image throughout the release workflow', () => {
const valid = [
'IMAGE: ghcr.io/shik3i/koalasync',
'images: ${{ env.IMAGE }}',
'subject-name: ${{ env.IMAGE }}',
'prepare-release:',
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
'node scripts/release-preflight.mjs --sources "$VERSION"',
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
'git push origin HEAD:main',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
].join('\n');
expect(validateReleaseWorkflowContract(valid)).toBe('ghcr.io/shik3i/koalasync');
expect(() => validateReleaseWorkflowContract(valid.replace(
'IMAGE: ghcr.io/shik3i/koalasync',
'IMAGE: ghcr.io/${{ github.repository }}'
))).toThrow('lowercase canonical image');
expect(() => validateReleaseWorkflowContract(`${valid}\n${'ghcr.io/${{ github.repository }}'}`))
.toThrow('case-preserving github.repository');
});
it('enforces the automatic version commit, direct push, prepared source, and final publication contract', () => {
const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8');
expect(validateReleaseWorkflowContract(workflow)).toBe('ghcr.io/shik3i/koalasync');
expect(() => validateReleaseWorkflowContract(workflow.replace(
'git push origin HEAD:main',
'git push origin HEAD:release'
))).toThrow('automatic tag versioning');
expect(() => validateReleaseWorkflowContract(workflow.replace(
'git push origin HEAD:main',
'git push origin HEAD:main || true'
))).toThrow('stop when the automatic main push fails');
});
it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {
expect(linuxGateCommand()).toBe([
'git clone --no-local /src /work',
'cd /work',
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
'npm ci',
'npm ci --prefix server',
'npm run verify',
'npm run test:e2e'
].join(' && '));
});
});
+157
View File
@@ -0,0 +1,157 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
export const REQUIRED_RELEASE_CHECKS = Object.freeze(['verify', 'node20', 'e2e']);
export function parseCheckRuns(text) {
return String(text).split(/\r?\n/u).filter(Boolean).map(line => {
const fields = line.split('\t');
if (fields.length < 2 || !fields[0]) throw new Error(`Invalid check-run record: ${line}`);
const [name, conclusion = '', url = ''] = fields;
return { name, conclusion, url };
});
}
export function validateRequiredChecks(checkRuns, required = REQUIRED_RELEASE_CHECKS) {
for (const name of required) {
const matches = checkRuns.filter(check => check.name === name);
if (matches.length === 0) throw new Error(`Required check is missing for the release commit: ${name}`);
if (matches.some(check => check.conclusion !== 'success')) {
const conclusions = matches.map(check => check.conclusion || 'pending').join(', ');
throw new Error(`Required check ${name} did not succeed: ${conclusions}`);
}
}
}
function run(command, args) {
return execFileSync(command, args, {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
export function validateRepositoryName(repo) {
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo || '')) {
throw new Error(`Invalid GitHub repository: ${repo || '<empty>'}`);
}
return repo;
}
export function validateVersionSnapshot(expectedVersion, snapshot) {
for (const [label, actualVersion] of Object.entries(snapshot)) {
if (actualVersion !== expectedVersion) {
throw new Error(`${label} version ${actualVersion || '<missing>'} does not match tag version ${expectedVersion}`);
}
}
}
function versionFromMarker(text, pattern, label) {
const matches = [...String(text).matchAll(pattern)];
if (matches.length !== 1) {
throw new Error(`${label} must contain exactly one release-version marker`);
}
return matches[0][1];
}
export function validateReleaseSourceVersion(expectedVersion, repoRoot = process.cwd()) {
const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
const packageJson = readJson('package.json');
const packageLock = readJson('package-lock.json');
const manifest = readJson('extension/manifest.base.json');
const websiteVersion = readJson('website/version.json');
const constants = fs.readFileSync(path.join(repoRoot, 'shared/constants.js'), 'utf8');
const websiteTemplate = fs.readFileSync(path.join(repoRoot, 'website/template.html'), 'utf8');
const websiteLlms = fs.readFileSync(path.join(repoRoot, 'website/llms.txt'), 'utf8');
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
validateVersionSnapshot(expectedVersion, {
'package.json': packageJson.version,
'package-lock.json': packageLock.version,
'package-lock root package': packageLock.packages?.['']?.version,
'extension manifest': manifest.version,
'shared constants': versionFromMarker(
constants,
/export const APP_VERSION = ["']([^"']+)["'];/gu,
'shared/constants.js'
),
'website/version.json': websiteVersion.version,
'website template': versionFromMarker(
websiteTemplate,
/"softwareVersion": "([^"]+)"/gu,
'website/template.html'
),
'website llms': versionFromMarker(
websiteLlms,
/Current website release: (\d+\.\d+\.\d+)/gu,
'website/llms.txt'
),
'README release badge': versionFromMarker(
readme,
/Release-v(\d+\.\d+\.\d+)-blue/gu,
'README.md release badge'
),
'README release banner': versionFromMarker(
readme,
/New v(\d+\.\d+\.\d+) Release!/gu,
'README.md release banner'
)
});
}
export function verifyReleaseRef({ tag, repo }) {
const version = versionFromTag(tag);
validateRepositoryName(repo);
const tagRef = `refs/tags/${tag}`;
if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') {
throw new Error(`${tag} must be an annotated tag`);
}
const tagCommit = run('git', ['rev-list', '-n', '1', tagRef]);
const mainCommit = run('git', ['rev-parse', 'origin/main']);
if (tagCommit !== mainCommit) {
throw new Error(`Release tag ${tag} points to ${tagCommit}, but origin/main is ${mainCommit}`);
}
const checks = parseCheckRuns(run('gh', [
'api', `repos/${repo}/commits/${tagCommit}/check-runs`,
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]));
validateRequiredChecks(checks);
const releaseTimestamp = run('git', ['show', '-s', '--format=%cI', tagCommit]);
return { version, tagCommit, releaseTimestamp };
}
function main() {
if (process.argv[2] === '--sources') {
if (process.argv.length !== 4) {
throw new Error('Usage: release-preflight.mjs --sources MAJOR.MINOR.PATCH');
}
const version = versionFromTag(`v${process.argv[3]}`);
validateReleaseSourceVersion(version);
console.log(`Release sources match v${version}`);
return;
}
const tag = process.env.GITHUB_REF_NAME || '';
const repo = process.env.GITHUB_REPOSITORY || '';
const outputPath = process.env.GITHUB_OUTPUT || '';
const result = verifyReleaseRef({ tag, repo });
if (!outputPath) throw new Error('GITHUB_OUTPUT is required');
fs.appendFileSync(
outputPath,
`version=${result.version}\ntag_commit=${result.tagCommit}\nrelease_timestamp=${result.releaseTimestamp}\n`,
'utf8'
);
console.log(`Release preflight accepted ${tag} at ${result.tagCommit}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (error) {
console.error(`Release preflight failed: ${error.message}`);
process.exitCode = 1;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
parseCheckRuns,
validateRepositoryName,
validateRequiredChecks,
validateVersionSnapshot
} from './release-preflight.mjs';
describe('release preflight helpers', () => {
it('parses successful GitHub check runs', () => {
const checks = parseCheckRuns('verify\tsuccess\thttps://example.test/1\nnode20\tsuccess\thttps://example.test/2\ne2e\tsuccess\thttps://example.test/3');
expect(checks).toEqual([
{ name: 'verify', conclusion: 'success', url: 'https://example.test/1' },
{ name: 'node20', conclusion: 'success', url: 'https://example.test/2' },
{ name: 'e2e', conclusion: 'success', url: 'https://example.test/3' }
]);
expect(() => validateRequiredChecks(checks)).not.toThrow();
});
it('ignores an unrelated in-progress release check while validating required checks', () => {
const checks = parseCheckRuns([
'verify\tsuccess\thttps://example.test/verify',
'node20\tsuccess\thttps://example.test/node20',
'e2e\tsuccess\thttps://example.test/e2e',
'preflight\t\thttps://example.test/preflight'
].join('\n'));
expect(checks.at(-1)).toEqual({
name: 'preflight',
conclusion: '',
url: 'https://example.test/preflight'
});
expect(() => validateRequiredChecks(checks)).not.toThrow();
});
it('rejects missing, pending, and failed release checks', () => {
expect(() => validateRequiredChecks([{ name: 'verify', conclusion: 'success' }]))
.toThrow('Required check is missing for the release commit: node20');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: '' }
])).toThrow('Required check e2e did not succeed: pending');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'success' }
])).toThrow('Required check verify did not succeed: failure');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' },
{ name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'success' }
])).toThrow('Required check verify did not succeed: success, failure');
});
it('validates repository names and malformed check output', () => {
expect(validateRepositoryName('Shik3i/KoalaSync')).toBe('Shik3i/KoalaSync');
for (const invalid of ['', 'KoalaSync', 'owner/repo/extra', 'owner /repo']) {
expect(() => validateRepositoryName(invalid)).toThrow('Invalid GitHub repository');
}
expect(() => parseCheckRuns('verify')).toThrow('Invalid check-run record');
});
it('requires every prepared release source to match the tag version', () => {
expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5',
manifest: '3.1.5'
})).not.toThrow();
expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5',
manifest: '3.1.4'
})).toThrow('manifest version 3.1.4 does not match tag version 3.1.5');
});
});
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BLACKLIST_DOMAINS,
BLACKLIST_OVERRIDES_STORAGE_KEY,
BLACKLIST_SOURCE_DEFAULT,
BLACKLIST_SOURCE_USER,
CUSTOM_BLACKLIST_STORAGE_KEY,
createEmptyBlacklistOverrides,
deriveBlacklistOverrides,
getBlacklistEntries,
getEffectiveBlacklistDomains,
isUrlBlacklisted,
normalizeBlacklistDomain,
normalizeBlacklistOverrides,
parseBlacklistDomains
} from '../shared/blacklist.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
assert.equal(CUSTOM_BLACKLIST_STORAGE_KEY, 'customBlacklistDomains');
assert.equal(normalizeBlacklistDomain(' Example.COM. '), 'example.com');
assert.equal(normalizeBlacklistDomain('https://Video.Example.com/watch/123'), 'video.example.com');
assert.equal(normalizeBlacklistDomain('*.example.com'), null, 'wildcards are rejected');
assert.equal(normalizeBlacklistDomain('not a domain'), null, 'spaces are rejected');
const parsed = parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n');
assert.deepEqual(parsed.domains, ['example.com', 'sub.example.com'], 'domains are normalized and deduplicated');
assert.deepEqual(parsed.invalid, []);
const invalid = parseBlacklistDomains('example.com\nnot a domain');
assert.deepEqual(invalid.invalid, ['not a domain'], 'invalid entries are reported without partial silent saves');
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'missing local setting uses shipped defaults');
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'an explicitly empty local list stays empty');
assert.equal(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com']), true, 'subdomains match a parent domain');
assert.equal(isUrlBlacklisted('https://notgoogle.com/', ['google.com']), false, 'lookalike domains do not match');
assert.equal(isUrlBlacklisted('not a url', ['example.com']), false, 'invalid URLs are ignored');
// --- Delta storage: shipped defaults keep flowing in after the user edits ---
assert.equal(BLACKLIST_OVERRIDES_STORAGE_KEY, 'blacklistOverrides');
assert.deepEqual(createEmptyBlacklistOverrides(), { removedDefaults: [], addedDomains: [] });
// A user who removes two defaults and adds one of their own.
const edited = BLACKLIST_DOMAINS
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
.concat(['videos.example']);
const overrides = deriveBlacklistOverrides(edited);
assert.deepEqual(overrides.removedDefaults, ['reddit.com', 'imgur.com'], 'only the removed defaults are stored');
assert.deepEqual(overrides.addedDomains, ['videos.example'], 'only the added domains are stored');
const effective = getEffectiveBlacklistDomains(overrides);
const effectiveDomains = new Set(effective);
assert.equal(effectiveDomains.has('reddit.com'), false, 'a removed default stays removed');
assert.equal(effectiveDomains.has('videos.example'), true, 'an added domain stays added');
// The property that makes newly shipped defaults reach existing users: every
// shipped domain the user did not explicitly remove is part of the result, so a
// default added in a later version cannot be missing from a stored delta.
const removedSet = new Set(overrides.removedDefaults);
for (const domain of BLACKLIST_DOMAINS) {
assert.equal(
effectiveDomains.has(domain) || removedSet.has(domain),
true,
`shipped default ${domain} must be present unless explicitly removed`
);
}
// Legacy full-list snapshots migrate to the delta form.
assert.deepEqual(
deriveBlacklistOverrides(edited),
normalizeBlacklistOverrides(overrides),
'a legacy snapshot produces the same delta'
);
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'no stored delta uses shipped defaults');
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'a legacy empty snapshot still means no filtering');
// Re-adding a removed default clears the removal instead of stacking state.
const readded = deriveBlacklistOverrides(effective.concat(['reddit.com']), overrides);
const readdedRemovedDefaults = new Set(readded.removedDefaults);
assert.equal(readdedRemovedDefaults.has('reddit.com'), false, 're-adding a default clears its removal');
// A domain the user added explicitly stays tagged as theirs even once the same
// domain ships as a default, so dropping the default does not drop their entry.
const stillUser = deriveBlacklistOverrides(['google.com'], { removedDefaults: [], addedDomains: ['google.com'] });
assert.deepEqual(stillUser.addedDomains, ['google.com'], 'an explicit addition survives becoming a default');
// Contradictory stored state resolves in favour of the addition.
assert.deepEqual(
normalizeBlacklistOverrides({ removedDefaults: ['example.com'], addedDomains: ['example.com'] }),
{ removedDefaults: [], addedDomains: ['example.com'] },
'a domain cannot be removed and added at once'
);
assert.deepEqual(normalizeBlacklistOverrides('nonsense'), createEmptyBlacklistOverrides(), 'garbage storage falls back to defaults');
// Entries are tagged so the editor can show what came from where.
const entries = getBlacklistEntries(overrides);
assert.equal(entries.find(e => e.domain === 'videos.example').source, BLACKLIST_SOURCE_USER);
assert.equal(entries.find(e => e.domain === 'google.com').source, BLACKLIST_SOURCE_DEFAULT);
// Comment lines are editor notes, not domains, and never count as invalid.
const withComments = parseBlacklistDomains('# your entries\nvideos.example\n\n#shipped defaults\ngoogle.com');
assert.deepEqual(withComments.domains, ['videos.example', 'google.com'], 'comment lines are skipped');
assert.deepEqual(withComments.invalid, [], 'comment lines are not reported as invalid');
// Round trip through the grouped editor body: rendering with comment headers
// and saving it again must not change the stored delta.
const rendered = [
'# Your entries',
...entries.filter(e => e.source === BLACKLIST_SOURCE_USER).map(e => e.domain),
'',
'# Shipped defaults',
...entries.filter(e => e.source === BLACKLIST_SOURCE_DEFAULT).map(e => e.domain)
].join('\n');
const roundTripped = parseBlacklistDomains(rendered);
assert.deepEqual(roundTripped.invalid, [], 'the rendered editor body contains no invalid entries');
assert.deepEqual(
deriveBlacklistOverrides(roundTripped.domains, overrides),
normalizeBlacklistOverrides(overrides),
'render then save leaves the delta unchanged'
);
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/, 'the delta is saved locally');
assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/, 'the list is never synced');
assert.match(popupSource, /chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/, 'the legacy snapshot is cleaned up after migration');
assert.match(popupSource, /isUrlBlacklisted\(tab\.url, blacklistDomains\)/, 'tab filtering uses the effective custom list');
// A broad parent domain must not hide a host with a dedicated player path,
// but an exact user entry for that host still filters it.
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', BLACKLIST_DOMAINS), false);
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', ['drive.google.com']), true);
assert.equal(isUrlBlacklisted('https://docs.google.com/document/d/x', BLACKLIST_DOMAINS), true);
assert.equal(isUrlBlacklisted('https://mail.google.com/mail/u/0', BLACKLIST_DOMAINS), true);
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
assert.match(popupHtml, /id="blacklistDomains"/, 'settings UI contains the editable domain list');
assert.match(popupHtml, /id="blacklistReset"/, 'settings UI contains a defaults reset');
console.log('blacklist settings tests passed');
+31
View File
@@ -184,6 +184,37 @@ assert.strictEqual(
'a hidden playing preload must not outrank the visible paused player'
);
// Crunchyroll wraps its Bitmovin player in `display: contents`. Such a wrapper
// has no box of its own and checkVisibility() returns false for the wrapper,
// even though the descendant video is fully visible.
const displayContentsPlayer = makeVideo('display-contents-player', 1920, 1080, {
controls: false,
paused: true,
duration: 1420
});
const displayContentsWrapper = {
_style: { display: 'contents', visibility: 'visible', opacity: '1' },
checkVisibility() { return false; },
parentElement: null
};
displayContentsPlayer.parentElement = displayContentsWrapper;
displayContentsPlayer.checkVisibility = () => true;
const displayContentsDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [displayContentsPlayer];
return [];
}
};
attachRenderEnvironment(
displayContentsDocument,
[displayContentsPlayer, displayContentsWrapper]
);
assert.strictEqual(
findVideo(displayContentsDocument),
displayContentsPlayer,
'a visible player inside a display: contents wrapper must remain selectable'
);
const belowFoldPlayer = makeVideo('below-fold-player', 800, 450, {
controls: true,
duration: 1200
-76
View File
@@ -1,76 +0,0 @@
import assert from 'node:assert/strict';
import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js';
// --- extractEpisodeId ---
// Standard SxxExx patterns
assert.equal(extractEpisodeId('S01E01'), 'S01E01');
assert.equal(extractEpisodeId('S1E1'), 'S01E01');
assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive');
assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02');
assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02');
// Separators: dash, dot, slash, colon, space, comma
assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator');
assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator');
assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)');
assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator');
assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator');
assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator');
// German / multi-language
assert.equal(extractEpisodeId('Folge 5'), 'EP005');
assert.equal(extractEpisodeId('Episode 12'), 'EP012');
assert.equal(extractEpisodeId('Ep. 3'), 'EP003');
assert.equal(extractEpisodeId('#42'), 'EP042');
// Edge cases
assert.equal(extractEpisodeId(null), null);
assert.equal(extractEpisodeId(undefined), null);
assert.equal(extractEpisodeId(''), null);
assert.equal(extractEpisodeId(123), null);
assert.equal(extractEpisodeId('Some Movie Title'), null);
assert.equal(extractEpisodeId('Breaking Bad'), null);
// Leading zeros preserved
assert.equal(extractEpisodeId('S01E001'), 'S01E001');
// --- sameEpisode ---
// Identical episodes
assert.equal(sameEpisode('S01E01', 'S01E01'), true);
assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English');
// Different episodes
assert.equal(sameEpisode('S01E01', 'S01E02'), false);
assert.equal(sameEpisode('Folge 1', 'Folge 2'), false);
assert.equal(sameEpisode('S01E01', 'S02E01'), false);
// Both unknown → assume same (backward compat)
assert.equal(sameEpisode(null, null), true);
assert.equal(sameEpisode(undefined, undefined), true);
assert.equal(sameEpisode('', ''), true);
assert.equal(sameEpisode('Some Movie', 'Some Movie'), true);
assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ');
// One unknown, one known → different
assert.equal(sameEpisode('S01E01', null), false);
assert.equal(sameEpisode(null, 'Episode 5'), false);
assert.equal(sameEpisode(undefined, 'S01E01'), false);
// Mixed formats — only match when the same episode
assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx');
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode');
assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X');
assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X');
// Different format IDs → different (season-tagged vs seasonless)
assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs');
assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs');
// parseable but truly different
assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes');
assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons');
console.log('episode-utils tests passed');
-180
View File
@@ -1,180 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from '../extension/host-access.js';
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
assert.equal(normalizeTabId(null), null);
assert.equal(normalizeTabId(undefined), null);
assert.equal(normalizeTabId(''), null);
assert.equal(normalizeTabId(0), null);
assert.equal(normalizeTabId('42'), 42);
assert.equal(normalizeTabId(true), null);
assert.equal(normalizeTabId([42]), null);
assert.equal(normalizeTabId('42.5'), null);
assert.equal(normalizeTabId(' 42 '), 42);
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost:8096/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
assert.equal(describeTabUrl('chrome://extensions/'), null);
assert.equal(describeTabUrl('not a url'), null);
let containsRequest = null;
const deniedChrome = {
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
const access = await inspectTabHostAccess(deniedChrome, 42);
assert.equal(access.granted, false);
assert.equal(access.host, 'video.example');
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
let firefoxContainsRequest = null;
const firefoxChrome = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
firefoxContainsRequest = request;
return false;
}
}
};
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
assert.equal(firefoxAccess.host, 'localhost:8096');
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
const unknownPermissionChrome = {
runtime: {},
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: (_request, callback) => { callback(undefined); }
}
};
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
let requestedTabId = null;
const requestChrome = {
permissions: {
addHostAccessRequest: async request => { requestedTabId = request; }
}
};
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
let removedTabId = null;
const removeRequestChrome = {
permissions: {
removeHostAccessRequest: async request => { removedTabId = request; }
}
};
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
const callbackPermissionChrome = {
runtime: {},
permissions: {
request: (_request, callback) => { callback(true); }
}
};
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
assert.match(background, /await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/,
'SET_TARGET_TAB must await successful activation before acknowledging it');
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
'failed injection must register Chrome host-access request');
assert.match(background, /retryPendingTarget\(\)/,
'pending target must resume after the user grants access');
assert.match(background, /activationGeneration !== targetActivationGeneration/,
'stale concurrent tab activations must not overwrite the newest selection');
assert.match(background, /pendingTargetRequestId/,
'pending access recovery must use an identity token');
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
'unrelated permission grants must not activate a pending target');
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
'stale content-routing retries must not reactivate an old target');
assert.match(background, /message\.expectedTabId/,
'popup playback events must be rejected after their target changes');
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
'a target switch must finish an in-flight force sync on the old target');
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
'stale content scripts must not acknowledge force sync for a new target');
const activateTargetBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
assert.ok(
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
'a tab must not become current until its content script injection succeeds'
);
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
'clearing a pending target must also clear Chrome toolbar access requests');
assert.match(popup, /response\?\.status === 'host_permission_required'/,
'popup must render the structured host-access failure');
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
'retry button must request withheld host access directly');
assert.match(popup, /expectedCurrentTabId: tabId/,
'manual reinjection must be tied to the selected target identity');
assert.match(popup, /expectedTabId: tabId/,
'force sync must be tied to the tab whose time was sampled');
assert.doesNotMatch(tabManager, /injectContentScript/,
'tab reload recovery must use the guarded background activation path');
assert.equal(
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
1,
'target-tab closure must have exactly one state owner'
);
assert.match(popupHtml, /id="siteAccessNotice"/,
'popup must contain a persistent site-access notice');
console.log('host access recovery tests passed');
-56
View File
@@ -1,56 +0,0 @@
import assert from 'node:assert/strict';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
// --- getAvatarForName (deterministic) ---
// Exact matches
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
// Case insensitive
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
// Longest match wins (caterpillar > cat)
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
// Emoji with ZWJ sequences (multi-codepoint)
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
// Human-like characters
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
// Fallback
assert.equal(getAvatarForName(''), '👤', 'empty string');
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
assert.equal(getAvatarForName(null), '👤', 'null');
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
// --- generateUsername (format check) ---
for (let i = 0; i < 10; i++) {
const name = generateUsername();
// Format: AdjectiveNoun (e.g. "HappyKoala")
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
// Adjective from list
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
assert.ok(adj, `adjective from list: ${name}`);
// Noun from list
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
assert.ok(noun, `noun from list: ${name}`);
}
// Every noun has an emoji (no broken usernames)
for (const noun of USERNAME_NOUNS) {
const avatar = getAvatarForName(noun);
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
}
console.log('names tests passed');
-131
View File
@@ -1,131 +0,0 @@
import assert from 'node:assert/strict';
import {
checkConnectionRate,
checkEventRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
checkAuthRate,
recordAuthFailure,
clearRateLimitMaps,
connectionCounts,
failedAuthAttempts,
eventCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
leaveRoomCounts,
rateLimitDenied,
startRateLimitCleanup,
stopRateLimitCleanup,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT,
LEAVE_ROOM_RATE_LIMIT
} from '../server/rate-limiter.js';
// Helper: mock io for cleanup
const mockIo = { sockets: { sockets: new Map() } };
// Reset state before each test group
function reset() {
clearRateLimitMaps();
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
stopRateLimitCleanup();
}
// --- checkConnectionRate ---
reset();
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
reset();
assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
// --- checkEventRate ---
reset();
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.events, 1);
reset();
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
// --- checkLeaveRoomRate ---
reset();
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.leaveRoom, 1);
reset();
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room socket independent');
// --- checkHealthRate ---
reset();
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4');
assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked');
assert.equal(rateLimitDenied.health, 1);
// --- checkAdminMetricsAuthRate ---
reset();
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed');
for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8');
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked');
assert.equal(rateLimitDenied.adminMetricsAuth, 1);
// --- checkAuthRate ---
reset();
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed');
for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a');
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked');
assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked');
// --- recordAuthFailure ---
reset();
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.size, 1, 'failure recorded');
const record = failedAuthAttempts.get('10.0.0.2:room-x');
assert.equal(record.count, 1, 'count incremented');
assert.ok(record.lastAttempt <= Date.now(), 'timestamp set');
recordAuthFailure('10.0.0.2', 'room-x');
assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat');
// --- clearRateLimitMaps ---
reset();
connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 });
eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
roomListCooldowns.set('sock2', Date.now());
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
clearRateLimitMaps();
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts cleared');
// --- startRateLimitCleanup / stopRateLimitCleanup ---
reset();
startRateLimitCleanup(mockIo);
startRateLimitCleanup(mockIo); // double-start guard
stopRateLimitCleanup();
assert.ok(true, 'cleanup start/stop does not throw');
// --- rateLimitDenied reset ---
reset();
rateLimitDenied.connections = 5;
rateLimitDenied.leaveRoom = 5;
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
console.log('rate-limiter tests passed');
-89
View File
@@ -1,89 +0,0 @@
import assert from 'node:assert/strict';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from '../server/ops.js';
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
assert.equal(
isAdminMetricsTokenStrong('a'.repeat(32)),
true,
'admin token with at least 32 characters should be considered strong'
);
const cooldowns = new Map();
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
const cache = new Map();
let buildCalls = 0;
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
const basicHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: false,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
});
assert.deepEqual(
Object.keys(basicHealth).sort(),
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
'basic health should not expose extended metrics'
);
const adminHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: true,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
rateLimitDenied: { leaveRoom: 8 }
});
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimits,
{
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
},
'admin metrics should expose rate-limit tracking and denial counts'
);
console.log('server ops tests passed');
+47
View File
@@ -10,6 +10,7 @@ import {
materializeMediaIntent,
reserveLatestMediaIntentSequence
} from '../extension/offline-media-intent.js';
import { FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -137,6 +138,22 @@ try {
assert.equal(coalescedLateRoom.mediaState.playbackState, 'paused');
assert.equal(coalescedLateRoom.mediaState.currentTime, 605);
assert.equal(coalescedLateRoom.mediaState.updatedBy, 'coalesce-sender');
// --- Stale peer reaper: terminal timeout + clean rejoin ---
const staleClient = await c();
const staleRoomId = 'stale-'+Date.now();
await j(staleClient, staleRoomId, 'stale-peer');
staleClient._m.length = 0;
const staleRoom = mod.rooms.get(staleRoomId);
staleRoom.peerData.values().next().value.lastSeen = 1;
mod.cleanupInactiveRooms(Date.now());
const [staleEvent, staleData] = await a(staleClient);
assert.equal(staleEvent, 'error');
assert.equal(staleData.code, 'peer_timed_out');
assert.equal(staleData.message, 'Removed from room after inactivity');
assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
staleClient._m.length = 0;
await j(staleClient, staleRoomId, 'stale-peer');
assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
close();
resetConnectionRate();
@@ -387,6 +404,18 @@ try {
'authorized EXECUTE commits the latest target visible to legacy peers');
assert.equal(competingForceState.updatedBy, 'msa');
s(msa, 'force_sync_prepare', { targetTime: 950 });
await w(msb, 'force_sync_prepare');
const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState };
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT - 1;
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
let expiredExecuteDropped = false;
try { await w(msb, 'force_sync_execute', 500); } catch { expiredExecuteDropped = true; }
assert.ok(expiredExecuteDropped, 'an expired Force Sync target rejects delayed EXECUTE');
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeExpiredExecute);
assert.equal(mod.rooms.get(msrid).forceSyncTarget, null);
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_prepare', { targetTime: 1_000 });
await w(msb, 'force_sync_prepare');
@@ -451,6 +480,24 @@ try {
s(msgGuest, 'leave_room', {});
await delay(80);
assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room');
// --- Terminal room timeout: coded error + complete membership cleanup ---
const timeoutClient = await c();
const timeoutRoomId = 'timeout-'+Date.now();
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
timeoutClient._m.length = 0;
mod.rooms.get(timeoutRoomId).lastActivity = 0;
mod.cleanupInactiveRooms(Date.now());
const [timeoutEvent, timeoutData] = await a(timeoutClient);
assert.equal(timeoutEvent, 'error');
assert.equal(timeoutData.code, 'room_closed');
assert.equal(timeoutData.message, 'Room closed');
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
timeoutClient._m.length = 0;
// The same connected socket must be able to join that room again. This
// proves timeout cleanup removed its stale socketToRoom membership.
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
close();
resetConnectionRate();
-87
View File
@@ -1,87 +0,0 @@
import assert from 'node:assert/strict';
import {
TITLE_PRIVACY_MODES,
applyTitlePrivacyToPayload,
normalizeSendTabTitle,
normalizeTabTitle,
normalizeTitlePrivacyMode,
sanitizeSharedTitle,
sanitizeTabTitle
} from '../extension/title-privacy.js';
assert.equal(normalizeTitlePrivacyMode(undefined), TITLE_PRIVACY_MODES.FULL);
assert.equal(normalizeTitlePrivacyMode('unknown'), TITLE_PRIVACY_MODES.FULL);
assert.equal(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN), TITLE_PRIVACY_MODES.HIDDEN);
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle(' '), null);
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('(12) Private Tab', true), 'Private Tab');
assert.equal(sanitizeTabTitle('Private Tab', false), null);
assert.equal(sanitizeTabTitle('', true), null);
assert.equal(sanitizeSharedTitle('Example Movie', 'full'), 'Example Movie');
assert.equal(sanitizeSharedTitle('', 'full'), null);
assert.equal(sanitizeSharedTitle(null, 'full'), null);
assert.equal(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode'), 'S01E04');
assert.equal(sanitizeSharedTitle('Folge 7 - Private Server', 'episode'), 'EP007');
assert.equal(sanitizeSharedTitle('Example Movie', 'episode'), null);
assert.equal(sanitizeSharedTitle('Show Name - S01E04', 'hidden'), null);
assert.equal(sanitizeSharedTitle('Private Tab Title', 'hidden'), null);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Jellyfin - S01E04',
mediaTitle: 'Show Name - S01E04',
currentTime: 42
}, 'episode'),
{
tabTitle: 'Private Jellyfin - S01E04',
mediaTitle: 'S01E04',
currentTime: 42
},
'media privacy must not rewrite tabTitle'
);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Jellyfin - S01E04',
status: 'heartbeat'
}, 'episode'),
{
tabTitle: 'Private Jellyfin - S01E04',
status: 'heartbeat'
},
'media privacy must not rewrite tabTitle or add absent media keys'
);
assert.deepEqual(
applyTitlePrivacyToPayload({
tabTitle: 'Private Tab',
mediaTitle: 'Private Media',
expectedTitle: 'S01E04',
title: 'S01E04'
}, 'hidden'),
{
tabTitle: 'Private Tab',
mediaTitle: null,
expectedTitle: null,
title: null
},
'hidden media privacy must not clear tabTitle'
);
console.log('title-privacy tests passed');
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
parseChecksumFile,
RELEASE_ASSET_NAMES,
sha256File,
validateArchiveEntries,
validateArchiveParity,
validateManifest,
validateReleaseAssetNames,
versionFromTag
} from './release-artifact-checks.mjs';
function parseArgs(argv) {
const options = { tag: '', repo: '', assetDir: '', skipAttestation: false };
const positional = [];
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === '--repo' || argument === '--asset-dir') {
const value = argv[++index];
if (!value) throw new Error(`${argument} requires a value`);
if (argument === '--repo') options.repo = value;
else options.assetDir = path.resolve(value);
} else if (argument === '--skip-attestation') {
options.skipAttestation = true;
} else if (argument.startsWith('-')) {
throw new Error(`Unknown option: ${argument}`);
} else {
positional.push(argument);
}
}
if (positional.length !== 1) {
throw new Error('Usage: node scripts/verify-published-release.mjs <tag> [--repo OWNER/REPO] [--asset-dir PATH] [--skip-attestation]');
}
options.tag = positional[0];
return options;
}
function run(command, args, { capture = true } = {}) {
return execFileSync(command, args, {
cwd: process.cwd(),
encoding: capture ? 'utf8' : undefined,
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
});
}
function readArchiveText(archivePath, entry) {
return run('unzip', ['-p', archivePath, entry]);
}
function listArchiveEntries(archivePath) {
return run('unzip', ['-Z1', archivePath]).split(/\r?\n/u).filter(Boolean);
}
function assertRuntimeBuild(browserName, archivePath, version) {
const constants = readArchiveText(archivePath, 'shared/constants.js');
const background = readArchiveText(archivePath, 'background.js');
const content = readArchiveText(archivePath, 'content.js');
const popup = readArchiveText(archivePath, 'popup.html');
if (!constants.includes(`export const APP_VERSION = "${version}";`)) {
throw new Error(`${browserName} shared/constants.js does not contain APP_VERSION ${version}`);
}
if (!background.includes(`const BROWSER_TYPE = "${browserName}";`)) {
throw new Error(`${browserName} background.js does not contain the injected browser type`);
}
if (!content.includes('const EVENTS = {')) {
throw new Error(`${browserName} content.js does not contain injected protocol events`);
}
if (popup.includes('__BUILD_TIMESTAMP__')) {
throw new Error(`${browserName} popup.html contains an unresolved build timestamp`);
}
}
async function verify() {
const options = parseArgs(process.argv.slice(2));
const version = versionFromTag(options.tag);
const repo = options.repo || run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).trim();
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) throw new Error(`Invalid GitHub repository: ${repo}`);
const tagRef = `refs/tags/${options.tag}`;
if (run('git', ['cat-file', '-t', tagRef]).trim() !== 'tag') {
throw new Error(`${options.tag} must be an annotated tag`);
}
run('git', ['merge-base', '--is-ancestor', tagRef, 'origin/main']);
const tagCommit = run('git', ['rev-list', '-n', '1', tagRef]).trim();
const temporaryDirectory = options.assetDir
? null
: fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-release-verification-'));
const assetDirectory = options.assetDir || temporaryDirectory;
try {
if (!options.assetDir) {
const publishedAssets = run('gh', [
'release', 'view', options.tag, '--repo', repo,
'--json', 'assets', '--jq', '.assets[].name'
]).split(/\r?\n/u).filter(Boolean);
validateReleaseAssetNames(publishedAssets);
run('gh', [
'release', 'download', options.tag, '--repo', repo, '--dir', assetDirectory,
'--pattern', 'koalasync-*.zip', '--pattern', 'SHA256SUMS'
], { capture: false });
}
for (const assetName of RELEASE_ASSET_NAMES) {
const assetPath = path.join(assetDirectory, assetName);
if (!fs.statSync(assetPath, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`Missing release asset: ${assetName}`);
}
}
const checksums = parseChecksumFile(fs.readFileSync(path.join(assetDirectory, 'SHA256SUMS'), 'utf8'));
validateReleaseAssetNames([...checksums.keys(), 'SHA256SUMS']);
for (const assetName of RELEASE_ASSET_NAMES.filter(name => name.endsWith('.zip'))) {
const actual = await sha256File(path.join(assetDirectory, assetName));
const expected = checksums.get(assetName);
if (actual !== expected) throw new Error(`${assetName} checksum mismatch: expected ${expected}, got ${actual}`);
}
const archiveEntries = {};
for (const browserName of ['chrome', 'firefox']) {
const archivePath = path.join(assetDirectory, `koalasync-${browserName}.zip`);
archiveEntries[browserName] = validateArchiveEntries(browserName, listArchiveEntries(archivePath));
let manifest;
try {
manifest = JSON.parse(readArchiveText(archivePath, 'manifest.json'));
} catch (error) {
throw new Error(`${browserName} manifest.json is invalid: ${error.message}`);
}
validateManifest(browserName, manifest, version);
assertRuntimeBuild(browserName, archivePath, version);
if (!options.skipAttestation && !options.assetDir) {
run('gh', [
'attestation', 'verify', archivePath,
'--repo', repo,
'--signer-workflow', `${repo}/.github/workflows/release.yml`,
'--source-ref', tagRef,
'--source-digest', tagCommit,
'--deny-self-hosted-runners'
], { capture: false });
}
}
validateArchiveParity(archiveEntries.chrome, archiveEntries.firefox);
console.log(`Published release ${options.tag} verified for ${repo}`);
console.log(`Assets: ${RELEASE_ASSET_NAMES.join(', ')}`);
console.log(`Version: ${version}; checksums, manifests, parity${options.skipAttestation || options.assetDir ? '' : ', attestations'} passed`);
} finally {
if (temporaryDirectory) fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
verify().catch(error => {
console.error(`Published release verification failed: ${error.message}`);
process.exitCode = 1;
});
+2 -8
View File
@@ -7,22 +7,16 @@ import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
['vitest unit tests', 'npm', ['run', 'test:unit']],
['server ops', 'node', ['scripts/test-server-ops.mjs']],
['coverage source inventory', 'node', ['scripts/check-coverage-inventory.mjs']],
['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
}],
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
['names generator', 'node', ['scripts/test-names.mjs']],
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['blacklist settings', 'node', ['scripts/test-blacklist-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],