diff --git a/docs/vr-apks.md b/docs/vr-apks.md index 7f6adef..2cb03ab 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -140,10 +140,12 @@ refresh covers both. The API is `POST /api/android` with `POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a background job. Batch results retain per-item errors, and the CLIs exit nonzero if any failed. Apps and titles without complete artwork show **Add -artwork**, `list` prints the command, and when Frame Control lists them while -Steam is running it re-applies their art in the background (at most every -five minutes), for example for a title Steam registered after an install made -while it wasn't running. +artwork** and `list` prints the command. Only entries marked `art_pending` at +install (a title Steam registered after an install made while it wasn't +running) are backfilled automatically, when Frame Control lists them with +Steam running (at most every five minutes), and that backfill only fills +slots Steam has no art for: names, icons, flags and any art the user set are +kept. Older installs without the flag are refreshed only on request. Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build 20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by @@ -176,7 +178,8 @@ The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by stopping its own container and child process group. A lock refuses duplicate launches; a container still running while the lock is free was orphaned by a killed launcher and is stopped before the new launch. Lepton doesn't inherit the -lock. Removing an app or title still deletes its files when Steam isn't +lock; the launcher records Lepton's process group and a later launch ends a +recorded group still running this APK. Removing an app or title still deletes its files when Steam isn't running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact 64-bit game ID string. Frame Control's Stop additionally has a direct-container fallback. The stable instance ID and compatdata paths remain unchanged. diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 594a6a9..8f393e2 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -30,6 +30,20 @@ fi exec 9>"$DIR/launch.lock" flock -n 9 || { echo "Android app is already running" >&2; exit 1; } CONTAINER="lepton-steamlaunch-$SteamAppId" +# Lepton doesn't hold the lock, so a launcher SIGKILLed before Lepton made its +# container leaves a Lepton that nothing tracks: end that process group first. +# Only a group still running this app.apk, never an unrelated reused id. +PGID_FILE="$DIR/launch.pgid" +if [[ -f "$PGID_FILE" ]]; then + old="$(cat "$PGID_FILE")" + if [[ "$old" =~ ^[0-9]+$ ]] && ps -A -o pgid=,args= | awk -v g="$old" '$1 == g' | grep -qF -- "$DIR/app.apk"; then + echo "Stopping the previous launch (process group $old)" >&2 + kill -TERM -- "-$old" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- "-$old" 2>/dev/null || break; sleep 0.5; done + kill -KILL -- "-$old" 2>/dev/null || true + fi + rm -f "$PGID_FILE" +fi # Holding the lock means no launcher owns a running container: it was orphaned # (this script SIGKILLed), so stop it rather than refuse every later Play. if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then @@ -60,6 +74,7 @@ cleanup() { kill -KILL "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true fi + rm -f "$PGID_FILE" } trap cleanup EXIT trap 'exit 143' TERM @@ -68,6 +83,7 @@ trap 'exit 129' HUP # 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & child=$! +echo "$child" > "$PGID_FILE" rc=0 wait "$child" || rc=$? child="" diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 5e3a405..bf614e5 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -9,7 +9,7 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`. steam_shortcuts.py stop APPID steam_shortcuts.py remove APPID """ -import base64, json, os, re, socket, struct, sys, urllib.request +import base64, glob, json, os, re, socket, struct, sys, urllib.request DEVTOOLS = 'http://127.0.0.1:8080/json' @@ -198,9 +198,26 @@ def _render(plan, renderer, label, images): return {'paths': paths, 'warnings': list(result.get('warnings', []))} +# Steam's own file for each custom-art type in userdata/*/config/grid/. +GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''} + + +def custom_art(appid): + """The custom-art types this shortcut already has in Steam, for any local user.""" + found = set() + for kind, suffix in GRID_FILES.items(): + pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*') + if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)): + found.add(kind) + return found + + def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): + """options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched).""" options = options or {} category = options.get('category', 'Android') + fill = bool(options.get('fill_only')) + existing = custom_art(appid) if fill else set() if set(artwork) != set(ASSETS): raise ValueError('all five Steam artwork slots are required') images = [] @@ -214,18 +231,22 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): data = f.read(MAX_ART + 1) if len(data) > MAX_ART: raise ValueError('artwork is too large') - if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. + # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. + if slot != 'icon' and ASSETS[slot] not in existing: images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) return evaluate(f'''(async () => {{ - const id = {int(appid)}, warnings = []; - SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); - if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); - if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); - if (typeof SteamClient.Apps.SetShortcutSortAs === "function") - SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); - SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); - // null (devkit titles): leave the VR flag as Steam registered it. - if ({json.dumps(vr)} !== null) {{ + const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)}; + const overview = appStore.GetAppOverviewByAppID(id); + if (!fill) {{ + SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); + if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); + if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if (typeof SteamClient.Apps.SetShortcutSortAs === "function") + SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); + }} + if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); + // null (devkit titles) or filling gaps: leave the VR flag as Steam has it. + if ({json.dumps(vr)} !== null && !fill) {{ if (typeof SteamClient.Apps.SetShortcutIsVR === "function") SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); else warnings.push("Steam VR shortcut flag API unavailable"); @@ -233,7 +254,7 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ for (const [type, ext, data] of {json.dumps(images)}) {{ // Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win. - if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") + if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}} await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); }} diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index cf0905e..e7716b9 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -38,6 +38,7 @@ function build(steam) { }); const shortcutOverview = s => ({ appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid, + icon_data: s.icon ? 'fake-icon' : undefined, local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 }, }); const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)]; diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index fdee8cb..40428f2 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts) @unittest.skipIf(os.name == 'nt', 'POSIX launcher') class LauncherTests(unittest.TestCase): - def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False, stale=None): with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) app = d / 'Applications/Android/org.test.app' @@ -60,6 +60,13 @@ class LauncherTests(unittest.TestCase): saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' saved.parent.mkdir(parents=True) saved.write_text('saved game') + previous = None + if stale: + # A Lepton left by a SIGKILLed launcher (its own session), or an unrelated reused id. + args = [sys.executable, '-c', 'import time; time.sleep(30)'] + previous = subprocess.Popen(args + ([str(app / 'app.apk')] if stale == 'lepton' else ['other']), + start_new_session=True) + (app / 'launch.pgid').write_text(str(previous.pid)) proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: if blocked: @@ -73,6 +80,10 @@ class LauncherTests(unittest.TestCase): while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline: time.sleep(.02) self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton') + if previous: + self.assertEqual(previous.poll() is not None, stale == 'lepton') + self.assertEqual((app / 'launch.pgid').read_text().strip(), + (d / 'started').read_text().strip()) # the new group is recorded self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') if terminate: self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') @@ -84,7 +95,12 @@ class LauncherTests(unittest.TestCase): self.assertEqual(proc.returncode, 128 + sig if terminate else 23) self.assertEqual(saved.read_text(), 'saved game') self.assertTrue((app / 'app.apk').exists()) + self.assertFalse((app / 'launch.pgid').exists()) finally: + if previous and previous.poll() is None: + previous.kill() + if previous: + previous.wait() if proc.poll() is None: proc.kill() proc.communicate() @@ -105,6 +121,12 @@ class LauncherTests(unittest.TestCase): def test_duplicate_launch_leaves_existing_session_alone(self): self.exercise(False, blocked='LOCKED') + def test_previous_launch_left_by_sigkill_is_reaped_first(self): + self.exercise(True, stale='lepton') + + def test_reused_process_group_id_is_left_alone(self): + self.exercise(True, stale='other') + def test_orphaned_container_is_stopped_and_play_proceeds(self): # Container running but the lock free: its launcher was SIGKILLed. self.exercise(False, orphan=True) @@ -167,6 +189,57 @@ class ArtworkTests(unittest.TestCase): with self.assertRaises(SourceError): art.fetch('file:///etc/passwd') + def trickle(self, head, seconds): + # A server that answers one byte every 20 ms, over a socketpair standing in for the network. + import socket + import threading + from apk_sources import _images + client, server = socket.socketpair() + def serve(): + try: + server.recv(65536) + for byte in head + b'x' * 1000: + server.sendall(bytes([byte])) + time.sleep(0.02) + except OSError: + pass + finally: + server.close() + threading.Thread(target=serve, daemon=True).start() + public = [(2, 1, 6, '', ('93.184.216.34', 80))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('http://example.org/a.png', deadline=start + seconds) + return time.monotonic() - start + + def test_deadline_bounds_trickling_headers_and_body(self): + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4) + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish + + def test_deadline_covers_name_resolution(self): + import threading + from apk_sources import _images + gate = threading.Event() + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.1) + self.assertLess(time.monotonic() - start, 0.4) + gate.set() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=time.monotonic() - 1) + + def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self): + import frame_steamgriddb as sgdb + from apk_sources import _images + with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get: + self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1]) + self.assertEqual(get.call_args.kwargs['redirects'], 0) + self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret') + self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5) + def test_supplied_jpeg(self): data = (FIXTURES / 'icon.jpg').read_bytes() self.assertEqual(art.image_type(data), 'jpg') @@ -296,6 +369,44 @@ class InstallTests(unittest.TestCase): '.local/share/Steam/steamapps/shadercache/2800000001') self.assertIn('Steam client running', result['library_warnings'][0]) + def test_remove_waits_for_refresh_and_is_never_undone(self): + import threading + state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}} + in_refresh, release, added = threading.Event(), threading.Event(), [] + def meta(pkg): + if not state['meta']: + raise android.FrameError(pkg + ' is not installed') + return dict(state['meta']) + def ssh(cmd, input=None, **kw): + if cmd.startswith('rm -rf Applications/Android/org.test.vr'): + state['meta'] = None + return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos' + def tool(*args, **kw): + if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']]) + if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}' + if args[0] == 'add': added.append(args); return '99' + if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}}) + return '{"warnings": []}' + def prepare(*args, **kw): + in_refresh.set(); release.wait(5) + return self.images, [] + with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \ + patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \ + patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \ + patch.object(android.frame_artwork, 'prepare', side_effect=prepare): + refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True}) + refresh.start() + self.assertTrue(in_refresh.wait(5)) + remove = threading.Thread(target=android.remove, args=('org.test.vr',)) + remove.start() + remove.join(0.3) + self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing') + release.set(); refresh.join(5); remove.join(5) + self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set()) + with self.assertRaisesRegex(android.FrameError, 'not installed'): + android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal + self.assertEqual(added, []) + def test_stop_requests_steam_and_has_container_fallback(self): with patch.object(android, '_meta_or_fail', return_value=self.existing), \ patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \ @@ -332,7 +443,7 @@ class SteamAPITests(unittest.TestCase): with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'}) js = evaluate.call_args.args[0] - self.assertIn('if (null !== null)', js) + self.assertIn('if (null !== null && !fill)', js) self.assertIn('const wanted = ["Sideloaded"]', js) with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '', json.dumps(slots), '{}']), \ @@ -411,6 +522,36 @@ class SteamContextTests(unittest.TestCase): self.assertEqual(steam['shortcuts'], []) self.assertEqual(steam['collections'][0]['apps'], [999]) + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_fill_only_keeps_customised_name_icon_and_art(self): + custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}} + steam = {'apps': [], 'compat_tools': {}, 'collections': [], + 'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]} + def evaluate(expression, timeout=20): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with tempfile.TemporaryDirectory() as home: + grid = Path(home, '.local/share/Steam/userdata/1/config/grid') + grid.mkdir(parents=True) + (grid / '42p.png').write_bytes(b'mine') + (grid / '42_hero.jpg').write_bytes(b'mine') + with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate): + self.assertEqual(shortcuts.custom_art(42), {0, 1}) + shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}, + {'category': 'Sideloaded', 'fill_only': True}) + s = steam['shortcuts'][0] + self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True)) + self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom) + self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'}) + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') def test_remove_without_collections_or_artwork_api_still_removes(self): steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"', diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py index a8c59c6..8671204 100644 --- a/tests/test_library_entrypoints.py +++ b/tests/test_library_entrypoints.py @@ -92,21 +92,34 @@ class EntryPoints(unittest.TestCase): server.titles({'action':'refresh-art', 'id':'G'}) title_refresh.assert_called_with('G') - def test_backfill_applies_missing_art_once_steam_answers(self): + def test_backfill_fills_only_entries_pending_since_install(self): import threading ran = threading.Event() with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \ - patch.object(titles, 'refresh_art', side_effect=lambda gid: ran.set()) as title_refresh: - apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True}, - {'package':'org.c.x','art_missing':False}] + patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh: + apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True}, + {'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending with contextlib.redirect_stderr(io.StringIO()): - self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}])) + self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}])) self.assertTrue(ran.wait(5)) self.assertFalse(server.backfill_art(apps=apps)) # throttled self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)]) - title_refresh.assert_called_once_with('G') - self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}])) + self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list)) + title_refresh.assert_called_once_with('G', fill_only=True) + + def test_upgrade_leaves_legacy_customised_titles_alone(self): + # A title installed before art_pending existed, whose art the user has customised in Steam. + legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'], + 'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}] + with patch.object(titles, 'ssh', return_value=json.dumps(legacy)): + listed = titles.list_titles() + self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False)) + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh: + self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed)) + refresh.assert_not_called(); app_refresh.assert_not_called() + self.api.assert_not_called() def test_art_missing_flags(self): self.assertTrue(android.art_missing({'artwork': {}})) diff --git a/tests/test_server.py b/tests/test_server.py index 47334c2..a1f9626 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -234,7 +234,6 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) -@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script") class ArtworkSettings(unittest.TestCase): """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" @@ -285,6 +284,7 @@ class ArtworkSettings(unittest.TestCase): self.assertLess(page.index("async function api("), page.index('