mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 12:01:48 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
829fd40750 | ||
|
|
b7988f2426 | ||
|
|
dd85015b28 | ||
|
|
8807b22023 |
No files matched your search
@@ -32,6 +32,9 @@ Frame Control now has an experimental [Framey panel host](framey-loader.md)
|
||||
for API v2 Decky frontend bundles. It renders common settings controls in the
|
||||
separate SteamVR dashboard panel, but Steam library and store page patches
|
||||
remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
|
||||
only official-store-owned plugins in its VR panel.
|
||||
|
||||
To test safely, use a separate Frame Control managed Decky install, verify the
|
||||
ZIP digest from the official store, install one plugin at a time, and inspect
|
||||
@@ -47,6 +50,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Decky API or UI feature | Framey behavior | Verified in |
|
||||
| --- | --- | --- |
|
||||
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
|
||||
@@ -76,6 +76,9 @@ operation after an error.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
Start with the [Framey settings sample](../examples/framey-settings/README.md)
|
||||
for a buildable Decky API v2 panel and Python settings backend.
|
||||
|
||||
Publish a normal [Decky store](https://plugins.deckbrew.xyz/) API v2 release
|
||||
with `plugin.json`, a single `dist/index.js` ESM frontend entry point, and a
|
||||
Python backend if needed. Give the frontend a `content` React element returned
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,37 @@
|
||||
# Framey settings sample
|
||||
|
||||
This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
|
||||
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
|
||||
and reads it back when the panel opens. The backend rejects other keys and
|
||||
values. The frontend also shows a local toast after a successful save.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
The committed `dist/index.js` is the built frontend. For a Decky release ZIP,
|
||||
put `plugin.json`, `main.py`, and `dist/index.js` inside one top-level plugin
|
||||
folder. Publish it through the official Decky store if you want Frame Control's
|
||||
managed installer to offer it. Framey deliberately opens only plugins that the
|
||||
official store installed and marked as owned; copying this example into the
|
||||
plugin directory does not enable its VR panel.
|
||||
|
||||
## Test and adapt
|
||||
|
||||
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
|
||||
repository root. It checks backend persistence and evaluates the built bundle
|
||||
against Framey's actual host script. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
The plugin needs no root access or system changes. Its settings remain after
|
||||
the managed plugin folder is removed, following Decky's settings behavior.
|
||||
For a production plugin, add migration and recovery handling for damaged
|
||||
settings files, and test every UI path in a worn headset.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
const manifest = {"name":"Framey Settings Sample"};
|
||||
const API_VERSION = 2;
|
||||
const internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;
|
||||
if (!internalAPIConnection) {
|
||||
throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');
|
||||
}
|
||||
let api;
|
||||
try {
|
||||
api = internalAPIConnection.connect(API_VERSION, manifest.name);
|
||||
}
|
||||
catch {
|
||||
api = internalAPIConnection.connect(1, manifest.name);
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);
|
||||
}
|
||||
if (api._version != API_VERSION) {
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
|
||||
}
|
||||
const callable = api.callable;
|
||||
const toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
return fn(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const getSetting = callable('get_setting');
|
||||
const setSetting = callable('set_setting');
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = SP_REACT.useState(false);
|
||||
const [busy, setBusy] = SP_REACT.useState(true);
|
||||
const [message, setMessage] = SP_REACT.useState('Loading setting…');
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
SP_REACT.useEffect(() => { void refresh(); }, []);
|
||||
async function change(value) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}));
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Small, unprivileged Decky backend for the Framey author example."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import decky
|
||||
|
||||
|
||||
def settings_file():
|
||||
# Decky provides this per-plugin path through its official Python module.
|
||||
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def get_setting(self, key: str, default=False):
|
||||
if key != 'enabled':
|
||||
raise ValueError('Unknown setting')
|
||||
try:
|
||||
stored = json.loads(settings_file().read_text())
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
return stored.get(key, default)
|
||||
|
||||
async def set_setting(self, key: str, value: bool):
|
||||
if key != 'enabled' or type(value) is not bool:
|
||||
raise ValueError('Expected an enabled boolean')
|
||||
target = settings_file()
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
|
||||
prefix='.settings-', delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
json.dump({'enabled': value}, output)
|
||||
output.write('\n')
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "framey-settings-sample",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c"
|
||||
},
|
||||
"dependencies": {
|
||||
"@decky/api": "^1.1.3",
|
||||
"@decky/ui": "^4.11.0",
|
||||
"react": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@decky/rollup": "^1.0.2",
|
||||
"@types/react": "^19.1.1",
|
||||
"rollup": "^4.53.3",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Framey Settings Sample",
|
||||
"author": "Frame Control contributors",
|
||||
"flags": [],
|
||||
"api_version": 2
|
||||
}
|
||||
Generated
+1239
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
import deckyPlugin from '@decky/rollup';
|
||||
|
||||
export default deckyPlugin({});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
|
||||
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [message, setMessage] = useState('Loading setting…');
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
} catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {void refresh();}, []);
|
||||
|
||||
async function change(value: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
|
||||
} catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <PanelSection title="Framey settings sample">
|
||||
<PanelSectionRow>
|
||||
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
|
||||
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
}));
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
|
||||
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js',import.meta.url),'utf8');
|
||||
assert.match(bundle,/SP_JSX\.jsx/);
|
||||
const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
|
||||
const plugin=window.sample();
|
||||
assert.equal(plugin.name,'Framey Settings Sample');
|
||||
assert.equal(typeof plugin.content.type,'function');
|
||||
const content=plugin.content.type();
|
||||
assert.equal(content.type,window.DFL.PanelSection);
|
||||
assert.equal(content.props.title,'Framey settings sample');
|
||||
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
|
||||
@@ -0,0 +1,50 @@
|
||||
"""The author example stores settings as a normal Decky user plugin."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.py'
|
||||
|
||||
|
||||
def backend(settings_dir):
|
||||
decky = types.ModuleType('decky')
|
||||
decky.decky_SETTINGS_DIR = settings_dir
|
||||
spec = importlib.util.spec_from_file_location('framey_sample', EXAMPLE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'decky': decky}):
|
||||
spec.loader.exec_module(module)
|
||||
return module.Plugin()
|
||||
|
||||
|
||||
class SamplePlugin(unittest.TestCase):
|
||||
def test_frontend_bundle_loads_in_framey_host(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent / 'page/framey_sample.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_setting_survives_backend_instance_restart(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
first = backend(folder)
|
||||
self.assertFalse(asyncio.run(first.get_setting('enabled', False)))
|
||||
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
|
||||
second = backend(folder)
|
||||
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
|
||||
self.assertTrue((Path(folder) / 'settings.json').is_file())
|
||||
|
||||
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
plugin = backend(folder)
|
||||
with self.assertRaises(ValueError): asyncio.run(plugin.get_setting('other'))
|
||||
with self.assertRaises(ValueError): asyncio.run(plugin.set_setting('enabled', 'yes'))
|
||||
self.assertFalse((Path(folder) / 'settings.json').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user