homegames-common 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict');
3
+ const { randomUUID } = require('node:crypto');
4
+ const { openGameInStudio, receiveGameInStudio } = require('../studio-handoff');
5
+ const send = (0, eval)('(' + openGameInStudio.toString() + ')'); // Also runs embedded in HTML.
6
+ const tick = () => new Promise(resolve => setImmediate(resolve));
7
+ function pair(origin = 'null', protocol = 'https:') {
8
+ function endpoint(location) {
9
+ const events = new Map();
10
+ return { location, crypto: { randomUUID },
11
+ addEventListener(type, fn) { events.set(type, fn); },
12
+ removeEventListener(type) { events.delete(type); },
13
+ emit(type, event) { events.get(type)?.(event); },
14
+ get listenerCount() { return events.size; } };
15
+ }
16
+ const game = endpoint({ origin, protocol }), studio = endpoint({ origin: 'https://studio.example' });
17
+ const traffic = [];
18
+ const gameRef = { postMessage(data, target) {
19
+ traffic.push({ direction: 'game', data, target });
20
+ assert.ok(target === '*' || target === origin);
21
+ queueMicrotask(() => game.emit('message', { data, origin: studio.location.origin, source: studioRef }));
22
+ } };
23
+ const studioRef = { closed: false, postMessage(data, target) {
24
+ traffic.push({ direction: 'studio', data, target });
25
+ assert.equal(target, studio.location.origin);
26
+ queueMicrotask(() => studio.emit('message', { data, origin, source: gameRef }));
27
+ } };
28
+ game.open = url => { studio.location.hash = new URL(url).hash; return studioRef; };
29
+ studio.opener = gameRef;
30
+ return { game, studio, gameRef, studioRef, traffic };
31
+ }
32
+
33
+ for (const [protocol, origin] of [['file:', 'null'], ['file:', 'file://'], ['https:', 'https://game.example']]) test(`complete archive travels directly from ${origin} and is acknowledged after persistence`, async () => {
34
+ const ctx = pair(origin, protocol), bytes = new Uint8Array([80, 75, 0, 255, 7]);
35
+ let getCount = 0, saved = false, release, imports = 0;
36
+ const sent = send(ctx.game, () => { getCount++; return bytes; }, 'https://studio.example/studio.html').then(() => { saved = true; });
37
+ const received = receiveGameInStudio(ctx.studio, async value => {
38
+ imports++; assert.deepEqual(value, bytes);
39
+ await new Promise(resolve => { release = resolve; }); return 'project';
40
+ });
41
+ await tick();
42
+ assert.equal(saved, false);
43
+ const packet = ctx.traffic.find(item => item.direction === 'studio').data;
44
+ ctx.studioRef.postMessage(packet, ctx.studio.location.origin); // Duplicate delivery must not create a second project.
45
+ await tick(); assert.equal(imports, 1); release();
46
+ assert.equal(await received, 'project'); await sent;
47
+ assert.equal(getCount, 1); assert.equal(saved, true);
48
+ assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
49
+ assert.ok(!ctx.studio.location.hash.includes('bytes'));
50
+ });
51
+
52
+ test('unrelated windows, origins, and tokens cannot inject an archive or acknowledge a transfer', async () => {
53
+ const ctx = pair(), bytes = new Uint8Array([1]); let imports = 0;
54
+ const sent = send(ctx.game, () => bytes, 'https://studio.example/studio.html');
55
+ const token = new URLSearchParams(ctx.studio.location.hash.slice(1)).get('edit');
56
+ const valid = { source: ctx.gameRef, origin: 'null', data: { type: 'homegames-studio-game', token, bytes } };
57
+ const received = receiveGameInStudio(ctx.studio, () => { imports++; });
58
+ for (const patch of [{ source: {} }, { origin: 'https://evil.example' }, { data: { ...valid.data, token: randomUUID() } }]) {
59
+ ctx.studio.emit('message', { ...valid, ...patch });
60
+ }
61
+ assert.equal(imports, 0);
62
+ await received; await sent; assert.equal(imports, 1);
63
+ });
64
+
65
+ test('failed persistence reaches the sender and releases both sides', async () => {
66
+ const ctx = pair();
67
+ const sent = assert.rejects(send(ctx.game, () => new Uint8Array([1]), 'https://studio.example/studio.html'), /storage full/);
68
+ const received = assert.rejects(receiveGameInStudio(ctx.studio, () => { throw new Error('storage full'); }), /storage full/);
69
+ await Promise.all([sent, received]);
70
+ assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
71
+ });
72
+
73
+ test('popup blocking and archive failures keep a usable recovery error', async () => {
74
+ const blocked = pair(); blocked.game.open = () => null;
75
+ await assert.rejects(send(blocked.game, () => {}), /import its HTML or ZIP/);
76
+ assert.equal(blocked.game.listenerCount, 0);
77
+ const ctx = pair();
78
+ const sent = assert.rejects(send(ctx.game, () => { throw new Error('Copy failed'); }, 'https://studio.example/studio.html'), /Copy failed/);
79
+ const received = receiveGameInStudio(ctx.studio, () => {});
80
+ const caught = assert.rejects(received, /closed/);
81
+ await sent; ctx.studio.emit('pagehide'); await caught;
82
+ assert.equal(ctx.game.listenerCount + ctx.studio.listenerCount, 0);
83
+ });
84
+
85
+ test('timeout and closed tabs clean up pending transfers', async t => {
86
+ t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
87
+ const ctx = pair();
88
+ const sent = assert.rejects(send(ctx.game, () => {}, 'https://studio.example/studio.html'), /did not receive/);
89
+ t.mock.timers.tick(90000); await sent; assert.equal(ctx.game.listenerCount, 0);
90
+ const closed = pair();
91
+ const pending = assert.rejects(send(closed.game, () => {}, 'https://studio.example/studio.html'), /closed/);
92
+ closed.studioRef.closed = true; t.mock.timers.tick(500); await pending;
93
+ assert.equal(closed.game.listenerCount, 0);
94
+ });
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict');
3
+ const { thumbnailPath } = require('../portable-project');
4
+ const project = source => ({'homegame.json':JSON.stringify({entry:'index.js'}),'index.js':source,
5
+ 'assets/cover.png':new Uint8Array([137,80,78,71]),'unfinished.js':'class {'});
6
+
7
+ test('Studio reads declared local artwork without evaluating game code or requiring other files to compile', () => {
8
+ const files = project("globalThis.thumbnailCodeRan = true; const COVER = './assets/cover.png'; class Game { static metadata() { return {thumbnail:COVER}; } }");
9
+ assert.equal(thumbnailPath(files), 'assets/cover.png');
10
+ assert.equal(globalThis.thumbnailCodeRan, undefined);
11
+ assert.equal(thumbnailPath({...files,'index.js':new TextEncoder().encode(files['index.js'])}), 'assets/cover.png');
12
+ });
13
+
14
+ test('missing, unsafe, computed and unfinished thumbnail metadata falls back without executing it', () => {
15
+ for(const value of ["'https://example.com/cover.png'", "'../cover.png'", "'assets/missing.png'", '(globalThis.thumbnailCodeRan = true)']) {
16
+ assert.equal(thumbnailPath(project(`class Game { static metadata() { return {thumbnail:${value}}; } }`)), null);
17
+ }
18
+ assert.equal(thumbnailPath(project('class {')), null);
19
+ assert.equal(thumbnailPath({}), null);
20
+ assert.equal(globalThis.thumbnailCodeRan, undefined);
21
+ });