Files
2026-10-14 04:20:24 +02:00

386 lines
18 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
import assert from 'node:assert/strict';
import test from 'node:test';
import {acquireImage, Archives, BrowserCapture, Controller, Downloads, FileSystem, Http, LocalNetwork, Power, Sound} from './native.js';
import {setFrameBatcher} from './frame.js';
test('native network tasks deliver progress, release listeners and stop polling', async () => {
let tick;
let clears = 0;
let snapshots = [];
const cancelled = [];
const originalInterval = globalThis.setInterval;
const originalClear = globalThis.clearInterval;
globalThis.setInterval = callback => { tick = callback; return 1; };
globalThis.clearInterval = () => { clears++; };
globalThis.__ps5ReactNative = {network: {
download: () => 1, request: () => 2,
cancel: id => cancelled.push(id), poll: () => snapshots, version: () => 'fixture',
}};
try {
const task = Downloads.enqueue({url: 'https://example.com/file', destination: '/download0/file'});
const states = [];
const unsubscribe = task.subscribe(progress => states.push(progress.state));
snapshots = [{id: 1, state: 'downloading', body: 'must not reach progress', written: 12}];
tick();
assert.equal(task.snapshot.destination, '/download0/file');
assert.equal('body' in task.snapshot, false);
unsubscribe();
snapshots = [{id: 1, state: 'completed', written: 24}];
tick();
assert.equal((await task.done).written, 24);
assert.deepEqual(states, ['queued', 'downloading']);
assert.equal(clears, 1);
task.cancel();
assert.deepEqual(cancelled, []);
const aborted = Downloads.enqueue({url: 'https://example.com/file', destination: '/download0/file'});
aborted.cancel();
snapshots = [{id: 1, state: 'cancelled'}];
tick();
await assert.rejects(aborted.done, error => error.name === 'AbortError' && error.message.includes('/download0/file'));
assert.deepEqual(cancelled, [1]);
const response = Http.request('https://example.com/json');
snapshots = [{id: 2, state: 'completed', status: 404, url: 'https://example.com/final', headers: {'content-type': 'application/json'}, body: '{"value":42}'}];
tick();
const result = await response;
assert.equal(result.ok, false);
assert.equal(result.status, 404);
assert.equal(result.url, 'https://example.com/final');
assert.equal(result.headers['content-type'], 'application/json');
assert.equal(Object.isFrozen(result.headers), true);
assert.deepEqual(await result.json(), {value: 42});
assert.equal(await result.text(), '{"value":42}');
assert.equal(clears, 3);
} finally {
globalThis.setInterval = originalInterval;
globalThis.clearInterval = originalClear;
delete globalThis.__ps5ReactNative;
}
});
test('removeAsync resolves once the worker removed the path and rejects with the path and system error', async () => {
let tick;
let snapshots = [];
const started = [];
const originalInterval = globalThis.setInterval;
const originalClear = globalThis.clearInterval;
globalThis.setInterval = callback => { tick = callback; return 1; };
globalThis.clearInterval = () => {};
globalThis.__ps5ReactNative = {
fs: {removeAsync: path => {
if (path.includes('..')) throw new Error(`fs.removeAsync ${path}: Path is not allowed`);
return started.push(path);
}},
removals: {poll: () => snapshots},
};
try {
const removed = FileSystem.removeAsync('/download0/game');
const refused = FileSystem.removeAsync('/download0/locked');
assert.deepEqual(started, ['/download0/game', '/download0/locked']);
snapshots = [{id: 1, state: 'removing', error: ''}, {id: 2, state: 'queued', error: ''}];
tick();
snapshots = [{id: 1, state: 'completed', error: ''}, {id: 2, state: 'failed', error: 'Permission denied'}];
tick();
assert.equal(await removed, undefined);
await assert.rejects(refused, {message: 'fs.removeAsync /download0/locked: Permission denied'});
await assert.rejects(FileSystem.removeAsync('/download0/../x'), /Path is not allowed/);
} finally {
globalThis.setInterval = originalInterval;
globalThis.clearInterval = originalClear;
delete globalThis.__ps5ReactNative;
}
});
test('remote images poll only while loads are pending and release every reference', () => {
const released = [];
let finished = [];
let polls = 0;
globalThis.__ps5ReactNative = {image: {
load: (uri, width, height) => (width === 10
? {id: 7, state: 'ready', name: '@image:7', width: 10, height: 10, error: ''}
: {id: 8, state: 'loading', name: '', width: 0, height: 0, error: ''}),
release: id => released.push(id),
poll: () => { polls++; const out = finished; finished = []; return out; },
}};
const seen = [];
const releaseReady = acquireImage('https://example.com/a.jpg', 10, 10, 0, false, result => seen.push(result.state));
globalThis.__ps5ReactFrame(16);
assert.equal(polls, 0, 'a cached image needs no polling');
const releaseLoading = acquireImage('https://example.com/b.jpg', 20, 20, 0, false, result => seen.push(result.state));
globalThis.__ps5ReactFrame(16);
finished = [{id: 8, state: 'ready', name: '@image:8', width: 20, height: 20, error: ''}];
globalThis.__ps5ReactFrame(16);
globalThis.__ps5ReactFrame(16);
assert.equal(polls, 2, 'polling stops once nothing is pending');
assert.deepEqual(seen, ['ready', 'loading', 'ready']);
releaseReady();
releaseLoading();
assert.deepEqual(released, [7, 8]);
globalThis.__ps5ReactFrame(16);
globalThis.__ps5ReactFrame(16);
assert.equal(polls, 3, 'a release polls once more so eviction can run');
delete globalThis.__ps5ReactNative;
});
test('finished image loads arriving together are delivered across frames under a time budget', () => {
const originalNow = performance.now;
let clock = 0, polls = 0, nextId = 1, finished = [];
performance.now = () => clock;
globalThis.__ps5ReactNative = {image: {
load: () => ({id: nextId++, state: 'loading', name: '', width: 0, height: 0, error: ''}),
release: () => {},
poll: () => { polls++; const out = finished; finished = []; return out; },
}};
try {
let frame = [];
const frames = [];
const step = () => { globalThis.__ps5ReactFrame(16); frames.push(frame); frame = []; };
// Each arrival costs what re-rendering its <Image> would: 1.5 ms, or 10 ms for id 6.
const releases = new Map();
for (let i = 1; i <= 9; i++) {
releases.set(i, acquireImage(`https://example.com/${i}.jpg`, 20, 20, 0, false, result => {
if (result.state === 'loading') return;
frame.push(result.state === 'failed' ? `${result.id}:${result.error}` : result.id);
clock += result.id === 6 ? 10 : 1.5;
}));
}
releases.get(3)();
finished = [1, 2, 3, 4, 5, 6, 7, 8, 9].map(id => (id === 9
? {id, state: 'failed', name: '', width: 0, height: 0, error: 'HTTP 404'}
: {id, state: 'ready', name: `@image:${id}`, width: 20, height: 20, error: ''}));
step();
releases.get(5)();
while (frames.length < 6) step();
assert.deepEqual(frames.filter(frame => frame.length), [[1, 2, 4], [6], [7, 8, '9:HTTP 404']],
'released ids are skipped for free, a slow arrival still lands, failures are delivered too');
assert.equal(polls, 3, 'polling continues while results are queued and stops once all are delivered');
} finally {
performance.now = originalNow;
delete globalThis.__ps5ReactNative;
}
});
test('image listener commits are measured individually despite the frame batcher', () => {
const originalNow = performance.now;
let finished = [], clock = 0;
const delivered = [];
const deferredBatches = [];
performance.now = () => clock;
setFrameBatcher(run => deferredBatches.push(run));
globalThis.__ps5ReactNative = {image: {
load: () => ({id: 1, state: 'loading', name: '', width: 0, height: 0, error: ''}),
release: () => {},
poll: () => { const out = finished; finished = []; return out; },
}};
try {
for (let id = 1; id <= 7; id++) acquireImage(`https://example.com/${id}.jpg`, 20, 20, 0, false, result => {
if (result.state === 'ready') { delivered.push(id); clock += 3; }
});
finished = [{id: 1, state: 'ready', name: '@image:1', width: 20, height: 20, error: ''}];
const batches = [];
for (let frame = 0; frame < 4; frame++) {
globalThis.__ps5ReactFrame(16);
batches.push(delivered.length);
}
assert.deepEqual(batches, [2, 4, 6, 7], 'the next listener waits until the next frame after commits cross 4 ms');
assert.deepEqual(delivered, [1, 2, 3, 4, 5, 6, 7]);
assert.equal(deferredBatches.length, 4, 'listeners ran although no batch did; each frame still closes with one');
} finally {
setFrameBatcher(run => run());
performance.now = originalNow;
delete globalThis.__ps5ReactNative;
}
});
test('Controller trigger effects map 0..1 onto the DualSense zones and steps', () => {
const calls = [];
globalThis.__ps5ReactNative = {pad: {setTriggerEffect: (...args) => calls.push(args)}};
try {
Controller.setTriggerResistance('l2', 0.75, 0.5);
Controller.setTriggerResistance('both', 0.01);
Controller.setTriggerResistance('r2', 0);
assert.deepEqual(calls.splice(0), [[1, 1, 5, 6], [3, 1, 0, 1], [2, 0]], 'a weak resistance is one step, not off');
Controller.setTriggerEffect('r2', {type: 'weapon', start: 0.25, end: 0.75, strength: 0.75});
Controller.setTriggerEffect('r2', {type: 'weapon', start: 0, end: 0.05, strength: 1});
Controller.setTriggerEffect('both', {type: 'weapon', start: 0.9, end: 1, strength: 0.5});
Controller.setTriggerEffect('l2', {type: 'vibration', amplitude: 0.5, frequency: 40.4});
Controller.setTriggerEffect('l2', {type: 'vibration', from: 1, amplitude: 0, frequency: 40});
Controller.setTriggerEffect('both', {type: 'off'});
assert.deepEqual(calls, [
[2, 2, 2, 7, 6],
[2, 2, 2, 3, 8], // zones the controller accepts: start 2..7, end start+1..8
[3, 2, 7, 8, 4],
[1, 3, 0, 4, 40],
[1, 0],
[3, 0],
]);
assert.throws(() => Controller.setTriggerResistance('l1', 1), /setTriggerResistance: trigger must be/);
assert.throws(() => Controller.setTriggerResistance('l2', 2), /must be 0\.\.1/);
assert.throws(() => Controller.setTriggerResistance('l2', 0.5, NaN), /must be 0\.\.1/);
assert.throws(() => Controller.setTriggerEffect('l2', {type: 'click'}), /setTriggerEffect: effect type must be/);
assert.throws(() => Controller.setTriggerEffect('l2'), /effect type must be/);
assert.throws(() => Controller.setTriggerEffect('l2', {type: 'weapon', start: 0.6, end: 0.4, strength: 1}), /start < end/);
assert.throws(() => Controller.setTriggerEffect('l2', {type: 'vibration', amplitude: 1, frequency: 0}), /1\.\.255 Hz/);
assert.throws(() => Controller.setTriggerEffect('l2', {type: 'resistance'}), /must be 0\.\.1/);
} finally {
delete globalThis.__ps5ReactNative;
}
});
test('Controller.subscribe reads the pad once a frame for every listener and stops with the last', () => {
let reads = 0;
let state = {connected: true, leftX: 0, leftY: 0, rightX: 0, rightY: 0, l2: 0, r2: 0, buttons: []};
globalThis.__ps5ReactNative = {pad: {state: () => { reads++; return state; }}};
try {
const a = [], b = [];
const stopA = Controller.subscribe(event => a.push(event.type === 'trigger' ? `${event.trigger}=${event.value}` : `${event.type} ${event.button}`));
const stopB = Controller.subscribe(event => b.push(event.type), {frames: true});
reads = 0;
state = {...state, r2: 0.7, buttons: ['r2', 'touchpad']};
globalThis.__ps5ReactFrame(16);
assert.equal(reads, 1, 'one read a frame, shared');
assert.deepEqual(a, ['press r2', 'press touchpad', 'r2=0.7']);
assert.deepEqual(b, ['press', 'press', 'trigger', 'frame']);
stopA();
stopA();
globalThis.__ps5ReactFrame(16);
assert.deepEqual(b.slice(4), ['frame']);
stopB();
reads = 0;
globalThis.__ps5ReactFrame(16);
assert.equal(reads, 0, 'no listener, no frame work');
} finally {
delete globalThis.__ps5ReactNative;
}
});
test('Power.keepAwake passes a boolean to the host', () => {
const calls = [];
globalThis.__ps5ReactNative = {power: {keepAwake: enabled => calls.push(enabled)}};
try {
Power.keepAwake(true);
Power.keepAwake(0);
assert.deepEqual(calls, [true, false]);
} finally {
delete globalThis.__ps5ReactNative;
}
});
test('Sound passes the name, volume and pan to the host and returns whether it started', () => {
const calls = [];
globalThis.__ps5ReactNative = {sound: {
play: (name, volume, pan) => { calls.push(['play', name, volume, pan]); return name === 'tick'; },
setVolume: volume => calls.push(['setVolume', volume]),
}};
try {
assert.equal(Sound.play('tick'), true);
assert.equal(Sound.play('page', {pan: -0.4}), false);
Sound.play('tick', {volume: 0.5});
Sound.setVolume(0);
assert.deepEqual(calls, [['play', 'tick', 1, 0], ['play', 'page', 1, -0.4], ['play', 'tick', 0.5, 0],
['setVolume', 0]]);
} finally {
delete globalThis.__ps5ReactNative;
}
});
test('archive and download task IDs are isolated while sharing the polling timer', async () => {
const originalInterval = globalThis.setInterval, originalClear = globalThis.clearInterval;
let tick, networkSnapshots = [], archiveSnapshots = [];
const cancelled = [];
const extractions = [];
globalThis.setInterval = callback => { tick = callback; return 1; };
globalThis.clearInterval = () => {};
globalThis.__ps5ReactNative = {
network: {download: () => 1, poll: () => networkSnapshots, cancel: id => cancelled.push(`network:${id}`)},
archives: {extract: (...args) => { extractions.push(args); return 1; }, poll: () => archiveSnapshots, cancel: id => cancelled.push(`archives:${id}`)},
};
try {
const download = Downloads.enqueue({url: 'https://example.com/file', destination: '/download0/file'});
const archive = Archives.extract({sources: ['/download0/file'], destination: '/download0/out', password: '[DLPSGAME.COM]'});
assert.deepEqual(extractions, [[['/download0/file'], '/download0/out', 1024**4, '[DLPSGAME.COM]', false]]);
assert.equal(Archives.streams, true);
for (const password of [42, 'bad\0password', 'x'.repeat(1025)])
assert.throws(() => Archives.extract({sources: ['/download0/file'], destination: '/download0/out', password}), /password/);
assert.equal(extractions.length, 1);
archive.cancel(); assert.deepEqual(cancelled, ['archives:1']);
archiveSnapshots = [{id: 1, state: 'extracting', phase: 'finishing', written: 50, synced: 1, syncTotal: 3}]; tick();
assert.deepEqual([archive.snapshot.phase, archive.snapshot.synced, archive.snapshot.syncTotal], ['finishing', 1, 3]);
archiveSnapshots = [{id: 1, state: 'completed', written: 50, artifacts: ['game.ffpfsc']}]; tick();
assert.deepEqual((await archive.done).artifacts, ['game.ffpfsc']);
assert.equal(download.snapshot.state, 'queued');
networkSnapshots = [{id: 1, state: 'completed', written: 100}]; tick();
assert.equal((await download.done).written, 100);
} finally {
globalThis.setInterval = originalInterval; globalThis.clearInterval = originalClear;
delete globalThis.__ps5ReactNative;
}
});
test('browser capture delivers only its result and cancellation rejects as AbortError', async () => {
const originalInterval = globalThis.setInterval, originalClear = globalThis.clearInterval;
let tick, snapshots = [], next = 1;
const calls = [], cancelled = [];
globalThis.setInterval = callback => { tick = callback; return 1; };
globalThis.clearInterval = () => {};
globalThis.__ps5ReactNative = {browser: {
capture: (...args) => { calls.push(args); return next++; },
poll: () => { const result = snapshots; snapshots = []; return result; },
cancel: id => cancelled.push(id),
}};
try {
const capture = BrowserCapture.capture({suffix: '/Game.pkg'});
assert.deepEqual(calls, [['https://vikingfile.com/d/', '/Game.pkg', 180]]);
const states = [];
const off = capture.subscribe(snapshot => states.push(snapshot.state));
snapshots = [{id: 1, state: 'capturing'}]; tick();
const url = 'https://vikingfile.com/d/Ab01234567/Game.pkg';
snapshots = [{id: 1, state: 'completed', url}]; tick();
assert.equal((await capture.done).url, url);
assert.deepEqual(states, ['queued', 'capturing', 'completed']); off();
const second = BrowserCapture.capture({suffix: '/Other.pkg', timeoutSeconds: 30});
second.cancel(); assert.deepEqual(cancelled, [2]);
snapshots = [{id: 2, state: 'cancelled'}]; tick();
await assert.rejects(second.done, {name: 'AbortError'});
} finally {
globalThis.setInterval = originalInterval; globalThis.clearInterval = originalClear;
delete globalThis.__ps5ReactNative;
}
});
test('a LAN broadcast resolves with its replies, and an abort cancels it', async () => {
const originalInterval = globalThis.setInterval, originalClear = globalThis.clearInterval;
let tick, snapshots = [], next = 1;
const calls = [], cancelled = [];
globalThis.setInterval = callback => { tick = callback; return 1; };
globalThis.clearInterval = () => {};
globalThis.__ps5ReactNative = {lan: {
broadcast: (...args) => { calls.push(args); return next++; },
poll: () => { const result = snapshots; snapshots = []; return result; },
cancel: id => cancelled.push(id),
}};
try {
const found = LocalNetwork.broadcast({port: 7359, message: 'who is JellyfinServer?'});
assert.deepEqual(calls, [[7359, 'who is JellyfinServer?', 1500, 16]]);
snapshots = [{id: 1, state: 'completed', error: '', replies: [{address: '192.168.0.10', port: 7359, body: '{"Id":"a"}'}]}];
tick();
assert.deepEqual(await found, [{address: '192.168.0.10', port: 7359, text: '{"Id":"a"}'}]);
const listeners = new Set();
const signal = {aborted: false, addEventListener: (_, fn) => listeners.add(fn), removeEventListener: (_, fn) => listeners.delete(fn)};
const second = LocalNetwork.broadcast({port: 7359, message: 'who is EmbyServer?', timeoutMs: 800, signal});
for (const listener of listeners) listener();
assert.deepEqual(cancelled, [2]);
snapshots = [{id: 2, state: 'cancelled', error: '', replies: []}];
tick();
await assert.rejects(second, {name: 'AbortError'});
assert.equal(listeners.size, 0);
} finally {
globalThis.setInterval = originalInterval; globalThis.clearInterval = originalClear;
delete globalThis.__ps5ReactNative;
}
});