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:
@@ -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
|
||||||
@@ -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
@@ -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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+14
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user