ci: add Windows quality and packaging gates

Teach ESLint the classic renderer and mixed CommonJS harness runtimes while retaining actionable rules. Add deterministic credential, lockfile, release-manifest, and lint contracts plus equivalent GitHub and Gitea Windows pipelines. Gate clean installs, focused offline smoke coverage, builds, packaged launches, and silent installer verification without enabling authenticated network tests.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:32:07 +02:00
parent e5114b814f
commit 66e84508c8
21 changed files with 708 additions and 39 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Windows CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: windows-latest
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.13.0'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Lint
run: npm run lint
timeout-minutes: 10
- name: Lint configuration contract
run: npm run test:lint-config
timeout-minutes: 10
- name: Security contracts
run: npm run security:check
timeout-minutes: 10
- name: Security regression tests
run: npm run test:security
timeout-minutes: 10
- name: CI contract
run: npm run test:ci-contract
timeout-minutes: 10
- name: Unit tests
run: npm run test:unit
timeout-minutes: 10
- name: Focused Electron smoke
run: npm run test:e2e:focused
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Package directory
run: npm run pack
timeout-minutes: 10
- name: Packaged launch smoke
run: npm run test:packaged-launch
timeout-minutes: 10
- name: Build installer
run: npm run dist:ci
timeout-minutes: 10
- name: Installer smoke
run: npm run test:installer
timeout-minutes: 10
+60
View File
@@ -0,0 +1,60 @@
name: Windows CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: windows-latest
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.13.0'
cache: npm
- name: Clean install
run: npm ci
timeout-minutes: 10
- name: Lint
run: npm run lint
timeout-minutes: 10
- name: Lint configuration contract
run: npm run test:lint-config
timeout-minutes: 10
- name: Security contracts
run: npm run security:check
timeout-minutes: 10
- name: Security regression tests
run: npm run test:security
timeout-minutes: 10
- name: CI contract
run: npm run test:ci-contract
timeout-minutes: 10
- name: Unit tests
run: npm run test:unit
timeout-minutes: 10
- name: Focused Electron smoke
run: npm run test:e2e:focused
timeout-minutes: 10
- name: Build
run: npm run build
timeout-minutes: 10
- name: Package directory
run: npm run pack
timeout-minutes: 10
- name: Packaged launch smoke
run: npm run test:packaged-launch
timeout-minutes: 10
- name: Build installer
run: npm run dist:ci
timeout-minutes: 10
- name: Installer smoke
run: npm run test:installer
timeout-minutes: 10
+61 -5
View File
@@ -1,25 +1,81 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import security from 'eslint-plugin-security'; import security from 'eslint-plugin-security';
import globals from 'globals';
export default [ export default [
{
ignores: ['dist/**', 'release/**', 'node_modules/**', 'tmp_*/**', 'docs/**']
},
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
security.configs.recommended, security.configs.recommended,
{ {
files: ['src/**/*.ts'], files: ['src/**/*.ts'],
rules: { rules: {
// Tune down noisy rules for existing codebase
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'off', 'no-console': 'off',
'security/detect-object-injection': 'off', // Too many false positives with Record types 'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources 'security/detect-non-literal-fs-filename': 'off',
'no-async-promise-executor': 'warn', 'no-async-promise-executor': 'warn',
'no-empty': ['warn', { allowEmptyCatch: true }], 'no-empty': ['warn', { allowEmptyCatch: true }]
} }
}, },
{ {
ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**'] files: ['src/renderer.ts', 'src/renderer-*.ts'],
ignores: ['src/**/*.test.ts'],
languageOptions: {
sourceType: 'script',
globals: globals.browser
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'prefer-const': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
sourceType: 'commonjs',
globals: globals.node
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-console': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off'
}
},
{
files: ['scripts/**/*.mjs'],
languageOptions: {
sourceType: 'module',
globals: globals.node
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-console': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off'
}
},
{
files: ['scripts/smoke-test*.js', 'scripts/capture-readme-screenshot.js', 'scripts/e2e-test-environment.js'],
languageOptions: {
globals: {
...globals.node,
...globals.browser
}
},
rules: {
'no-undef': 'off'
}
} }
]; ];
+14
View File
@@ -21,6 +21,7 @@
"electron-builder": "^26.15.7", "electron-builder": "^26.15.7",
"eslint": "^10.4.0", "eslint": "^10.4.0",
"eslint-plugin-security": "^4.0.0", "eslint-plugin-security": "^4.0.0",
"globals": "^16.4.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"typescript-eslint": "^8.59.4", "typescript-eslint": "^8.59.4",
@@ -3372,6 +3373,19 @@
"node": ">=10.0" "node": ">=10.0"
} }
}, },
"node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": { "node_modules/globalthis": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+11
View File
@@ -10,6 +10,8 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"lint": "eslint .",
"security:check": "node scripts/security-check.js && node scripts/smoke-test-public-release-config.js",
"start": "npm run build && electron .", "start": "npm run build && electron .",
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"test:unit": "vitest run --passWithNoTests", "test:unit": "vitest run --passWithNoTests",
@@ -24,11 +26,18 @@
"test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js",
"test:capability-contract": "node scripts/smoke-test-file-capability-contract.js", "test:capability-contract": "node scripts/smoke-test-file-capability-contract.js",
"test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js",
"test:e2e:focused": "npm run test:e2e:isolation && npm run test:e2e:workspace-ui",
"test:security": "node --test scripts/security-check.test.js",
"test:lint-config": "node --test scripts/lint-config.test.mjs",
"test:ci-contract": "node scripts/smoke-test-ci-contract.js",
"test:packaged-launch": "node scripts/smoke-test-packaged-launch.js",
"test:installer": "node scripts/smoke-test-installer.js",
"test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave",
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"dist:win": "npm run test:e2e:release && electron-builder --win", "dist:win": "npm run test:e2e:release && electron-builder --win",
"dist:ci": "electron-builder --win nsis",
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js" "test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
}, },
"dependencies": { "dependencies": {
@@ -44,6 +53,7 @@
"electron-builder": "^26.15.7", "electron-builder": "^26.15.7",
"eslint": "^10.4.0", "eslint": "^10.4.0",
"eslint-plugin-security": "^4.0.0", "eslint-plugin-security": "^4.0.0",
"globals": "^16.4.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"typescript-eslint": "^8.59.4", "typescript-eslint": "^8.59.4",
@@ -72,6 +82,7 @@
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "build/icon.ico", "icon": "build/icon.ico",
"signExecutable": false,
"artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}" "artifactName": "Twitch-VOD-Manager-Setup-${version}.${ext}"
}, },
"nsis": { "nsis": {
+4 -1
View File
@@ -59,7 +59,10 @@ function restartElectron() {
function isElectronRestartTarget(fileName) { function isElectronRestartTarget(fileName) {
const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? ''; const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? '';
return !/^renderer(?:[-.].+)?\.js$/.test(baseName); if (baseName === 'renderer.js') return false;
if (!baseName.startsWith('renderer') || !baseName.endsWith('.js')) return true;
const suffix = baseName.slice(8);
return suffix.length <= 4 || (suffix[0] !== '-' && suffix[0] !== '.');
} }
function scheduleRestart(fileName) { function scheduleRestart(fileName) {
-16
View File
@@ -6,9 +6,6 @@ const OFFLINE_PROXY = 'http://127.0.0.1:1';
function buildSafeConfig(downloadsDir, overrides = {}) { function buildSafeConfig(downloadsDir, overrides = {}) {
return { return {
client_id: '',
download_path: downloadsDir,
streamers: [],
theme: 'twitch', theme: 'twitch',
download_mode: 'full', download_mode: 'full',
part_minutes: 120, part_minutes: 120,
@@ -22,30 +19,17 @@ function buildSafeConfig(downloadsDir, overrides = {}) {
persist_queue_on_restart: false, persist_queue_on_restart: false,
metadata_cache_minutes: 10, metadata_cache_minutes: 10,
parallel_downloads: 1, parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: [], downloaded_vod_ids: [],
streamlink_quality: 'best', streamlink_quality: 'best',
notify_on_each_completion: false, notify_on_each_completion: false,
streamlink_disable_ads: true, streamlink_disable_ads: true,
auto_record_streamers: [],
auto_record_poll_seconds: 90, auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_cleanup_days: 30, auto_cleanup_days: 30,
auto_cleanup_target: 'live_only', auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive', auto_cleanup_action: 'archive',
log_stream_events: false, log_stream_events: false,
auto_vod_download_streamers: [],
auto_vod_download_poll_minutes: 15, auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24, auto_vod_max_age_hours: 24,
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false,
...overrides, ...overrides,
client_id: '', client_id: '',
download_path: downloadsDir, download_path: downloadsDir,
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { ESLint } from 'eslint';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const eslint = new ESLint({ cwd: root, overrideConfigFile: path.join(root, 'eslint.config.mjs') });
async function messagesFor(source, filePath) {
const [result] = await eslint.lintText(source, { filePath: path.join(root, filePath) });
return result.messages;
}
test('accepts classic renderer declarations and browser globals', async () => {
const messages = await messagesFor('function openPanel() { return document.title; }\n', 'src/renderer-contract-fixture.ts');
assert.deepEqual(messages, []);
});
test('keeps recommended renderer errors active', async () => {
const messages = await messagesFor('function openPanel() { debugger; return document.title; }\n', 'src/renderer-contract-fixture.ts');
assert.ok(messages.some((message) => message.ruleId === 'no-debugger' && message.severity === 2));
});
test('accepts CommonJS imports and rejects unused script bindings', async () => {
const messages = await messagesFor("const fs = require('node:fs');\nconst unused = fs;\n", 'scripts/contract-fixture.js');
assert.ok(messages.some((message) => message.ruleId === 'no-unused-vars' && message.severity === 2));
assert.ok(!messages.some((message) => message.ruleId === '@typescript-eslint/no-require-imports'));
});
+45
View File
@@ -1,5 +1,7 @@
{ {
"files": [ "files": [
".gitea/workflows/windows-ci.yml",
".github/workflows/windows-ci.yml",
".gitignore", ".gitignore",
"CHANGELOG.md", "CHANGELOG.md",
"LICENSE", "LICENSE",
@@ -12,13 +14,21 @@
"package-lock.json", "package-lock.json",
"package.json", "package.json",
"scripts/e2e-test-environment.js", "scripts/e2e-test-environment.js",
"scripts/file-capability-contract.js",
"scripts/lint-config.test.mjs",
"scripts/capture-readme-screenshot.js", "scripts/capture-readme-screenshot.js",
"scripts/dev.mjs", "scripts/dev.mjs",
"scripts/public-release-files.json", "scripts/public-release-files.json",
"scripts/security-check.js",
"scripts/security-check.test.js",
"scripts/smoke-test-ci-contract.js",
"scripts/smoke-test-cutter.js", "scripts/smoke-test-cutter.js",
"scripts/smoke-test-e2e-isolation-contract.js", "scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-file-capability-contract.js",
"scripts/smoke-test-full.js", "scripts/smoke-test-full.js",
"scripts/smoke-test-installer.js",
"scripts/smoke-test-merge-split-logic.js", "scripts/smoke-test-merge-split-logic.js",
"scripts/smoke-test-packaged-launch.js",
"scripts/smoke-test-public-release-config.js", "scripts/smoke-test-public-release-config.js",
"scripts/smoke-test-settings-autosave.js", "scripts/smoke-test-settings-autosave.js",
"scripts/smoke-test-template-guide.js", "scripts/smoke-test-template-guide.js",
@@ -26,6 +36,33 @@
"scripts/smoke-test-workspace-ui.js", "scripts/smoke-test-workspace-ui.js",
"scripts/smoke-test.js", "scripts/smoke-test.js",
"src/index.html", "src/index.html",
"src/main/domain/app-state-store.test.ts",
"src/main/domain/app-state-store.ts",
"src/main/domain/chat-reader.test.ts",
"src/main/domain/chat-reader.ts",
"src/main/domain/config-export.test.ts",
"src/main/domain/config-export.ts",
"src/main/domain/cutter-project.test.ts",
"src/main/domain/cutter-project.ts",
"src/main/domain/download-policy.test.ts",
"src/main/domain/download-policy.ts",
"src/main/domain/file-capability.test.ts",
"src/main/domain/file-capability.ts",
"src/main/domain/managed-tools.test.ts",
"src/main/domain/managed-tools.ts",
"src/main/domain/persistence-commit.test.ts",
"src/main/domain/persistence-commit.ts",
"src/main/domain/privileged-ipc.test.ts",
"src/main/domain/privileged-ipc.ts",
"src/main/domain/renderer-queue-input.test.ts",
"src/main/domain/renderer-queue-input.ts",
"src/main/domain/secret-input.test.ts",
"src/main/domain/secret-input.ts",
"src/main/domain/secret-store.test.ts",
"src/main/domain/secret-store.ts",
"src/main/domain/tool-manifest.ts",
"src/main/domain/update-check-operation.test.ts",
"src/main/domain/update-check-operation.ts",
"src/main/domain/archive-files-store.test.ts", "src/main/domain/archive-files-store.test.ts",
"src/main/domain/archive-files-store.ts", "src/main/domain/archive-files-store.ts",
"src/main/domain/chunk-index-store.test.ts", "src/main/domain/chunk-index-store.test.ts",
@@ -76,12 +113,18 @@
"src/main/infra/schema-v5.ts", "src/main/infra/schema-v5.ts",
"src/main/infra/secure-storage.test.ts", "src/main/infra/secure-storage.test.ts",
"src/main/infra/secure-storage.ts", "src/main/infra/secure-storage.ts",
"src/main/queue/process-lifecycle.integration.test.ts",
"src/main/queue/process-registry.test.ts",
"src/main/queue/process-registry.ts",
"src/main.ts", "src/main.ts",
"src/main/dev-executable.test.ts", "src/main/dev-executable.test.ts",
"src/main/dev-executable.ts", "src/main/dev-executable.ts",
"src/main/dev-reload.test.ts", "src/main/dev-reload.test.ts",
"src/main/dev-reload.ts", "src/main/dev-reload.ts",
"src/preload.ts", "src/preload.ts",
"src/renderer-accessibility.integration.test.ts",
"src/renderer-accessibility.test.ts",
"src/renderer-accessibility.ts",
"src/renderer-archive.ts", "src/renderer-archive.ts",
"src/renderer-command-palette.ts", "src/renderer-command-palette.ts",
"src/renderer-cutter.ts", "src/renderer-cutter.ts",
@@ -89,8 +132,10 @@
"src/renderer-locale-de.ts", "src/renderer-locale-de.ts",
"src/renderer-locale-en.ts", "src/renderer-locale-en.ts",
"src/renderer-profile.ts", "src/renderer-profile.ts",
"src/renderer-production-path.integration.test.ts",
"src/renderer-queue.ts", "src/renderer-queue.ts",
"src/renderer-settings.ts", "src/renderer-settings.ts",
"src/renderer-settings-autosave.test.ts",
"src/renderer-shared.ts", "src/renderer-shared.ts",
"src/renderer-stats.ts", "src/renderer-stats.ts",
"src/renderer-streamers.ts", "src/renderer-streamers.ts",
+121
View File
@@ -0,0 +1,121 @@
const fs = require('fs');
const path = require('path');
const textExtensions = new Set([
'', '.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.nsh', '.ps1', '.ts', '.tsx', '.txt', '.yaml', '.yml'
]);
const sensitivePatterns = [
['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g],
['aws-access-key', /\bAKIA[0-9A-Z]{16}\b/g],
['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g],
['discord-webhook', /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/\d{8,}\/[A-Za-z0-9._-]{20,}/gi],
['url-credentials', /https?:\/\/[^\s/@:]+:[^\s/@]+@/gi],
['windows-user-path', /\b[A-Za-z]:\\Users\\[^\\/\s]+\\/g]
];
function lineNumberAt(source, index) {
return source.slice(0, index).split('\n').length;
}
function scanText(relativePath, source) {
const findings = [];
for (const header of ['PRIVATE KEY', 'RSA PRIVATE KEY', 'EC PRIVATE KEY', 'DSA PRIVATE KEY', 'OPENSSH PRIVATE KEY', 'ENCRYPTED PRIVATE KEY']) {
const marker = `-----BEGIN ${header}-----`;
let index = source.indexOf(marker);
while (index >= 0) {
findings.push({ file: relativePath, line: lineNumberAt(source, index), rule: 'private-key' });
index = source.indexOf(marker, index + marker.length);
}
}
for (const [rule, pattern] of sensitivePatterns) {
pattern.lastIndex = 0;
for (const match of source.matchAll(pattern)) {
findings.push({ file: relativePath, line: lineNumberAt(source, match.index ?? 0), rule });
}
}
return findings;
}
function isContainedPath(root, target) {
const relative = path.relative(root, target);
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
}
function inspectPublicFiles(root, entries) {
const findings = [];
const resolvedRoot = path.resolve(root);
for (const rawEntry of entries) {
const entry = String(rawEntry || '').replace(/\\/g, '/');
const absolutePath = path.resolve(resolvedRoot, ...entry.split('/'));
if (!entry || path.isAbsolute(entry) || !isContainedPath(resolvedRoot, absolutePath)) {
findings.push({ file: entry || '<empty>', line: 0, rule: 'manifest-path' });
continue;
}
if (!fs.existsSync(absolutePath)) {
findings.push({ file: entry, line: 0, rule: 'manifest-missing' });
continue;
}
const stat = fs.lstatSync(absolutePath);
if (stat.isSymbolicLink()) {
findings.push({ file: entry, line: 0, rule: 'manifest-symlink' });
continue;
}
if (!stat.isFile()) {
findings.push({ file: entry, line: 0, rule: 'manifest-file-type' });
continue;
}
if (!textExtensions.has(path.extname(entry).toLowerCase())) continue;
findings.push(...scanText(entry, fs.readFileSync(absolutePath, 'utf8')));
}
return findings;
}
function inspectLockfile(lockfile) {
const findings = [];
if (!Number.isInteger(lockfile?.lockfileVersion) || lockfile.lockfileVersion < 3) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'lockfile-version' });
}
const rootPackage = lockfile?.packages?.[''] || {};
for (const [name, specifier] of Object.entries({
...(rootPackage.dependencies || {}),
...(rootPackage.devDependencies || {})
})) {
if (/^(?:file:|git(?:\+|:)|https?:)/i.test(String(specifier))) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: name });
}
}
for (const [packagePath, metadata] of Object.entries(lockfile?.packages || {})) {
if (!packagePath || metadata?.link) continue;
const resolved = typeof metadata?.resolved === 'string' ? metadata.resolved : '';
if (/^https:\/\/registry\.npmjs\.org\//i.test(resolved) && !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(metadata.integrity || ''))) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-integrity', package: packagePath });
}
if (resolved && !/^https:\/\/registry\.npmjs\.org\//i.test(resolved)) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: packagePath });
}
}
return findings;
}
function run(root = process.cwd()) {
const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
const lockfilePath = path.join(root, 'package-lock.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const lockfile = JSON.parse(fs.readFileSync(lockfilePath, 'utf8'));
const entries = Array.isArray(manifest.files) ? manifest.files : [];
return [...inspectPublicFiles(root, entries), ...inspectLockfile(lockfile)];
}
if (require.main === module) {
try {
const failures = run();
console.log(JSON.stringify({ failures }, null, 2));
if (failures.length) process.exitCode = 1;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
module.exports = { inspectLockfile, inspectPublicFiles, run, scanText };
+62
View File
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const securityCheck = require('./security-check');
test('detects credential material and private machine paths', () => {
const githubToken = ['gh', 'p_', 'a'.repeat(40)].join('');
const privateKey = ['-----BEGIN ', 'PRIVATE KEY-----'].join('');
const source = `${githubToken}\n${privateKey}\nC:\\Users\\real-user\\AppData`;
const findings = securityCheck.scanText('fixture.txt', source);
assert.deepEqual(findings.map((finding) => finding.rule).sort(), [
'github-token',
'private-key',
'windows-user-path'
]);
});
test('accepts public source with secret field names but no credential value', () => {
const findings = securityCheck.scanText('fixture.ts', "const client_secret = config.client_secret;\nconst token = '';\n");
assert.deepEqual(findings, []);
});
test('rejects public manifest traversal and symbolic links', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-security-'));
const outside = path.join(path.dirname(root), `${path.basename(root)}-outside.txt`);
try {
fs.writeFileSync(path.join(root, 'safe.txt'), 'safe', 'utf8');
fs.writeFileSync(outside, 'outside', 'utf8');
const entries = ['safe.txt', '../outside.txt'];
const findings = securityCheck.inspectPublicFiles(root, entries);
assert.ok(findings.some((finding) => finding.rule === 'manifest-path'));
if (process.platform === 'win32') {
fs.symlinkSync(outside, path.join(root, 'linked.txt'), 'file');
const linkedFindings = securityCheck.inspectPublicFiles(root, ['linked.txt']);
assert.ok(linkedFindings.some((finding) => finding.rule === 'manifest-symlink'));
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { force: true });
}
});
test('requires registry dependency integrity in the lockfile', () => {
const lockfile = {
lockfileVersion: 3,
packages: {
'': { dependencies: { example: '^1.0.0' } },
'node_modules/example': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org/example/-/example-1.0.0.tgz'
}
}
};
const findings = securityCheck.inspectLockfile(lockfile);
assert.ok(findings.some((finding) => finding.rule === 'dependency-integrity'));
});
+72
View File
@@ -0,0 +1,72 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const failures = [];
function check(condition, message) {
if (!condition) failures.push(message);
}
const requiredScripts = {
lint: 'eslint .',
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
'test:security': 'node --test scripts/security-check.test.js',
'test:lint-config': 'node --test scripts/lint-config.test.mjs',
'test:ci-contract': 'node scripts/smoke-test-ci-contract.js',
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
'test:installer': 'node scripts/smoke-test-installer.js',
'dist:ci': 'electron-builder --win nsis'
};
for (const [name, command] of Object.entries(requiredScripts)) {
check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`);
}
for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) {
const absolutePath = path.join(root, relativePath);
check(fs.existsSync(absolutePath), `${relativePath} is missing`);
if (!fs.existsSync(absolutePath)) continue;
const source = fs.readFileSync(absolutePath, 'utf8');
const requiredCommands = [
'npm ci',
'npm run lint',
'npm run test:lint-config',
'npm run security:check',
'npm run test:security',
'npm run test:ci-contract',
'npm run test:unit',
'npm run test:e2e:focused',
'npm run build',
'npm run pack',
'npm run test:packaged-launch',
'npm run dist:ci',
'npm run test:installer'
];
check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`);
check(/node-version:\s*['"]?22\.13\.0['"]?/.test(source), `${relativePath} does not pin Node 22.13.0`);
for (const command of requiredCommands) {
check(source.includes(`run: ${command}`), `${relativePath} is missing ${command}`);
}
const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line));
const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line));
check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`);
check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`);
}
for (const relativePath of [
'scripts/security-check.js',
'scripts/security-check.test.js',
'scripts/lint-config.test.mjs',
'scripts/smoke-test-packaged-launch.js',
'scripts/smoke-test-installer.js'
]) {
check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`);
}
console.log(JSON.stringify({ failures }, null, 2));
if (failures.length) process.exitCode = 1;
+1 -2
View File
@@ -175,7 +175,7 @@ async function run() {
const staleCutterDirectories = ['media', 'waveform', 'preview'].map((kind) => path.join(os.tmpdir(), `tvm-editor-${kind}-2147483647-${Date.now()}-${Math.random().toString(36).slice(2)}`)); const staleCutterDirectories = ['media', 'waveform', 'preview'].map((kind) => path.join(os.tmpdir(), `tvm-editor-${kind}-2147483647-${Date.now()}-${Math.random().toString(36).slice(2)}`));
staleCutterDirectories.forEach((directory) => fs.mkdirSync(directory)); staleCutterDirectories.forEach((directory) => fs.mkdirSync(directory));
let realMaximumZoomState = null; let realMaximumZoomState = null;
let replacementPromptState = null; let replacementPromptState;
let replacementPlaybackState = null; let replacementPlaybackState = null;
let app; let app;
const check = (condition, message) => { if (!condition) failures.push(message); }; const check = (condition, message) => { if (!condition) failures.push(message); };
@@ -431,7 +431,6 @@ async function run() {
const tiles = [...strip.querySelectorAll('img:not(.cutter-thumbnail-sprite), .cutter-thumbnail-tile')]; const tiles = [...strip.querySelectorAll('img:not(.cutter-thumbnail-sprite), .cutter-thumbnail-tile')];
const waveform = document.getElementById('cutterWaveform'); const waveform = document.getElementById('cutterWaveform');
await Promise.all([...images, waveform].map((image) => image.decode())); await Promise.all([...images, waveform].map((image) => image.decode()));
const scroll = document.getElementById('cutterTimelineScroll');
const timeline = document.getElementById('timeline'); const timeline = document.getElementById('timeline');
const targetWidth = Math.min(32000, Math.ceil(timeline.getBoundingClientRect().width * window.devicePixelRatio)); const targetWidth = Math.min(32000, Math.ceil(timeline.getBoundingClientRect().width * window.devicePixelRatio));
const firstFrameRect = images[0].getBoundingClientRect(); const firstFrameRect = images[0].getBoundingClientRect();
+1 -5
View File
@@ -107,7 +107,7 @@ async function run() {
await win.waitForTimeout(2200); await win.waitForTimeout(2200);
const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => { const summary = await win.evaluate(async () => {
const failures = []; const failures = [];
const checks = {}; const checks = {};
@@ -357,10 +357,6 @@ async function run() {
} }
return { checks, failures }; return { checks, failures };
}, {
mediaA: mediaA.replace(/\\/g, '/'),
mediaB: mediaB.replace(/\\/g, '/'),
tmpDir: environment.mediaDir.replace(/\\/g, '/')
}); });
await app.close(); await app.close();
+68
View File
@@ -0,0 +1,68 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
...options,
encoding: 'utf8',
timeout: 240000,
windowsHide: true
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${path.basename(command)} failed: ${JSON.stringify({ status: result.status, signal: result.signal, stdout: result.stdout, stderr: result.stderr })}`);
}
return result;
}
function findUninstaller(installationDirectory) {
return fs.readdirSync(installationDirectory)
.filter((name) => /^uninstall.*\.exe$/i.test(name))
.map((name) => path.join(installationDirectory, name))[0] || '';
}
function main() {
if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows');
if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') {
throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in');
}
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`);
if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`);
const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-'));
const installationDirectory = path.join(smokeRoot, 'app');
const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`);
let uninstallerPath = '';
try {
run(installerPath, ['/S', `/D=${installationDirectory}`], { cwd: smokeRoot });
if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`);
uninstallerPath = findUninstaller(installationDirectory);
if (!uninstallerPath) throw new Error('Installed uninstaller is missing');
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
cwd: root,
env: { ...process.env, PACKAGED_APP_PATH: executablePath }
});
run(uninstallerPath, ['/S'], { cwd: smokeRoot });
if (fs.existsSync(executablePath)) throw new Error('Silent uninstall left the packaged executable installed');
console.log(JSON.stringify({ failures: [], installerPath }, null, 2));
} finally {
if (uninstallerPath && fs.existsSync(uninstallerPath)) {
spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
}
fs.rmSync(smokeRoot, { recursive: true, force: true });
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
function packagedExecutablePath() {
if (process.env.PACKAGED_APP_PATH) return path.resolve(process.env.PACKAGED_APP_PATH);
return path.join(root, 'release', 'win-unpacked', `${packageJson.build.productName}.exe`);
}
function terminateProcessTree(child) {
if (!child || child.exitCode !== null) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
return;
}
child.kill('SIGTERM');
}
async function verifyPackagedLaunch(executablePath = packagedExecutablePath(), readyMs = 5000) {
if (process.platform !== 'win32') throw new Error('Packaged launch smoke requires Windows');
if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged executable is missing: ${executablePath}`);
const environmentRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-packaged-launch-'));
const userDataDir = path.join(environmentRoot, 'userdata');
const programDataDir = path.join(environmentRoot, 'programdata');
const appDataDir = path.join(environmentRoot, 'appdata');
const localAppDataDir = path.join(environmentRoot, 'localappdata');
const tempDir = path.join(environmentRoot, 'temp');
for (const directory of [userDataDir, programDataDir, appDataDir, localAppDataDir, tempDir]) {
fs.mkdirSync(directory, { recursive: true });
}
let output = '';
const child = spawn(executablePath, [
`--user-data-dir=${userDataDir}`,
'--proxy-server=http://127.0.0.1:1',
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost'
], {
cwd: path.dirname(executablePath),
env: {
...process.env,
PROGRAMDATA: programDataDir,
APPDATA: appDataDir,
LOCALAPPDATA: localAppDataDir,
TEMP: tempDir,
TMP: tempDir,
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: ''
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
});
const capture = (chunk) => {
output = `${output}${chunk}`.slice(-32768);
};
child.stdout.on('data', capture);
child.stderr.on('data', capture);
try {
const result = await Promise.race([
new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => resolve({ code, signal }));
}),
new Promise((resolve) => setTimeout(() => resolve(null), readyMs))
]);
if (result) throw new Error(`Packaged app exited before readiness: ${JSON.stringify({ ...result, output })}`);
return { executablePath, readyMs };
} finally {
terminateProcessTree(child);
fs.rmSync(environmentRoot, { recursive: true, force: true });
}
}
if (require.main === module) {
verifyPackagedLaunch()
.then((result) => console.log(JSON.stringify({ failures: [], result }, null, 2)))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
module.exports = { packagedExecutablePath, verifyPackagedLaunch };
+1 -1
View File
@@ -62,7 +62,7 @@ export function parseFfprobeJson(rawJson: string): ProbeResult {
try { try {
parsed = JSON.parse(rawJson) as FfprobeJson; parsed = JSON.parse(rawJson) as FfprobeJson;
} catch (e) { } catch (e) {
throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`); throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
} }
const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({ const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({
+1 -2
View File
@@ -197,8 +197,7 @@ export class ManagedToolInstaller {
const existing = this.inFlight.get(manifest.id); const existing = this.inFlight.get(manifest.id);
if (existing) return existing; if (existing) return existing;
let operation!: Promise<ManagedToolInstallResult>; const operation = Promise.resolve()
operation = Promise.resolve()
.then(() => this.installOnce(manifest)) .then(() => this.installOnce(manifest))
.catch(async (error: unknown) => await this.failure(manifest, 'download-failed', this.errorText(error), [])) .catch(async (error: unknown) => await this.failure(manifest, 'download-failed', this.errorText(error), []))
.finally(() => { .finally(() => {
+1 -1
View File
@@ -22,7 +22,7 @@ describe('resolveSecretInputUpdate', () => {
const revision = createSecretInputRevision(); const revision = createSecretInputRevision();
const requestRevision = revision.current(); const requestRevision = revision.current();
let resolveSave: (() => void) | undefined; let resolveSave: (() => void) | undefined;
let visibleValue = 'first-secret'; let visibleValue: string;
const save = new Promise<void>((resolve) => { const save = new Promise<void>((resolve) => {
resolveSave = resolve; resolveSave = resolve;
}).then(() => { }).then(() => {
+1 -1
View File
@@ -107,7 +107,7 @@ export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip
try { try {
parsed = JSON.parse(text) as HelixClipsResponse; parsed = JSON.parse(text) as HelixClipsResponse;
} catch (e) { } catch (e) {
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`); throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
} }
const rows = parsed.data ?? []; const rows = parsed.data ?? [];
+4 -5
View File
@@ -1,7 +1,6 @@
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O. // Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g; const FILENAME_INVALID_CHARACTERS = new Set('<>:"|?*\\/');
const FILENAME_PATH_SEP_RE = /[\\/]/g;
/** /**
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem * Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
@@ -9,9 +8,9 @@ const FILENAME_PATH_SEP_RE = /[\\/]/g;
* nichts uebrig bleibt. * nichts uebrig bleibt.
*/ */
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
const cleaned = (input || '') const cleaned = Array.from(input || '', (character) => {
.replace(FILENAME_INVALID_RE, '_') return character.charCodeAt(0) < 32 || FILENAME_INVALID_CHARACTERS.has(character) ? '_' : character;
.replace(FILENAME_PATH_SEP_RE, '_') }).join('')
.trim(); .trim();
return cleaned || fallback; return cleaned || fallback;
} }