Move trampoline files (boot-hook, watcher, overlay) into trampoline/ subdirectory. Add upstream Luma v2.3.1 source at repo root with our additions: - Page::Ha (4th nav tab) with placeholder empty state - HA tab config schema and default entity mapping (ha-home/) - Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings - All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
7.4 KiB
JavaScript
207 lines
7.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import vm from 'node:vm';
|
|
|
|
const script = await fs.readFile(new URL('../tv/home.cjs', import.meta.url), 'utf8');
|
|
const app = 'com.ecklf.luma';
|
|
const stage = '/var/lib/luma-home/app';
|
|
const target = '/usr/palm/applications/com.webos.app.home';
|
|
const developer = `/media/developer/${app}/preferences.json`;
|
|
const shared = `/media/developer/temp/${app}/preferences.json`;
|
|
const internal = `/media/internal/.${app}/preferences.json`;
|
|
|
|
function fixture({ production = false, mounted = false, preferences = {} } = {}) {
|
|
const appDir = `/media/${production ? 'cryptofs' : 'developer'}/apps/usr/palm/applications/${app}`;
|
|
const files = new Map([
|
|
[stage, Buffer.alloc(0)],
|
|
[`${stage}/appinfo.json`, Buffer.from('{}')],
|
|
[`${appDir}/luma`, Buffer.from('mock executable')],
|
|
[`${appDir}/splash.png`, Buffer.from('default')],
|
|
...['dunes', 'forest', 'prism'].map((name) => [
|
|
`${appDir}/splash/${name}.png`,
|
|
Buffer.from(name),
|
|
]),
|
|
...Object.entries(preferences).map(([file, value]) => [
|
|
file,
|
|
Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)),
|
|
]),
|
|
]);
|
|
const writes = [];
|
|
const logs = [];
|
|
const missing = () => Object.assign(new Error('Missing'), { code: 'ENOENT' });
|
|
const mockFs = {
|
|
existsSync: (file) => files.has(file),
|
|
readFileSync(file, encoding) {
|
|
if (!files.has(file)) throw missing();
|
|
const data = files.get(file);
|
|
return encoding ? data.toString(encoding) : data;
|
|
},
|
|
writeFileSync(file, data) {
|
|
writes.push(file);
|
|
files.set(file, data);
|
|
},
|
|
renameSync(from, to) {
|
|
assert.ok(files.has(from));
|
|
files.set(to, files.get(from));
|
|
files.delete(from);
|
|
},
|
|
statSync(file) {
|
|
assert.ok([stage, target].includes(file));
|
|
return { dev: 1, ino: mounted || file === stage ? 1 : 2 };
|
|
},
|
|
readlinkSync() {
|
|
throw missing();
|
|
},
|
|
};
|
|
const context = vm.createContext({
|
|
require(name) {
|
|
if (name === 'fs') return mockFs;
|
|
if (name === 'path') return path.posix;
|
|
assert.equal(name, 'child_process');
|
|
const unexpected = () => assert.fail('Tests must not invoke any system or TV command');
|
|
return { execFileSync: unexpected, spawnSync: unexpected };
|
|
},
|
|
process: { argv: ['node', 'home.cjs', 'status'] },
|
|
console: { log: (line) => logs.push(line), error: (line) => logs.push(line) },
|
|
});
|
|
vm.runInContext(script, context, { timeout: 2000 });
|
|
assert.equal(context.process.exitCode, undefined);
|
|
return {
|
|
context,
|
|
files,
|
|
writes,
|
|
appDir,
|
|
splash() {
|
|
context.stageSplash(appDir);
|
|
assert.ok(!files.has(`${stage}/splash.png.new`));
|
|
return files.get(`${stage}/splash.png`).toString();
|
|
},
|
|
};
|
|
}
|
|
|
|
test('production and developer installs follow native preference precedence', () => {
|
|
const preferences = {
|
|
[developer]: { background: 'forest' },
|
|
[shared]: { background: 'dunes' },
|
|
[internal]: { background: 'prism' },
|
|
};
|
|
assert.equal(fixture({ production: true, preferences }).splash(), 'prism');
|
|
assert.equal(fixture({ preferences }).splash(), 'forest');
|
|
});
|
|
|
|
test('an existing persistent destination wins before installation-local migration', () => {
|
|
const persistent = fixture({ preferences: { [internal]: { background: 'prism' } } });
|
|
persistent.files.set(
|
|
`${persistent.appDir}/preferences.json`,
|
|
Buffer.from(JSON.stringify({ background: 'forest' })),
|
|
);
|
|
assert.equal(persistent.splash(), 'prism');
|
|
const legacy = fixture();
|
|
legacy.files.set(
|
|
`${legacy.appDir}/preferences.json`,
|
|
Buffer.from(JSON.stringify({ background: 'forest' })),
|
|
);
|
|
assert.equal(legacy.splash(), 'forest');
|
|
});
|
|
|
|
test('missing, malformed, invalid and unavailable backgrounds have a default splash', () => {
|
|
for (const saved of [undefined, '{', { background: '../forest' }, { background: 'unknown' }]) {
|
|
const mock = fixture({ preferences: saved === undefined ? {} : { [developer]: saved } });
|
|
assert.equal(mock.splash(), saved?.background === 'unknown' ? 'default' : 'dunes');
|
|
}
|
|
});
|
|
|
|
test('apply refreshes the staged image even while the Home mount is active', () => {
|
|
const mock = fixture({ mounted: true, preferences: { [developer]: { background: 'prism' } } });
|
|
mock.files.set(`${stage}/splash.png`, Buffer.from('old background'));
|
|
const calls = [];
|
|
Object.assign(mock.context, {
|
|
foreignMount: () => false,
|
|
foreground: () => 'some.other.app',
|
|
reread: () => calls.push('reread'),
|
|
stopHome: () => calls.push('stop'),
|
|
launchHome: () => assert.fail('Applying must not steal focus from another app'),
|
|
});
|
|
mock.context.apply({ appDir: mock.appDir }, false);
|
|
assert.equal(mock.files.get(`${stage}/splash.png`).toString(), 'prism');
|
|
assert.deepEqual(mock.writes, [`${stage}/splash.png.new`]);
|
|
assert.deepEqual(calls, ['reread', 'stop']);
|
|
});
|
|
|
|
function closeFixture({ running, closeReply = { returnValue: true } }) {
|
|
const mock = fixture();
|
|
const calls = [];
|
|
let queries = 0;
|
|
let pauses = 0;
|
|
Object.assign(mock.context, {
|
|
luna(method, payload) {
|
|
calls.push([method, { ...payload }]);
|
|
if (method === 'running') {
|
|
return { returnValue: true, running: running[Math.min(queries++, running.length - 1)] };
|
|
}
|
|
// The old close({id: APP}) call causes "no app matched by pid" on TV.
|
|
assert.equal(method, 'closeByAppId');
|
|
assert.equal(payload.id, app);
|
|
if (closeReply instanceof Error) throw closeReply;
|
|
return closeReply;
|
|
},
|
|
pause() {
|
|
pauses++;
|
|
},
|
|
});
|
|
return { ...mock, calls, pauses: () => pauses };
|
|
}
|
|
|
|
test('takeover closes the normal native instance by app ID and waits for its exit', () => {
|
|
const normal = { id: app, processId: '1234' };
|
|
const other = { id: 'some.other.app', processId: '5678' };
|
|
const mock = closeFixture({ running: [[normal, other], [normal, other], [other]] });
|
|
mock.context.closeNormal();
|
|
assert.deepEqual(mock.calls, [
|
|
['running', {}],
|
|
['closeByAppId', { id: app }],
|
|
['running', {}],
|
|
['running', {}],
|
|
]);
|
|
assert.equal(mock.pauses(), 1);
|
|
});
|
|
|
|
test('takeover skips closing when the normal instance is not running', () => {
|
|
const mock = closeFixture({ running: [[{ id: 'com.webos.app.home' }]] });
|
|
mock.context.closeNormal();
|
|
assert.deepEqual(mock.calls, [['running', {}]]);
|
|
assert.equal(mock.pauses(), 0);
|
|
});
|
|
|
|
test('an instance exiting before the close reply does not block takeover', () => {
|
|
for (const closeReply of [
|
|
{ returnValue: false, errorCode: 1, errorText: 'no app matched by pid' },
|
|
new Error('Luna close request timed out'),
|
|
]) {
|
|
const mock = closeFixture({ running: [[{ id: app }], []], closeReply });
|
|
mock.context.closeNormal();
|
|
assert.equal(mock.calls.length, 3);
|
|
assert.equal(mock.pauses(), 0);
|
|
}
|
|
});
|
|
|
|
test('close failures remain visible when the normal instance is still running', () => {
|
|
const mock = closeFixture({
|
|
running: [[{ id: app }]],
|
|
closeReply: { returnValue: false, errorText: 'Permission denied' },
|
|
});
|
|
assert.throws(
|
|
() => mock.context.closeNormal(),
|
|
/Close the normal Luma instance.*Permission denied/,
|
|
);
|
|
assert.equal(mock.pauses(), 0);
|
|
});
|
|
|
|
test('takeover stops waiting after five seconds if the normal instance stays running', () => {
|
|
const mock = closeFixture({ running: [[{ id: app }]] });
|
|
assert.throws(() => mock.context.closeNormal(), /Close Luma before enabling Home integration/);
|
|
assert.equal(mock.pauses(), 5);
|
|
});
|