diff --git a/docs/framey-loader.md b/docs/framey-loader.md index e6dded6..918932c 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -53,6 +53,10 @@ live Decky settings persistence, or badges on Steam game pages. The last item has no supported injection target in the Frame Control panel. The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md) records exactly what was exercised. +Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4 +and Steam Status 1.2.3 archives, checks their API v2 manifests and frontend +bytes, and verifies they can be packed as Framey panels. It does not execute +their actual backend code or prove their frontend controls work in VR. The fake Frame integration test now writes a Decky setting through Framey's scoped backend endpoint, reads it in a new CEF request, updates the plugin, diff --git a/tests/e2e/test_framey.py b/tests/e2e/test_framey.py index efe0719..2d2bfaa 100644 --- a/tests/e2e/test_framey.py +++ b/tests/e2e/test_framey.py @@ -10,6 +10,7 @@ import threading import zipfile from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh +from framey_official import PLUGINS as OFFICIAL_PLUGINS, archive as official_archive require() # Skip the module outside the disposable fake Frame, like its peers. @@ -26,6 +27,10 @@ def archive(version): ARTIFACTS = {hashlib.sha256(a).hexdigest(): a for a in (archive('1.0.0'), archive('1.1.0'))} HASHES = list(ARTIFACTS) +for plugin in OFFICIAL_PLUGINS: + data = official_archive(plugin) + assert hashlib.sha256(data).hexdigest() == plugin['sha256'] + ARTIFACTS[plugin['sha256']] = data class StoreFixture(BaseHTTPRequestHandler): @@ -35,10 +40,15 @@ class StoreFixture(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/plugins': - body = json.dumps([{'id':123,'name':'Sample Panel','author':'Fixture', + records = [{'id':999,'name':'Sample Panel','author':'Fixture', 'description':'A harmless panel','tags':[], 'versions':[{'name':['1.0.0','1.1.0'][self.release], - 'hash':HASHES[self.release]}]}]).encode() + 'hash':HASHES[self.release]}]}] + records.extend({'id':plugin['id'], 'name':plugin['name'], 'author':'Official Decky store', + 'description':'Pinned official archive', 'tags':[], + 'versions':[{'name':plugin['version'], 'hash':plugin['sha256']}]} + for plugin in OFFICIAL_PLUGINS) + body = json.dumps(records).encode() content_type = 'application/json' elif self.path.startswith('/versions/') and self.path.endswith('.zip'): body = ARTIFACTS.get(self.path.split('/')[-1][:-4]) @@ -93,41 +103,66 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T self.assertEqual(status, 403) catalogue = ok('GET', '/api/decky/store')['plugins'] self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0])) - status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':123}) + status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999}) self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement. job = finished(ok('POST', '/api/decky/plugin', - {'action':'install','id':123,'acknowledged':True})) + {'action':'install','id':999,'acknowledged':True})) self.assertFalse(job.get('error'), job) self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0') self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js')) - status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'get_setting','args':[]}, + status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]}, headers={'X-Frame-UI':''}) self.assertEqual(status, 403) - status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'x;alert(1)','args':[]}) + status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]}) self.assertEqual(status, 400) self.assertEqual(ok('POST', '/api/framey/method', - {'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'dark') + {'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark') self.assertTrue(ok('POST', '/api/framey/method', - {'id':123,'method':'set_setting','args':['theme','light']})['result']) + {'id':999,'method':'set_setting','args':['theme','light']})['result']) self.assertEqual(ok('POST', '/api/framey/method', - {'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light') + {'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light') + StoreFixture.release = 1 job = finished(ok('POST', '/api/decky/plugin', - {'action':'update','id':123,'acknowledged':True})) + {'action':'update','id':999,'acknowledged':True})) self.assertFalse(job.get('error'), job) self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0') self.assertEqual(ok('POST', '/api/framey/method', - {'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light') + {'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light') job = finished(ok('POST', '/api/decky/plugin', - {'action':'remove','id':123,'acknowledged':True})) + {'action':'remove','id':999,'acknowledged':True})) self.assertFalse(job.get('error'), job) self.assertEqual(ok('GET', '/api/decky')['plugins'], []) self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky/plugins/sample-panel && echo yes || echo no').strip(), 'no') status, _, _ = api('POST', '/api/framey/method', - {'id':123,'method':'get_setting','args':['theme','dark']}) + {'id':999,'method':'get_setting','args':['theme','dark']}) self.assertEqual(status, 400) # No method calls while the plugin is removed. job = finished(ok('POST', '/api/decky/plugin', - {'action':'install','id':123,'acknowledged':True})) + {'action':'install','id':999,'acknowledged':True})) self.assertFalse(job.get('error'), job) self.assertEqual(ok('POST', '/api/framey/method', - {'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light') + {'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light') + + def test_two_pinned_official_api_v2_plugins_install_and_remove(self): + self.backend() + catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']} + for plugin in OFFICIAL_PLUGINS: + with self.subTest(plugin=plugin['name']): + self.assertEqual(catalog[plugin['id']]['sha256'], plugin['sha256']) + job = finished(ok('POST', '/api/decky/plugin', + {'action':'install','id':plugin['id'],'acknowledged':True})) + self.assertFalse(job.get('error'), job) + found = [item for item in ok('GET', '/api/decky')['plugins'] + if item['storeId'] == plugin['id']] + self.assertEqual(len(found), 1) + self.assertEqual(found[0]['version'], plugin['version']) + installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}" + with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle: + expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest() + self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected) + self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], 2) + job = finished(ok('POST', '/api/decky/plugin', + {'action':'remove','id':plugin['id'],'acknowledged':True})) + self.assertFalse(job.get('error'), job) + self.assertFalse(any(item['storeId'] == plugin['id'] + for item in ok('GET', '/api/decky')['plugins'])) diff --git a/tests/fixtures/framey-official/123.zip b/tests/fixtures/framey-official/123.zip new file mode 100644 index 0000000..310ae7c Binary files /dev/null and b/tests/fixtures/framey-official/123.zip differ diff --git a/tests/fixtures/framey-official/14.zip b/tests/fixtures/framey-official/14.zip new file mode 100644 index 0000000..0806fff Binary files /dev/null and b/tests/fixtures/framey-official/14.zip differ diff --git a/tests/fixtures/framey-official/README.md b/tests/fixtures/framey-official/README.md new file mode 100644 index 0000000..b73e6de --- /dev/null +++ b/tests/fixtures/framey-official/README.md @@ -0,0 +1,18 @@ +# Pinned Decky store test archives + +These are unmodified ZIPs downloaded from the official Decky store CDN on +2026-10-25. Each ZIP includes its upstream `LICENSE`. They are test data, not +plugins shipped or installed by Frame Control. The fake Frame tests serve them +from a local fixture so CI does not depend on the store being online. + +| File | Official plugin | Version | Store SHA-256 | +| --- | --- | --- | --- | +| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` | +| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` | + +The matching source projects are +[ProtonDB Badges](https://github.com/bschelst/protondb-decky) and +[Steam Status](https://github.com/bschelst/steamstatus-decky). +The test verifies the ZIP digest, archive policy, API v2 manifest, installed +frontend bytes, managed store receipt, and removal. It does **not** run their +Python backends or prove their JavaScript panels render in the VR shell. diff --git a/tests/framey_official.py b/tests/framey_official.py new file mode 100644 index 0000000..bdd5fb8 --- /dev/null +++ b/tests/framey_official.py @@ -0,0 +1,15 @@ +"""Pinned official Decky store archives used only in disposable test fixtures.""" +from pathlib import Path + + +ROOT = Path(__file__).parent / 'fixtures/framey-official' +PLUGINS = ( + {'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', + 'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'}, + {'id': 123, 'name': 'Steam Status', 'version': '1.2.3', + 'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'}, +) + + +def archive(plugin): + return (ROOT / f"{plugin['id']}.zip").read_bytes() diff --git a/tests/test_framey_official.py b/tests/test_framey_official.py new file mode 100644 index 0000000..0ccf5f3 --- /dev/null +++ b/tests/test_framey_official.py @@ -0,0 +1,45 @@ +"""The pinned store bundles remain valid API v2 Framey panel inputs.""" +import sandbox # noqa: F401 +import base64 +import hashlib +import json +from pathlib import Path +import re +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile +import io + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_decky_store +import framey_panel +from framey_official import PLUGINS, archive + + +class OfficialBundles(unittest.TestCase): + def test_pinned_store_archives_install_and_pack_into_framey_panel(self): + with tempfile.TemporaryDirectory() as root: + plugins = Path(root) + for plugin in PLUGINS: + with self.subTest(plugin=plugin['name']): + data = archive(plugin) + self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256']) + self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), + plugin['folder']) + with zipfile.ZipFile(io.BytesIO(data)) as z: + manifest = json.loads(z.read(plugin['folder'] + '/plugin.json')) + self.assertEqual(manifest['api_version'], 2) + z.extractall(plugins) + folder = plugins / plugin['folder'] + (folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']})) + with patch.object(framey_panel, 'PLUGINS', plugins): + page = framey_panel.panel(plugin['id']).decode() + encoded = re.search(r'', page) + self.assertIsNotNone(encoded) + self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes()) + + +if __name__ == '__main__': + unittest.main()