diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index ffe9811795..dc92d334b2 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -1,6 +1,6 @@ --- name: App launch preset -description: Request one Sunshine launch option for a non-game app. +description: Request one launch option for a non-game app. title: '[APP PRESET]: ' labels: - request-app-preset @@ -9,7 +9,7 @@ body: attributes: value: | Apps have no GameDB record. A maintainer reviews the official URL and command before approval. - Submit one host OS and command per issue. The preset name is generated from the app name and OS. + Submit one host OS and command per issue. The preset name is the app name; the host OS is shown separately. - type: input id: app_name attributes: @@ -52,7 +52,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-emulator.yml b/.github/ISSUE_TEMPLATE/game-emulator.yml index 5b05081a39..73ec6859e5 100644 --- a/.github/ISSUE_TEMPLATE/game-emulator.yml +++ b/.github/ISSUE_TEMPLATE/game-emulator.yml @@ -1,6 +1,6 @@ --- name: Emulator game preset -description: Request a Sunshine Emulator launch option for a GameDB game. +description: Request an emulator launch option for a GameDB game. title: '[EMULATOR GAME PRESET]: ' labels: - request-game-preset @@ -28,7 +28,7 @@ body: attributes: label: Command description: | - Portable Sunshine command for the emulator. Use {{ROM_PATH}} for the game and + Portable command for the emulator. Use {{ROM_PATH}} for the game and {{HOME}} for a variable home path. Put the emulator executable on the host PATH. Windows-only path placeholders, literal home directories, and reserved system names are rejected. validations: @@ -37,7 +37,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional portable Sunshine working-dir value. + description: Optional portable working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-epic-games.yml b/.github/ISSUE_TEMPLATE/game-epic-games.yml index cc218fe74d..73346f9b4c 100644 --- a/.github/ISSUE_TEMPLATE/game-epic-games.yml +++ b/.github/ISSUE_TEMPLATE/game-epic-games.yml @@ -1,6 +1,6 @@ --- name: Epic Games game preset -description: Request a Sunshine Epic Games launch option for a GameDB game. +description: Request an Epic Games launch option for a GameDB game. title: '[EPIC GAMES GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Three-part Sandbox ID, Catalog ID, and Artifact ID from the Epic launcher shortcut, separated by colons or %3A. For example fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-gog.yml b/.github/ISSUE_TEMPLATE/game-gog.yml index 0e06dfd23c..42506964a1 100644 --- a/.github/ISSUE_TEMPLATE/game-gog.yml +++ b/.github/ISSUE_TEMPLATE/game-gog.yml @@ -1,6 +1,6 @@ --- name: GOG game preset -description: Request a Sunshine GOG launch option for a GameDB game. +description: Request a GOG launch option for a GameDB game. title: '[GOG GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml index 753e77a58a..a28ac5ebc9 100644 --- a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml +++ b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml @@ -1,6 +1,6 @@ --- name: Microsoft Store game preset -description: Request a Sunshine Microsoft Store launch option for a GameDB game. +description: Request a Microsoft Store launch option for a GameDB game. title: '[MICROSOFT STORE GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Installed app AUMID from Get-StartApps, for example Microsoft.WindowsCalculator_8wekyb3d8bbwe!App. A Store product ID will not launch the game. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-native.yml b/.github/ISSUE_TEMPLATE/game-native.yml index 808564a39a..35f74998fe 100644 --- a/.github/ISSUE_TEMPLATE/game-native.yml +++ b/.github/ISSUE_TEMPLATE/game-native.yml @@ -1,6 +1,6 @@ --- name: Native game preset -description: Request a Sunshine Native launch option for a GameDB game. +description: Request a native launch option for a GameDB game. title: '[NATIVE GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-steam.yml b/.github/ISSUE_TEMPLATE/game-steam.yml index 447e4a39b0..b85177eb1e 100644 --- a/.github/ISSUE_TEMPLATE/game-steam.yml +++ b/.github/ISSUE_TEMPLATE/game-steam.yml @@ -1,6 +1,6 @@ --- name: Steam game preset -description: Request a Sunshine Steam launch option for a GameDB game. +description: Request a Steam launch option for a GameDB game. title: '[STEAM GAME PRESET]: ' labels: - request-game-preset @@ -24,7 +24,7 @@ body: label: Launch ID description: | Steam numeric app ID from its store URL, for example 464920. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/workflows/build-pages.yml b/.github/workflows/build-pages.yml index 757cbabde7..5d696fd556 100644 --- a/.github/workflows/build-pages.yml +++ b/.github/workflows/build-pages.yml @@ -36,6 +36,15 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + - name: Preview database migrations + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + source_commit=$(git -C database-branch rev-parse HEAD) + node src/migrate-database.js --database database-branch/database \ + --backup-branch "database-backup-$source_commit" --source-commit "$source_commit" + - name: Assemble site run: node src/build-site.js --database database-branch/database --output site-build diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 550e06698d..841d7f9464 100644 --- a/.github/workflows/check-preset.yml +++ b/.github/workflows/check-preset.yml @@ -53,7 +53,9 @@ jobs: EVENT_FILE: ${{ github.event_path }} TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} - run: node src/issue.js --event "$EVENT_FILE" --database database-branch/database --mode check --report report.md + run: | + node src/issue.js --event "$EVENT_FILE" --database database-branch/database \ + --mode check --report report.md --title-output issue-title.txt - name: Post result if: always() && hashFiles('report.md') != '' @@ -62,6 +64,22 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue comment "$ISSUE_NUMBER" --body-file report.md + - name: Update issue title + if: steps.validate.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const fs = require('node:fs') + const title = fs.readFileSync('issue-title.txt', 'utf8').trim() + if (context.payload.issue.title !== title) { + await github.rest.issues.update({ + ...context.repo, + issue_number: context.issue.number, + title + }) + } + - name: Queue trusted game submission if: steps.validate.outcome == 'success' && contains(github.event.issue.labels.*.name, 'request-game-preset') uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/migrate-database.yml b/.github/workflows/migrate-database.yml new file mode 100644 index 0000000000..dc7580aa99 --- /dev/null +++ b/.github/workflows/migrate-database.yml @@ -0,0 +1,87 @@ +--- +name: Migrate Database +permissions: {} + +env: + NODE_VERSION: '24' + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: database-migrations + cancel-in-progress: false + +jobs: + migrate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout database + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: database + path: database-branch + token: ${{ secrets.GH_BOT_TOKEN }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Back up and migrate database + id: migrate + env: + BOT_NAME: ${{ vars.GH_BOT_NAME }} + BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + shell: bash + run: | + set -euo pipefail + git -C database-branch config user.name "$BOT_NAME" + git -C database-branch config user.email "$BOT_EMAIL" + for _ in 1 2 3 4 5; do + git -C database-branch fetch origin database + git -C database-branch reset --hard origin/database + pending=$(node src/migrate-database.js --database database-branch/database --pending) + if [[ -z "$pending" ]]; then + echo 'No pending database migrations' + exit 0 + fi + source_commit=$(git -C database-branch rev-parse HEAD) + backup_branch="database-backup-$source_commit" + backup_remote=$(git -C database-branch ls-remote origin "refs/heads/$backup_branch" | cut -f1) + if [[ -z "$backup_remote" ]]; then + git -C database-branch push origin "$source_commit:refs/heads/$backup_branch" + elif [[ "$backup_remote" != "$source_commit" ]]; then + echo "Backup branch $backup_branch does not match $source_commit" >&2 + exit 1 + fi + node src/migrate-database.js --database database-branch/database \ + --backup-branch "$backup_branch" --source-commit "$source_commit" + git -C database-branch add database + if git -C database-branch diff --cached --quiet; then + echo 'A pending migration produced no database changes' >&2 + exit 1 + fi + git -C database-branch commit -m 'chore: apply database migrations' + if git -C database-branch push origin HEAD:database; then + echo 'migrated=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo 'Database changed during all five migration attempts' >&2 + exit 1 + + - name: Build updated site + if: steps.migrate.outputs.migrated == 'true' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + run: gh workflow run build-pages.yml --ref master diff --git a/README.md b/README.md index b8447d815b..e753c6d640 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ width="256" />
Community launch options for Windows, Linux, and macOS. Find a game or app, then choose the preset that fits your host.
+Community launch commands for Windows, Linux, and macOS. Browse presets, copy a command, or export Sunshine application JSON.
Contribute a game preset Contribute an app preset diff --git a/package.json b/package.json index 6dbcc7a574..e09e70d63c 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "presetdb", "version": "0.0.0", "private": true, - "description": "Community Sunshine game and app launch presets.", + "description": "Community game and app launch presets.", "license": "AGPL-3.0-only", "engines": { "node": ">=20" }, "scripts": { "test": "node --test tests/*.test.js", - "lint": "node --check src/presets.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js", + "lint": "node --check src/presets.js && node --check src/record.js && node --check src/migrate-database.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js", "test:ci": "node --test --experimental-test-coverage --test-reporter=junit --test-reporter=lcov --test-reporter-destination=junit.xml --test-reporter-destination=lcov.info tests/*.test.js" } } diff --git a/src/build-site.js b/src/build-site.js index 33b38b70b0..1b9058fdba 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -3,10 +3,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { buildStatistics } = require('./statistics'); +const { normalizeRecord } = require('./record'); function supportedOs(preset) { if (preset.os) return [preset.os]; - return preset.sunshine_by_os ? Object.keys(preset.sunshine_by_os) : ['Windows', 'Linux', 'macOS']; + return preset.commands_by_os ? Object.keys(preset.commands_by_os) : ['Windows', 'Linux', 'macOS']; } const PROTON_TIERS = new Set(['borked', 'bronze', 'silver', 'gold', 'platinum', 'native']); @@ -51,7 +52,7 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch) const target = path.join(output, folder); fs.mkdirSync(target, { recursive: true }); for (const file of files) { - const item = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')); + const item = normalizeRecord(JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'))); if (item.kind !== kind || !Array.isArray(item.presets) || item.presets.length === 0 || String(item.id) !== path.basename(file, '.json')) { throw new Error(`Invalid database record: ${folder}/${file}`); @@ -62,7 +63,8 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch) index[folder].push({ id: item.id, name: item.name, preset_count: item.presets.length, image_url: item.image_url || null, - operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)) + operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)), + ...(kind === 'game' ? { launch_methods: [...new Set(item.presets.map(preset => preset.method))].sort((a, b) => a.localeCompare(b)) } : {}) }); } index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))); diff --git a/src/database.js b/src/database.js index 8d235b1e4c..1fadab514b 100644 --- a/src/database.js +++ b/src/database.js @@ -3,6 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PresetError } = require('./presets'); +const { normalizeRecord } = require('./record'); function recordPath(root, preset) { const folder = preset.kind === 'game' ? 'games' : 'apps'; @@ -12,7 +13,7 @@ function recordPath(root, preset) { function readRecord(file) { if (!fs.existsSync(file)) return null; - const record = JSON.parse(fs.readFileSync(file, 'utf8')); + const record = normalizeRecord(JSON.parse(fs.readFileSync(file, 'utf8'))); if (!record || !Array.isArray(record.presets)) throw new PresetError(`Invalid database record: ${file}`); return record; } @@ -25,7 +26,7 @@ function prepareRecord(root, preset) { let record = readRecord(file); if (!record) { record = { - schema_version: 1, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, + schema_version: 2, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, name, source_url: sourceUrl, image_url: null, presets: [] }; } @@ -43,20 +44,6 @@ function prepareRecord(root, preset) { return { file, record, name }; } -function methodLabel(method) { - return { - native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', - gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' - }[method]; -} - -function displayName(name, preset) { - if (preset.kind === 'app') return `${name} (${preset.os})`; - const method = methodLabel(preset.method); - const variant = preset.variantName ? `: ${preset.variantName}` : ''; - return preset.os ? name + ' (' + preset.os + ', ' + method + variant + ')' : name + ' (' + method + variant + ')'; -} - function replacementIndex(record, preset) { const previous = preset.replacementIssue == null ? -1 : record.presets.findIndex(item => item.origin_issue === preset.replacementIssue || item.source_issue === preset.replacementIssue @@ -79,7 +66,7 @@ function mergePreset(root, preset, { const { file, record, name } = prepareRecord(root, preset); const previous = replacementIndex(record, preset); const originIssue = previous >= 0 ? record.presets[previous].origin_issue : issueNumber; - const presetId = `issue-${originIssue}`; + const presetId = String(originIssue); const history = previous >= 0 ? [...(record.presets[previous].history || [])] : []; if (history.some(item => item.issue === issueNumber)) { throw new PresetError(`Issue #${issueNumber} has already been approved for this preset`); @@ -88,22 +75,16 @@ function mergePreset(root, preset, { issue: issueNumber, action: previous >= 0 ? 'replace' : 'add', author_id: authorId, author_login: authorLogin, approved_at: approvedAt }); - const generatedName = displayName(name, preset); const entry = { - id: presetId, name: generatedName, os: preset.os, method: preset.method, + id: presetId, name, os: preset.os, method: preset.method, ...(preset.variantName ? { variant_name: preset.variantName } : {}), ...(preset.launchId ? { launch_id: preset.launchId } : {}), - ...(preset.commandsByOs ? { - sunshine_by_os: Object.fromEntries(Object.entries(preset.commandsByOs).map(([os, cmd]) => [ - os, { name: name + ' (' + os + ', ' + methodLabel(preset.method) + ')', cmd } - ])) - } : { - sunshine: { - name: generatedName, - cmd: preset.command, - ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {}) - } - }), + ...(preset.commandsByOs + ? { commands_by_os: preset.commandsByOs } + : { + command: preset.command, + ...(preset.workingDir ? { working_directory: preset.workingDir } : {}) + }), notes: preset.notes, origin_issue: originIssue, source_issue: issueNumber, @@ -113,7 +94,7 @@ function mergePreset(root, preset, { }; if (previous >= 0) record.presets[previous] = entry; else record.presets.push(entry); - record.presets.sort((a, b) => a.id.localeCompare(b.id)); + record.presets.sort((a, b) => Number(a.id) - Number(b.id)); if (write) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`); diff --git a/src/issue.js b/src/issue.js index ac01649d47..5fc7275114 100644 --- a/src/issue.js +++ b/src/issue.js @@ -46,6 +46,14 @@ async function processIssue(event, database, { approve = false, actor = '', fetc return { ...merged, preset, kind }; } +function issueTitle(preset) { + const name = preset.kind === 'app' ? preset.appName : preset.gameName; + const prefix = preset.kind === 'app' + ? 'APP PRESET' : preset.method.replaceAll('-', ' ').toUpperCase() + ' GAME PRESET'; + const detail = preset.os || preset.variantName; + return '[' + prefix + ']: ' + name + (detail ? ' (' + detail + ')' : ''); +} + function argsToObject(args) { const options = {}; for (let i = 0; i < args.length; i += 2) { @@ -61,30 +69,36 @@ function argsToObject(args) { async function main(args = process.argv.slice(2)) { const options = argsToObject(args); let message; + let title; let success = false; try { const event = JSON.parse(fs.readFileSync(options.event, 'utf8')); const result = await processIssue(event, options.database, { approve: options.mode === 'approve', actor: process.env.GITHUB_ACTOR || 'local' }); + title = issueTitle(result.preset); const item = result.kind === 'game' ? `GameDB game ${result.preset.gameId}` : `app ${result.preset.appName}`; const entry = result.record.presets.find(preset => preset.id === result.id); - const preview = entry.sunshine || entry.sunshine_by_os; + const preview = entry.commands_by_os || { command: entry.command, ...(entry.working_directory ? { working_directory: entry.working_directory } : {}) }; const methodLine = result.kind === 'game' ? `- Method: ${result.preset.method}\n` : ''; message = `Preset ${result.action === 'replace' ? 'replacement' : 'request'} validated for ${item}.\n\n` + `- Host: ${result.preset.os || 'OS independent'}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + - `Sunshine application JSON preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`; + `Launch command preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`; success = true; } catch (error) { message = `Preset validation failed: ${String(error.message).replace(/[\r\n]+/g, ' ').slice(0, 500)}\n`; } fs.mkdirSync(path.dirname(options.report), { recursive: true }); fs.writeFileSync(options.report, message); + if (success && options['title-output']) { + fs.mkdirSync(path.dirname(options['title-output']), { recursive: true }); + fs.writeFileSync(options['title-output'], title + '\n'); + } console.log(message); if (!success) process.exitCode = 1; } if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; }); -module.exports = { processIssue, requestKind }; +module.exports = { processIssue, requestKind, issueTitle, main }; diff --git a/src/migrate-database.js b/src/migrate-database.js new file mode 100644 index 0000000000..e188e83a3e --- /dev/null +++ b/src/migrate-database.js @@ -0,0 +1,83 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { normalizeRecord } = require('./record'); + +const MIGRATIONS = [{ + id: '001-generic-launch-presets', + apply(database) { + for (const folder of ['games', 'apps']) { + const directory = path.join(database, folder); + if (!fs.existsSync(directory)) continue; + for (const file of fs.readdirSync(directory).filter(name => name.endsWith('.json'))) { + const recordPath = path.join(directory, file); + const record = JSON.parse(fs.readFileSync(recordPath, 'utf8')); + const migrated = normalizeRecord(record); + if (migrated !== record) fs.writeFileSync(recordPath, JSON.stringify(migrated, null, 2) + '\n'); + } + } + } +}]; + +function migrationHistory(database) { + const file = path.join(database, 'migrations.json'); + if (!fs.existsSync(file)) return { applied: [] }; + const history = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!history || !Array.isArray(history.applied) || + history.applied.some(entry => typeof entry.id !== 'string') || + new Set(history.applied.map(entry => entry.id)).size !== history.applied.length) { + throw new Error('Invalid database migration history'); + } + return history; +} + +function pendingMigrations(database) { + const applied = new Set(migrationHistory(database).applied.map(entry => entry.id)); + return MIGRATIONS.filter(migration => !applied.has(migration.id)); +} + +function applyMigrations(database, { backupBranch, sourceCommit, appliedAt = new Date().toISOString() } = {}) { + const pending = pendingMigrations(database); + if (pending.length === 0) return []; + if (!/^database-backup-[a-z0-9-]+$/.test(backupBranch || '') || + !/^[0-9a-f]{40}$/.test(sourceCommit || '')) { + throw new Error('A database backup branch and its source commit are required'); + } + const history = migrationHistory(database); + for (const migration of pending) { + migration.apply(database); + history.applied.push({ + id: migration.id, + backup_branch: backupBranch, + source_commit: sourceCommit, + applied_at: appliedAt + }); + } + fs.writeFileSync(path.join(database, 'migrations.json'), JSON.stringify(history, null, 2) + '\n'); + return pending.map(migration => migration.id); +} + +function main(args = process.argv.slice(2)) { + const options = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--pending') options.pending = true; + else if (args[i].startsWith('--') && args[i + 1]) options[args[i++].slice(2)] = args[i]; + else throw new Error('Use --database with --pending or --backup-branch and --source-commit'); + } + if (!options.database) throw new Error('--database is required'); + if (options.pending) { + const pending = pendingMigrations(options.database).map(migration => migration.id); + console.log(pending.join('\n')); + return pending; + } + const applied = applyMigrations(options.database, { + backupBranch: options['backup-branch'], sourceCommit: options['source-commit'] + }); + console.log(applied.length ? 'Applied migrations: ' + applied.join(', ') : 'No pending migrations'); + return applied; +} + +if (require.main === module) main(); + +module.exports = { MIGRATIONS, migrationHistory, pendingMigrations, applyMigrations, main }; diff --git a/src/presets.js b/src/presets.js index 3304576f3f..286595275b 100644 --- a/src/presets.js +++ b/src/presets.js @@ -72,7 +72,7 @@ function validatePlaceholders(value, os, label) { } function validatePortablePath(value, os, label) { - // Personal home locations cannot be shared between Sunshine hosts. + // Personal home locations cannot be shared between hosts. const normalized = value.replaceAll('\\', '/'); const homeRoot = /(?:^|[\s"'=])(?:~\/|[a-z]:\/(?:users|documents and settings)\/|\/(?:home|users)\/)/i; const homeVariable = /%userprofile%|%homepath%|\$home|\$\{home\}|\$\(home\)/i; @@ -127,9 +127,9 @@ function steamLaunch(id) { } const uri = 'steam://rungameid/' + id; return { launchId: id, commandsByOs: { - Windows: 'cmd /c start "" "' + uri + '"', - Linux: 'steam "' + uri + '"', - macOS: 'open "' + uri + '"' + Windows: uri, + Linux: 'setsid steam ' + uri, + macOS: 'open ' + uri } }; } @@ -141,8 +141,8 @@ function epicLaunch(id) { const launchId = parts.join('%3A'); const uri = 'com.epicgames.launcher://apps/' + launchId + '?action=launch&silent=true'; return { launchId, commandsByOs: { - Windows: 'cmd /c start "" "' + uri + '"', - macOS: 'open "' + uri + '"' + Windows: uri, + macOS: 'open ' + uri } }; } @@ -308,4 +308,4 @@ async function validateGameDb(preset, fetcher = globalThis.fetch, credentials = return preset; } -module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug }; +module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug, generatedLaunch }; diff --git a/src/record.js b/src/record.js new file mode 100644 index 0000000000..5a3eebe22e --- /dev/null +++ b/src/record.js @@ -0,0 +1,35 @@ +'use strict'; + +const { PresetError, generatedLaunch } = require('./presets'); + +function normalizeRecord(record) { + if (!record || !Array.isArray(record.presets)) throw new PresetError('Invalid database record'); + if (record.schema_version === 2) return record; + if (record.schema_version !== 1) throw new PresetError('Unsupported database schema version'); + + return { + ...record, + schema_version: 2, + presets: record.presets.map(preset => { + const item = { ...preset }; + const legacyId = /^issue-([1-9]\d*)$/.exec(item.id); + if (legacyId) item.id = legacyId[1]; + item.name = record.name; + if (item.sunshine_by_os) { + const method = { steam: 'Steam', 'epic-games': 'Epic Games', 'microsoft-store': 'Microsoft Store' }[item.method]; + item.commands_by_os = method && item.launch_id + ? generatedLaunch({ launchId: item.launch_id }, method).commandsByOs + : Object.fromEntries(Object.entries(item.sunshine_by_os).map(([os, launch]) => [os, launch.cmd])); + delete item.sunshine_by_os; + } + if (item.sunshine) { + item.command = item.sunshine.cmd; + if (item.sunshine['working-dir']) item.working_directory = item.sunshine['working-dir']; + delete item.sunshine; + } + return item; + }) + }; +} + +module.exports = { normalizeRecord }; diff --git a/tests/issue-cli.test.js b/tests/issue-cli.test.js new file mode 100644 index 0000000000..cce62415be --- /dev/null +++ b/tests/issue-cli.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { main, issueTitle } = require('../src/issue'); + +test('issue command writes a contributor review with the short preset ID and command preview', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-issue-cli-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const eventFile = path.join(root, 'event.json'); + const reportFile = path.join(root, 'report.md'); + const titleFile = path.join(root, 'issue-title.txt'); + fs.writeFileSync(eventFile, JSON.stringify({ issue: { + number: 51, + labels: ['request-app-preset'], + body: [ + '### App name', 'App One', + '### Official app URL', 'https://example.org/app', + '### Host operating system', 'Windows', + '### Command', 'app.exe' + ].join('\n\n') + } })); + await main(['--event', eventFile, '--database', path.join(root, 'database'), + '--mode', 'check', '--report', reportFile, '--title-output', titleFile]); + const report = fs.readFileSync(reportFile, 'utf8'); + assert.equal(fs.readFileSync(titleFile, 'utf8').trim(), '[APP PRESET]: App One (Windows)'); + assert.ok(report.includes('Preset ID: ' + String.fromCharCode(96) + '51' + String.fromCharCode(96))); + assert.match(report, /Launch command preview:/); + assert.match(report, /"command": "app.exe"/); + assert.match(report, /awaiting maintainer review/); + assert.doesNotMatch(report, /sunshine/); +}); + +test('validated issue titles identify launch method and keep it out of the preset name', () => { + assert.equal(issueTitle({ kind: 'game', gameName: '007 First Light', method: 'steam' }), + '[STEAM GAME PRESET]: 007 First Light'); + assert.equal(issueTitle({ kind: 'game', gameName: 'Example', method: 'native', os: 'Linux' }), + '[NATIVE GAME PRESET]: Example (Linux)'); + assert.equal(issueTitle({ kind: 'game', gameName: 'Example', method: 'emulator', + variantName: 'RetroArch Snes9x' }), + '[EMULATOR GAME PRESET]: Example (RetroArch Snes9x)'); +}); diff --git a/tests/migration-cli.test.js b/tests/migration-cli.test.js new file mode 100644 index 0000000000..43386ee482 --- /dev/null +++ b/tests/migration-cli.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { main } = require('../src/migrate-database'); + +test('migration command reports pending work, applies it once, and records its backup', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migration-cli-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const recordFile = path.join(games, '123.json'); + fs.writeFileSync(recordFile, JSON.stringify({ + schema_version: 1, kind: 'game', id: 123, name: 'Example', + presets: [{ + id: 'issue-4', name: 'Example (Native)', method: 'native', os: 'Windows', + sunshine: { name: 'Example (Native)', cmd: 'example.exe' } + }] + })); + const sourceCommit = 'b'.repeat(40); + const backupBranch = 'database-backup-' + sourceCommit; + assert.deepEqual(main(['--database', root, '--pending']), ['001-generic-launch-presets']); + assert.deepEqual(main(['--database', root, '--backup-branch', backupBranch, + '--source-commit', sourceCommit]), ['001-generic-launch-presets']); + assert.equal(JSON.parse(fs.readFileSync(recordFile, 'utf8')).presets[0].id, '4'); + assert.deepEqual(main(['--database', root, '--pending']), []); + assert.deepEqual(main(['--database', root]), []); + const history = JSON.parse(fs.readFileSync(path.join(root, 'migrations.json'), 'utf8')); + assert.equal(history.applied[0].backup_branch, backupBranch); + assert.equal(history.applied[0].source_commit, sourceCommit); + assert.throws(() => main([]), /database is required/); + assert.throws(() => main(['--database']), /Use --database/); +}); diff --git a/tests/migration.test.js b/tests/migration.test.js new file mode 100644 index 0000000000..cbc89e68fe --- /dev/null +++ b/tests/migration.test.js @@ -0,0 +1,95 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { applyMigrations, migrationHistory, pendingMigrations } = require('../src/migrate-database'); + +test('migration records its backup and does not rerun', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const file = path.join(games, '123.json'); + fs.writeFileSync(file, JSON.stringify({ + schema_version: 1, kind: 'game', id: 123, name: 'Example', + presets: [{ id: 'issue-4', name: 'Example (Native)', method: 'native', os: 'Windows', + sunshine: { name: 'Example (Native)', cmd: 'example.exe', 'working-dir': 'C:\\Games' } }] + })); + const sourceCommit = 'a'.repeat(40); + const backupBranch = 'database-backup-' + sourceCommit; + assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']); + assert.deepEqual(applyMigrations(root, { + backupBranch, sourceCommit, appliedAt: '2026-09-23T00:00:00.000Z' + }), ['001-generic-launch-presets']); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(record.schema_version, 2); + assert.equal(record.presets[0].id, '4'); + assert.equal(record.presets[0].command, 'example.exe'); + assert.equal(record.presets[0].working_directory, 'C:\\Games'); + assert.ok(!Object.hasOwn(record.presets[0], 'sunshine')); + assert.deepEqual(migrationHistory(root), { applied: [{ + id: '001-generic-launch-presets', + backup_branch: backupBranch, + source_commit: sourceCommit, + applied_at: '2026-09-23T00:00:00.000Z' + }] }); + const content = fs.readFileSync(file, 'utf8'); + assert.deepEqual(pendingMigrations(root), []); + assert.deepEqual(applyMigrations(root, { backupBranch: 'database-backup-other' }), []); + assert.equal(fs.readFileSync(file, 'utf8'), content); +}); + +test('migration requires a backup reference before changing the database', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-backup-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.throws(() => applyMigrations(root, {}), /backup branch/); + assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']); + assert.ok(!fs.existsSync(path.join(root, 'migrations.json'))); +}); + +test('migration history rejects duplicate IDs', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-history-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'migrations.json'), JSON.stringify({ + applied: [{ id: '001-generic-launch-presets' }, { id: '001-generic-launch-presets' }] + })); + assert.throws(() => pendingMigrations(root), /Invalid database migration history/); +}); + +test('first migration rewrites a legacy Steam preset ID, commands, and storage key', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-steam-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const file = path.join(games, '141114.json'); + fs.writeFileSync(file, JSON.stringify({ + schema_version: 1, kind: 'game', id: 141114, name: '007 First Light', + presets: [{ + id: 'issue-4', name: '007 First Light (Steam)', os: null, method: 'steam', + launch_id: '3768760', sunshine_by_os: { + Windows: { cmd: 'cmd /c start "" "steam://rungameid/3768760"' }, + Linux: { cmd: 'steam "steam://rungameid/3768760"' }, + macOS: { cmd: 'open "steam://rungameid/3768760"' } + } + }] + })); + const sourceCommit = 'c'.repeat(40); + applyMigrations(root, { + backupBranch: 'database-backup-' + sourceCommit, + sourceCommit, + appliedAt: '2026-09-23T00:00:00.000Z' + }); + const migrated = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(migrated.schema_version, 2); + assert.equal(migrated.presets[0].id, '4'); + assert.equal(migrated.presets[0].name, '007 First Light'); + assert.deepEqual(migrated.presets[0].commands_by_os, { + Windows: 'steam://rungameid/3768760', + Linux: 'setsid steam steam://rungameid/3768760', + macOS: 'open steam://rungameid/3768760' + }); + assert.ok(!Object.hasOwn(migrated.presets[0], 'sunshine_by_os')); +}); diff --git a/tests/presets.test.js b/tests/presets.test.js index d39dc7cba4..da4f9853e8 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -9,7 +9,7 @@ const { parseIssue, validateFields, validateGameDb, resolveIgdbSlug } = require( const { mergePreset } = require('../src/database'); const { processIssue } = require('../src/issue'); const { buildSite } = require('../src/build-site'); -const { filterItems, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app'); +const { filterItems, commandForOs, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app'); const credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; const gameValues = { @@ -120,14 +120,17 @@ test('store IDs generate commands for each launcher OS without asking for OS', ( const steam = validateFields({ ...base, method: 'Steam', launchId: '464920' }, 'game'); assert.equal(steam.os, null); assert.deepEqual(steam.commandsByOs, { - Windows: 'cmd /c start "" "steam://rungameid/464920"', - Linux: 'steam "steam://rungameid/464920"', - macOS: 'open "steam://rungameid/464920"' + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920', + macOS: 'open steam://rungameid/464920' }); const epic = validateFields({ ...base, method: 'Epic Games', launchId: 'fn:4fe75bbc5a674f4f9b356b5c90567da5:Fortnite' }, 'game'); assert.equal(epic.launchId, 'fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite'); - assert.deepEqual(Object.keys(epic.commandsByOs), ['Windows', 'macOS']); + assert.deepEqual(epic.commandsByOs, { + Windows: 'com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true', + macOS: 'open com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true' + }); const aumid = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'; const store = validateFields({ ...base, method: 'Microsoft Store', launchId: aumid }, 'game'); assert.deepEqual(store.commandsByOs, { Windows: 'explorer.exe shell:AppsFolder\\' + aumid }); @@ -176,7 +179,7 @@ test('approved presets get issue IDs and replacements preserve identity', async const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-db-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const first = await validateGameDb(validateFields(gameValues, 'game'), mockApis, credentials); - assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10'); + assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, '10'); assert.throws(() => mergePreset(root, first, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); assert.throws(() => mergePreset(root, { ...first, variantName: 'retroarch snes9x' }, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); @@ -184,28 +187,30 @@ test('approved presets get issue IDs and replacements preserve identity', async mergePreset(root, second, { issueNumber: 12, approvedBy: 'maintainer' }, { write: true }); const replacement = await validateGameDb(validateFields({ ...gameValues, command: 'new-command', replacementIssue: '10', replacementReason: 'Old command failed' }, 'game'), mockApis, credentials); - assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10'); + assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, '10'); const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json'))); + assert.equal(record.schema_version, 2); assert.equal(record.presets.length, 2); - assert.equal(record.presets.find(item => item.id === 'issue-10').sunshine.cmd, 'new-command'); - assert.equal(record.presets.find(item => item.id === 'issue-10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)'); - assert.equal(record.presets.find(item => item.id === 'issue-10').origin_issue, 10); - assert.equal(record.presets.find(item => item.id === 'issue-10').source_issue, 13); + assert.deepEqual(record.presets.map(item => item.id), ['10', '12']); + assert.equal(record.presets.find(item => item.id === '10').command, 'new-command'); + assert.equal(record.presets.find(item => item.id === '10').name, 'One Tap Hero'); + assert.equal(record.presets.find(item => item.id === '10').origin_issue, 10); + assert.equal(record.presets.find(item => item.id === '10').source_issue, 13); assert.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/); assert.throws(() => mergePreset(root, { ...first, replacementIssue: 99, replacementReason: 'Fix' }, { issueNumber: 14, approvedBy: 'maintainer' }), /No preset/); }); -test('Steam app ID publishes one logical preset with host-specific Sunshine commands', async t => { +test('Steam app ID publishes one logical preset with host-specific commands', async t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-steam-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const steam = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials); const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true }); assert.equal(record.presets[0].launch_id, '464920'); - assert.equal(record.presets[0].sunshine_by_os.Windows.cmd, 'cmd /c start "" "steam://rungameid/464920"'); - assert.equal(record.presets[0].sunshine_by_os.Linux.cmd, 'steam "steam://rungameid/464920"'); - assert.equal(record.presets[0].name, 'One Tap Hero (Steam)'); + assert.equal(record.presets[0].commands_by_os.Windows, 'steam://rungameid/464920'); + assert.equal(record.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920'); + assert.equal(record.presets[0].name, 'One Tap Hero'); assert.ok(!Object.hasOwn(record.presets[0], 'detached')); assert.ok(!Object.hasOwn(record.presets[0], 'image-path')); }); @@ -224,9 +229,10 @@ test('issue processing and site build publish both game and app JSON', async t = const index = await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); assert.equal(index.games[0].preset_count, 1); assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); + assert.deepEqual(index.games[0].launch_methods, ['emulator']); assert.equal(index.apps[0].id, 'app-one'); assert.equal(index.apps[0].image_url, 'https://example.org/icon.png'); - assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'apps/app-one.json'))).presets[0].name, 'App One (Linux)'); + assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'apps/app-one.json'))).presets[0].name, 'App One'); assert.ok(fs.existsSync(path.join(output, 'games/100245.json'))); assert.ok(fs.existsSync(path.join(output, 'apps/app-one.json'))); assert.ok(fs.existsSync(path.join(output, 'top_contributors.svg'))); @@ -240,9 +246,14 @@ test('site filters and serializes Sunshine application JSON', () => { apps: [{ id: 'app', name: 'Media', operating_systems: ['Linux'], preset_count: 1 }] }; assert.deepEqual(filterItems(index, 'hal', 'game', 'Windows').map(item => item.name), ['Halo']); assert.equal(filterItems(index, '', 'all', 'macOS').length, 0); - assert.equal(JSON.parse(sunshineSnippet({ sunshine: { name: 'Halo', cmd: 'game.exe' } })).cmd, 'game.exe'); + assert.equal(commandForOs({ command: 'game.exe' }), 'game.exe'); + assert.deepEqual(JSON.parse(sunshineSnippet({ name: 'Halo', command: 'game.exe', working_directory: 'C:\\Games' })), + { name: 'Halo', cmd: 'game.exe', 'working-dir': 'C:\\Games' }); + assert.throws(() => commandForOs({ commands_by_os: { Windows: 'game.exe' } }, 'Linux'), /available host OS/); assert.equal(normalizeBasePath('/PresetDB'), '/PresetDB'); assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123'); + assert.equal(normalizeBasePath(''), '/PresetDB'); + assert.equal(normalizeBasePath('{{ site.baseurl }}'), '/PresetDB'); }); test('Pages build adds a ProtonDB tier to Steam presets and keeps the source link', async t => { @@ -259,11 +270,51 @@ test('Pages build adds a ProtonDB tier to Steam presets and keeps the source lin }; const index = await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output, fetcher); assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); + assert.deepEqual(index.games[0].launch_methods, ['steam']); const published = JSON.parse(fs.readFileSync(path.join(output, 'games/100245.json'))); assert.deepEqual(published.presets[0].protondb, { tier: 'gold', reports: 73 }); assert.equal(published.presets[0].protondb_url, 'https://www.protondb.com/app/464920'); assert.equal(JSON.parse(sunshineSnippet(published.presets[0], 'Linux')).cmd, - 'steam "steam://rungameid/464920"'); + 'setsid steam steam://rungameid/464920'); +}); + +test('legacy approved records publish generic commands and short IDs', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-legacy-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const database = path.join(root, 'database'); + const output = path.join(root, 'site'); + fs.mkdirSync(path.join(database, 'games'), { recursive: true }); + const record = { + schema_version: 1, kind: 'game', id: 100245, name: 'One Tap Hero', + source_url: 'https://www.igdb.com/games/one-tap-hero', + presets: [{ id: 'issue-4', name: 'One Tap Hero', os: null, method: 'steam', + launch_id: '464920', origin_issue: 4, source_issue: 4, + sunshine_by_os: { Windows: { cmd: 'cmd /c start "" "steam://rungameid/464920"' }, + Linux: { cmd: 'steam "steam://rungameid/464920"' }, + macOS: { cmd: 'open "steam://rungameid/464920"' } } }] + }; + fs.writeFileSync(path.join(database, 'games/100245.json'), JSON.stringify(record)); + await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output, + async () => ({ ok: false, status: 404 })); + const published = JSON.parse(fs.readFileSync(path.join(output, 'games/100245.json'))); + assert.equal(published.schema_version, 2); + assert.equal(published.presets[0].id, '4'); + assert.deepEqual(published.presets[0].commands_by_os, { + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920', + macOS: 'open steam://rungameid/464920' + }); + assert.ok(!Object.hasOwn(published.presets[0], 'sunshine_by_os')); + const replacement = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', + variantName: '', command: '', workingDir: '', launchId: '464920', + replacementIssue: '4', replacementReason: 'Refresh the launcher command' }, 'game'), mockApis, credentials); + const merged = mergePreset(database, replacement, { issueNumber: 50, approvedBy: 'reviewer' }, { write: true }); + assert.equal(merged.id, '4'); + const stored = JSON.parse(fs.readFileSync(path.join(database, 'games/100245.json'))); + assert.equal(stored.schema_version, 2); + assert.equal(stored.presets[0].id, '4'); + assert.equal(stored.presets[0].source_issue, 50); + assert.equal(stored.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920'); }); test('game method labels select the form and reject conflicting issue fields', async t => { diff --git a/tests/site-card.test.js b/tests/site-card.test.js new file mode 100644 index 0000000000..6274f0beb7 --- /dev/null +++ b/tests/site-card.test.js @@ -0,0 +1,105 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderPresetCard, browseMethodBadges } = require('../gh-pages-template/assets/js/app'); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.handlers = {}; + this.textContent = ''; + this.style = {}; + } + + append(...nodes) { + this.children.push(...nodes); + if (this.tagName === 'select' && this.value === undefined && nodes.length) { + this.value = nodes[0].value; + } + } + + addEventListener(name, handler) { + this.handlers[name] = handler; + } +} + +function findAll(root, tagName) { + return [root, ...root.children.flatMap(child => findAll(child, tagName))] + .filter(node => node.tagName === tagName); +} + +test('site card changes host command and copies command and Sunshine JSON', async t => { + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const copied = []; + Object.defineProperty(globalThis, 'document', { + configurable: true, value: { createElement: tag => new FakeElement(tag) } + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, value: { clipboard: { writeText: async value => copied.push(value) } } + }); + t.after(() => { + if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); + else delete globalThis.document; + if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator); + else delete globalThis.navigator; + }); + + const card = renderPresetCard({ + name: 'Example', + method: 'steam', + notes: 'Install Steam first.', + commands_by_os: { + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920' + }, + protondb: { tier: 'gold', reports: 73 }, + protondb_url: 'https://www.protondb.com/app/464920', + origin_issue: 4, + source_issue: 9 + }); + const select = findAll(card, 'select')[0]; + const codes = findAll(card, 'code'); + const buttons = findAll(card, 'button'); + assert.equal(select.value, 'Windows'); + assert.ok(findAll(card, 'span').some(badge => badge.textContent === 'Steam')); + assert.equal(JSON.parse(codes[1].textContent).name, 'Example'); + assert.equal(codes[0].textContent, 'steam://rungameid/464920'); + assert.equal(JSON.parse(codes[1].textContent).cmd, 'steam://rungameid/464920'); + assert.ok(findAll(card, 'a').some(link => link.href?.endsWith('/issues/4'))); + assert.ok(findAll(card, 'a').some(link => link.textContent === 'ProtonDB: Gold (73 reports)')); + + select.value = 'Linux'; + select.handlers.change(); + assert.equal(codes[0].textContent, 'setsid steam steam://rungameid/464920'); + assert.equal(JSON.parse(codes[1].textContent).cmd, 'setsid steam steam://rungameid/464920'); + + await buttons[0].handlers.click(); + await buttons[1].handlers.click(); + assert.deepEqual(copied, [codes[0].textContent, codes[1].textContent]); + assert.equal(buttons[0].textContent, 'Copied'); + assert.equal(buttons[1].textContent, 'Copied'); + + navigator.clipboard.writeText = async () => { throw new Error('clipboard unavailable'); }; + await buttons[0].handlers.click(); + await buttons[1].handlers.click(); + assert.match(buttons[0].textContent, /Select and copy the command/); + assert.match(buttons[1].textContent, /Select and copy the JSON/); + + const native = renderPresetCard({ name: 'Example', method: 'native', os: 'Windows', command: 'example.exe' }); + assert.equal(findAll(native, 'select').length, 0); + assert.equal(findAll(native, 'code')[0].textContent, 'example.exe'); + assert.deepEqual(findAll(native, 'span').map(badge => badge.textContent), ['Native', 'Windows']); + const app = renderPresetCard({ name: 'App One', method: 'native', os: 'Linux', + command: 'app-one' }, 'app'); + assert.deepEqual(findAll(app, 'span').map(badge => badge.textContent), ['Linux']); + const emulator = renderPresetCard({ name: 'Example', method: 'emulator', + variant_name: 'RetroArch Snes9x', command: 'retroarch' }); + assert.deepEqual(findAll(emulator, 'span').map(badge => badge.textContent), + ['Emulator', 'RetroArch Snes9x']); + const browseBadges = browseMethodBadges(['steam', 'gog', 'steam']); + assert.deepEqual(browseBadges.children.map(badge => badge.textContent), ['Steam', 'GOG']); + assert.equal(browseMethodBadges([]), null); +});