diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d9bd16b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.py text eol=lf diff --git a/.github/workflows/build_plugin.yml b/.github/workflows/build_plugin.yml index 4e55ad8..0911642 100644 --- a/.github/workflows/build_plugin.yml +++ b/.github/workflows/build_plugin.yml @@ -1,54 +1,57 @@ name: Plugin build on: - workflow_dispatch: # Manual trigger from GitHub UI + pull_request: + workflow_dispatch: inputs: duetwebcontrol_ref: - description: 'Enter the tag, branch or SHA of the Duet3D/DuetWebControl repository to use.' + description: DuetWebControl tag, branch or SHA (3.7 or newer) required: true + default: v3.7.0 + +permissions: + contents: read jobs: - build: + backend-test: runs-on: ubuntu-latest + strategy: + matrix: + dsf_python_ref: + - 23308a2bdb90d34f6904541f42b008755bbae072 # 3.6 + - 9fbeb14607a0ef82bfc1180239858fa3b08c03ce # 3.7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - run: python -m pip install "git+https://github.com/Duet3D/dsf-python.git@${{ matrix.dsf_python_ref }}" + - run: python -m unittest discover -s tests -v + build: + runs-on: ubuntu-latest steps: - - name: Checkout current repository - uses: actions/checkout@v4 - with: + - uses: actions/checkout@v4 + with: path: plugin - - - name: Checkout DuetWebControl repository - uses: actions/checkout@v4 - with: - repository: Duet3D/DuetWebControl - ref: ${{ github.event.inputs.duetwebcontrol_ref }} - path: DuetWebControl - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: latest - - - name: Install dependencies in DuetWebControl - working-directory: DuetWebControl - run: npm install - - - name: Build plugin - working-directory: DuetWebControl - run: npm run build-plugin ../plugin/src - - - name: Rename plugin zip file - id: rename-zip - working-directory: DuetWebControl - run: | - zipfile=$(ls dist/ExecOnMcode-*.zip) - base=$(basename "$zipfile" .zip) - newname="${base}-${{ github.event.inputs.duetwebcontrol_ref }}.zip" - mv "$zipfile" "$newname" - echo "zipname=$(basename $newname .zip)" >> "$GITHUB_OUTPUT" - - - name: Upload plugin archive - uses: actions/upload-artifact@v4 - with: - name: ExecOnMcode_toUnzip - path: DuetWebControl/${{ steps.rename-zip.outputs.zipname }}.zip + - uses: actions/checkout@v4 + with: + repository: Duet3D/DuetWebControl + ref: ${{ inputs.duetwebcontrol_ref || 'v3.7.0' }} + path: DuetWebControl + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Test frontend command handling + working-directory: plugin + run: npm test + - name: Install DWC build dependencies + working-directory: DuetWebControl + run: npm ci --no-audit --no-fund + - name: Build and package plugin + working-directory: DuetWebControl + run: node scripts/build-plugin-pkg.js ../plugin/src + - uses: actions/upload-artifact@v4 + with: + name: ExecOnMcode-3.7 + path: plugin/src/ExecOnMcode-*.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed37e01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +src/dist/ +src/pkg/ +src/*.zip diff --git a/MIGRATION-3.7.md b/MIGRATION-3.7.md new file mode 100644 index 0000000..7bffc8a --- /dev/null +++ b/MIGRATION-3.7.md @@ -0,0 +1,60 @@ +# DWC / DSF 3.7 migration + +This branch targets DuetWebControl 3.7.0 and DuetSoftwareFramework 3.7.0. +The DWC frontend requires Vue 3, Vuetify 4, Pinia and the Vite plugin loader. +The backend supports both dsf-python 3.6 (IPC 12) and 3.7 (IPC 13), because a +DSF 3.7 installation can still carry the older Python package. + +## Changes + +- Port route registration and the settings UI to the DWC 3.7 plugin API. +- Read/write configuration through standard DWC download/upload actions, + replacing the custom HTTP listener. Opening the panel only reads the file. +- After a successful save, send the existing `M1200` filter-reload code. + Configured commands are not executed by the editor. +- Handle both `resolve_path()` return forms, enum naming conventions and + `majorNumber` / `major_number` without changing the installed Python package. +- Preserve the configuration path and custom fields. Editing uses a copy so + cancelling an edit cannot mutate the live table. +- Validate duplicate/reserved M-codes and allocate free codes from unsorted lists. +- Enforce LF line endings for executable Python scripts on Windows checkouts. +- Update the packaging workflow to the DWC 3.7 build script. + +The custom HTTP endpoint implementation is removed; configuration clients should +use the standard DSF/DWC file APIs for `0:/sys/ExecOnMcode/ExecOnMcode.json` and +reload filters with `M1200` after saving. The configuration file is not part of +the installation payload, so upgrades preserve existing commands. + +## Build and test + +With DWC 3.7.0 checked out at `/path/to/DuetWebControl` and its npm dependencies +installed, run from this repository: + +```sh +npm test +python -m pip install -r requirements-test.txt +python -m unittest discover -s tests -v +node /path/to/DuetWebControl/scripts/build-plugin-pkg.js src +``` + +Node.js 22.18+ or 24 LTS is recommended; the Python tests support Python 3.11+. +The ZIP is written to `src/ExecOnMcode-.zip`. Both DWC source type checking +and Vite bundling are performed by the build script. CI runs the backend tests +against pinned revisions of the official dsf-python 3.6 and 3.7 clients. + +Tests cover configuration loading/defaults, preservation of extra fields, +validation, save/reload ordering, upload/reload failures, compatibility adapters, +and interception with subprocess execution mocked out. + +## Hardware validation and limits + +The rc.5 migration was installed on a DSF/DWC 3.7.0 system with dsf-python 3.6.0. +This rc.6 proposal additionally extracts the configuration transport for isolated +testing and removes the unused custom HTTP implementation; those final changes +were checked locally and have not been reinstalled on the live system. +The backend remained running, the frontend displayed the existing configuration, +and the configuration bytes were unchanged after installation. No configured +command or G-code was executed, and no save/delete operation was performed on the +live configuration. End-to-end live save/reload and actual command execution +therefore remain for maintainer validation. No firmware or system packages were +updated during this validation. diff --git a/README.md b/README.md index f05758b..336f986 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # DSF_ExecOnMcode_Plugin +This branch supports **DuetWebControl / DuetSoftwareFramework 3.7**. +See [MIGRATION-3.7.md](MIGRATION-3.7.md) for API changes, build/test instructions +and the scope of hardware validation. + ![ExecOnMcode](https://user-images.githubusercontent.com/974748/212554271-af1bf32e-a8fa-4a3b-988b-4c76a5a31052.png) ## Description @@ -15,12 +19,12 @@ Command arguments can be passed from G-Code parameters, e.g: when issuing `Mxxx ## Installation -1. Go on the `Plugins` Tab of Duet Web Control. -2. Select `External plugins`. -3. Click on *Upload System Files* and select the plugin zip archive. -4. Follow the on-screen instructions to install the plugin. -5. Click on the `Start` button to start the plugin. -6. *ExecOnMcode* entry should appears under the *Settings* tab of DWC. +1. Open the **Plugins** page in Duet Web Control 3.7. +2. Select **Install plugin**, choose the plugin ZIP and follow the installation steps. +3. Start the plugin. Open **ExecOnMcode** under **Preferences** to edit commands. + +Configuration is stored at `0:/sys/ExecOnMcode/ExecOnMcode.json`. Opening the +panel only reads it; saving uploads the file and sends `M1200` to reload filters. ## Limitations diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee471f3 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "exec-on-mcode-migration", + "private": true, + "type": "module", + "scripts": { + "test": "node --experimental-strip-types --test tests/*.test.mjs" + } +} diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..a149308 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +dsf-python @ git+https://github.com/Duet3D/dsf-python.git@9fbeb14607a0ef82bfc1180239858fa3b08c03ce diff --git a/src/dsf/compat.py b/src/dsf/compat.py new file mode 100644 index 0000000..b309309 --- /dev/null +++ b/src/dsf/compat.py @@ -0,0 +1,29 @@ +"""The DSF 3.7 server is also deployed with older dsf-python package revisions.""" +from dsf.commands.code import CodeType +from dsf.object_model.messages import MessageType +from dsf.object_model.state.log_level import LogLevel + + +def enum_value(enum, current, legacy): + # Do not evaluate the fallback eagerly: some releases only provide one name. + if hasattr(enum, current): + return getattr(enum, current) + return getattr(enum, legacy) + + +SUCCESS = enum_value(MessageType, "SUCCESS", "Success") +ERROR = enum_value(MessageType, "ERROR", "Error") +INFO = enum_value(LogLevel, "INFO", "Info") +WARN = enum_value(LogLevel, "WARN", "Warn") +MCODE = enum_value(CodeType, "MCODE", "MCode") + + +def resolved_path(result): + """New clients return a string; earlier clients wrap it in a response.""" + return result if isinstance(result, str) else result.result + + +def major_number(code): + if hasattr(code, "major_number"): + return code.major_number + return code.majorNumber diff --git a/src/dsf/execOnMcode.py b/src/dsf/execOnMcode.py index 9da8fb7..29a66f0 100644 --- a/src/dsf/execOnMcode.py +++ b/src/dsf/execOnMcode.py @@ -1,17 +1,15 @@ #!/usr/bin/python3 import json +import dsf import subprocess import traceback from pathlib import Path -from dsf.commands.code import CodeType from dsf.connections import CommandConnection, InterceptConnection, InterceptionMode -from dsf.object_model.messages import MessageType -from dsf.object_model.state.log_level import LogLevel -from http_endpoints import custom_http_endpoints from MCodeAction import MCodeAction +from compat import SUCCESS, ERROR, INFO, WARN, MCODE, resolved_path, major_number DEFAULT_FILTERS = ["M1200"] @@ -34,7 +32,7 @@ def __update_interception_filters(intercept_connection): actions = get_actions_from_config() filters = __get_filters_from_actions(actions) msg = f"{PLUGIN_NAME}: Interception filters successfully updated" - intercept_connection.resolve_code(MessageType.Success, msg) + intercept_connection.resolve_code(SUCCESS, msg) intercept_connection.close() intercept_connection = InterceptConnection(InterceptionMode.PRE, filters=filters) intercept_connection.connect() @@ -68,11 +66,11 @@ def __do_action_for_code(intercept_connection, actions, code): # Resolve the received code and return result if error_msg: - intercept_connection.resolve_code(MessageType.Error, error_msg) + intercept_connection.resolve_code(ERROR, error_msg) else: # Display the command output if not explicitly requested to be hidden msg = f"[{PLUGIN_NAME}]: {out.stdout}" if out and out.stdout and not action.cmd_capture_output else None - intercept_connection.resolve_code(MessageType.Success, msg) + intercept_connection.resolve_code(SUCCESS, msg) def intercept_mcodes(actions): @@ -86,11 +84,11 @@ def intercept_mcodes(actions): code = intercept_connection.receive_code() # Check for the type of the code - if code.type != CodeType.MCode: + if code.type != MCODE: intercept_connection.ignore_code() continue - if code.majorNumber == 1200: # Update interception filters + if major_number(code) == 1200: # Update interception filters intercept_connection, filters, actions = __update_interception_filters(intercept_connection) elif code.short_str() in filters: # Do action from JSON file __do_action_for_code(intercept_connection, actions, code) @@ -103,7 +101,7 @@ def intercept_mcodes(actions): intercept_connection.close() -def write_message(msg, msgType=MessageType.Success, loglvl=LogLevel.Info): +def write_message(msg, msgType=SUCCESS, loglvl=INFO): cmd_conn.write_message(msgType, f"{PLUGIN_NAME}: {msg}", True, loglvl) @@ -111,7 +109,7 @@ def get_actions_from_config(): actions = [] # Use DSF API to get the physical path to the configuration file res = cmd_conn.resolve_path(f"0:/sys/{PLUGIN_NAME}/{PLUGIN_NAME}.json") - filter_filepath = Path(res.result) + filter_filepath = Path(resolved_path(res)) if not filter_filepath.is_file(): # Create missing parent directories filter_filepath.parent.mkdir(parents=True, exist_ok=True) @@ -142,29 +140,26 @@ def get_actions_from_config(): json_filter = json.load(fp) except json.decoder.JSONDecodeError as e: json_filter = [] - write_message(e, MessageType.Error, LogLevel.Warn) + write_message(e, ERROR, WARN) for action in json_filter: if action['cmd_code'] in DEFAULT_FILTERS: write_message( f"{action['cmd_code']} is a reserved filter and thus it can't be used.", - MessageType.Error, - LogLevel.Warn) + ERROR, + WARN) continue try: actions.append(MCodeAction(action)) except KeyError as e: - write_message(e, MessageType.Error, LogLevel.Warn) + write_message(e, ERROR, WARN) return actions if __name__ == "__main__": - endpoints = [] cmd_conn = CommandConnection() try: cmd_conn.connect() - endpoints = custom_http_endpoints(cmd_conn) + print(f"ExecOnMcode: dsf-python {getattr(dsf, '__version__', 'unknown')}, protocol {dsf.PROTOCOL_VERSION}", flush=True) intercept_mcodes(get_actions_from_config()) finally: - for endpoint in endpoints: - endpoint.close() cmd_conn.close() diff --git a/src/dsf/http_endpoints.py b/src/dsf/http_endpoints.py deleted file mode 100644 index 1d23b77..0000000 --- a/src/dsf/http_endpoints.py +++ /dev/null @@ -1,64 +0,0 @@ -import json - -from dsf.connections import CommandConnection -from dsf.http import HttpEndpointConnection, HttpEndpointType - -PLUGIN_NAME = "ExecOnMcode" -filter_filepath = "" - - -async def getCmdList_handler(http_endpoint_connection: HttpEndpointConnection): - await http_endpoint_connection.read_request() - - try: - with open(filter_filepath) as fp: - file_data = json.load(fp) - except FileNotFoundError: - file_data = [] - except json.decoder.JSONDecodeError: - file_data = [] - - json_data = {'data': file_data} - await http_endpoint_connection.send_response(200, json.dumps(json_data)) - http_endpoint_connection.close() - - -async def saveCmdList_handler(http_endpoint_connection: HttpEndpointConnection): - request = await http_endpoint_connection.read_request() - # Write the commands list to the JSON file - with open(filter_filepath, 'w') as fp: - # TODO: Make sure the command code is Mxxx before to save it / Sanitize the data - fp.write(json.dumps(json.loads(request.body), indent=4)) - - response_data = {'success': True} - await http_endpoint_connection.send_response(200, json.dumps(response_data)) - http_endpoint_connection.close() - - cmd_conn = CommandConnection() - cmd_conn.connect() - try: - # Send M1200 to update interception filters - cmd_conn.perform_simple_code('M1200') - finally: - cmd_conn.close() - - -def custom_http_endpoints(cmd_conn): - endpoints = [] - - # Use DSF API to get the physical path to the configuration file - res = cmd_conn.resolve_path(f"0:/sys/{PLUGIN_NAME}/{PLUGIN_NAME}.json") - global filter_filepath - filter_filepath = res.result if res else None - - # Setup the endpoint and register the handler to reply on GET requests to read the commands list - endpoint = cmd_conn.add_http_endpoint(HttpEndpointType.GET, PLUGIN_NAME, "getCmdList") - endpoint.set_endpoint_handler(getCmdList_handler) - endpoints.append(endpoint) - - # Setup the endpoint and register the handler to reply on POST requests to save the commands list - endpoint = cmd_conn.add_http_endpoint(HttpEndpointType.POST, PLUGIN_NAME, "saveCmdList") - endpoint.set_endpoint_handler(saveCmdList_handler) - endpoints.append(endpoint) - - return endpoints diff --git a/src/plugin.json b/src/plugin.json index 89f4900..5530f59 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -2,12 +2,12 @@ "id": "ExecOnMcode", "name": "Exec On M-Code", "author": "Loïc GRENON", - "version": "0.7", + "version": "0.8.0-rc.6", "license": "GPL-3.0-or-later", "homepage": "https://github.com/LoicGRENON/DSF_ExecOnMcode_Plugin", - "dwcVersion": "auto-major", + "dwcVersion": "3.7.0", "sbcRequired": true, - "sbcDsfVersion": "auto-major", + "sbcDsfVersion": "3.7.0", "sbcExecutable": "execOnMcode.py", "sbcOutputRedirected": true, "sbcPermissions": [ @@ -20,6 +20,8 @@ "networkAccess", "webcamAccess", "gpioAccess" - ], - "sbcPackageDependencies": ["python3-dsf-python"] -} \ No newline at end of file + ], + "sbcPackageDependencies": [ + "python3-dsf-python" + ] +} diff --git a/src/src/ExecOnMcode.vue b/src/src/ExecOnMcode.vue index cc2a5d1..472a6ee 100644 --- a/src/src/ExecOnMcode.vue +++ b/src/src/ExecOnMcode.vue @@ -1,242 +1,134 @@ - \ No newline at end of file +function errorMessage(value: unknown): string { + return value instanceof Error ? value.message : String(value); +} + +const transport = { + read: () => machine.download({ filename: configPath, type: "json" }, false, false, false), + write: (content: Blob) => machine.upload({ filename: configPath, content }, false, false), + reload: () => machine.sendCode("M1200") +}; + +async function reloadCommands() { + loading.value = true; + error.value = ""; + try { + commands.value = await readCommands(transport); + } catch (e) { + error.value = `Could not load the command configuration. ${errorMessage(e)}`; + } finally { loading.value = false; } +} + +function newCommand() { + editCommand({ ...blankCommand(), cmd_code: nextAvailableCode(commands.value) }, true); +} + +function editCommand(command: Command, isNew = false) { + editingCode.value = isNew ? null : command.cmd_code; + // Editing a copy keeps Cancel from mutating the live command list. + editItem.value = { ...command }; + editError.value = ""; + editDialog.value = true; +} + +async function persist(next: Command[]) { + saving.value = true; + try { + await writeCommands(transport, next); + commands.value = next; + } finally { saving.value = false; } +} + +async function saveCommand() { + const command = { ...editItem.value, cmd_code: editItem.value.cmd_code.trim().toUpperCase(), cmd_timeout: Number(editItem.value.cmd_timeout) }; + editError.value = validateCommand(command, commands.value, editingCode.value) ?? ""; + if (editError.value) return; + const next = editingCode.value === null ? [...commands.value, command] + : commands.value.map(item => item.cmd_code === editingCode.value ? command : item); + try { + await persist(next); + editDialog.value = false; + } catch (e) { editError.value = errorMessage(e); } +} + +async function deleteCommand() { + if (deleteCode.value === null) return; + error.value = ""; + try { await persist(commands.value.filter(item => item.cmd_code !== deleteCode.value)); } + catch (e) { error.value = errorMessage(e); } + finally { deleteCode.value = null; } +} + +onMounted(reloadCommands); + diff --git a/src/src/commands.ts b/src/src/commands.ts new file mode 100644 index 0000000..fb43eb8 --- /dev/null +++ b/src/src/commands.ts @@ -0,0 +1,30 @@ +export interface Command { + cmd_code: string; + cmd_name: string; + cmd_command: string; + cmd_enabled: boolean; + cmd_timeout: number; + cmd_capture_output: boolean; + [key: string]: unknown; +} + +export function nextAvailableCode(commands: Command[]): string { + const used = new Set(commands.map(command => command.cmd_code.toUpperCase())); + let number = 1201; + while (used.has(`M${number}`)) number++; + return `M${number}`; +} + +export function validateCommand(command: Command, commands: Command[], editingCode: string | null): string | null { + if (!/^M\d+(?:\.\d+)?$/.test(command.cmd_code) || /^M0*1200(?:\.|$)/.test(command.cmd_code)) { + return "Enter an M-Code; M1200 is reserved for reloading the plugin."; + } + if (commands.some(item => item.cmd_code.toUpperCase() === command.cmd_code && item.cmd_code !== editingCode)) { + return "This M-Code is already configured."; + } + if (!command.cmd_name.trim() || !command.cmd_command.trim()) return "Name and command are required."; + if (!Number.isInteger(command.cmd_timeout) || command.cmd_timeout < 0) { + return "Timeout must be a whole number of seconds (0 means no timeout)."; + } + return null; +} diff --git a/src/src/configuration.ts b/src/src/configuration.ts new file mode 100644 index 0000000..1175bac --- /dev/null +++ b/src/src/configuration.ts @@ -0,0 +1,30 @@ +import type { Command } from "./commands"; + +export const configPath = "0:/sys/ExecOnMcode/ExecOnMcode.json"; + +export interface ConfigurationTransport { + read(): Promise; + write(content: Blob): Promise; + reload(): Promise; +} + +export async function readCommands(transport: ConfigurationTransport): Promise { + const response = await transport.read(); + if (!Array.isArray(response)) throw new Error("The plugin configuration is not a command list."); + return response.map(item => { + if (item === null || typeof item !== "object" || typeof item.cmd_code !== "string" || typeof item.cmd_command !== "string") { + throw new Error("The plugin configuration contains an invalid command."); + } + // Match MCodeAction defaults for fields omitted by hand-written configurations. + return { + cmd_name: "", cmd_enabled: true, cmd_timeout: 30, cmd_capture_output: false, + ...item + }; + }); +} + +export async function writeCommands(transport: ConfigurationTransport, commands: Command[]): Promise { + await transport.write(new Blob([JSON.stringify(commands, null, 4)], { type: "application/json" })); + // Reload filters only after a successful upload; never execute a configured command. + await transport.reload(); +} diff --git a/src/src/index.js b/src/src/index.js index 3d70392..e6ab5c0 100644 --- a/src/src/index.js +++ b/src/src/index.js @@ -1,14 +1,9 @@ -'use strict' +import { registerRoute } from "@/plugins"; +import ExecOnMcode from "./ExecOnMcode.vue"; -import { registerRoute } from '@/routes' -import ExecOnMcode from './ExecOnMcode.vue' -// Register a route via Settings -> ExecOnMcode registerRoute(ExecOnMcode, { - Settings: { - ExecOnMcode: { - icon: 'mdi-powershell', - caption: 'ExecOnMcode', - path: '/ExecOnMcode' - } - } -}); \ No newline at end of file + Preferences: { ExecOnMcode: { + icon: "mdi-powershell", caption: "ExecOnMcode", translated: true, + path: "/ExecOnMcode" + } } +}); diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs new file mode 100644 index 0000000..7258793 --- /dev/null +++ b/tests/commands.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { nextAvailableCode, validateCommand } from '../src/src/commands.ts'; + +const command = code => ({ cmd_code: code, cmd_name: 'Example', cmd_command: 'echo example', cmd_timeout: 30, cmd_enabled: true, cmd_capture_output: false }); + +test('allocates the first free M-code even when the list is unsorted', () => { + assert.equal(nextAvailableCode([command('M1203'), command('M1201')]), 'M1202'); +}); +test('rejects duplicate codes when adding and renaming', () => { + const commands = [command('M1201'), command('M1202')]; + assert.ok(validateCommand(command('M1201'), commands, null)); + assert.ok(validateCommand(command('M1201'), commands, 'M1202')); + assert.equal(validateCommand(command('M1201'), commands, 'M1201'), null); +}); +test('protects the reload code and rejects non-M codes', () => { + for (const code of ['M1200', 'M1200.1', 'M01200', 'G28', 'M12\nM999', '']) assert.ok(validateCommand(command(code), [], null)); +}); +test('allows zero timeout and rejects invalid or negative timeouts', () => { + assert.equal(validateCommand({ ...command('M1201'), cmd_timeout: 0 }, [], null), null); + for (const timeout of [-1, NaN, Infinity, 1.5]) assert.ok(validateCommand({ ...command('M1201'), cmd_timeout: timeout }, [], null)); +}); diff --git a/tests/configuration.test.mjs b/tests/configuration.test.mjs new file mode 100644 index 0000000..678eb4b --- /dev/null +++ b/tests/configuration.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readCommands, writeCommands } from '../src/src/configuration.ts'; + +test('reading configuration performs no writes or reloads', async () => { + const result = await readCommands({ + read: async () => [{ cmd_code: 'M1201', cmd_command: 'example', custom_field: 'keep' }], + write: async () => { assert.fail('read must not upload'); }, + reload: async () => { assert.fail('read must not send codes'); } + }); + assert.equal(result[0].cmd_timeout, 30); + assert.equal(result[0].custom_field, 'keep'); +}); + +test('saving preserves fields and reloads only after uploading', async () => { + const order = []; + const commands = [{ cmd_code: 'M1201', cmd_command: 'example', cmd_timeout: 0, custom_field: 'keep' }]; + await writeCommands({ + read: async () => { assert.fail('save does not read'); }, + write: async blob => { order.push('write'); assert.deepEqual(JSON.parse(await blob.text()), commands); }, + reload: async () => { order.push('reload'); } + }, commands); + assert.deepEqual(order, ['write', 'reload']); +}); + +test('upload failure never reloads filters', async () => { + await assert.rejects(writeCommands({ + read: async () => [], + write: async () => { throw new Error('upload failed'); }, + reload: async () => { assert.fail('failed upload must not reload'); } + }, []), /upload failed/); +}); + +test('reload failure is reported to the editor', async () => { + await assert.rejects(writeCommands({ + read: async () => [], write: async () => {}, + reload: async () => { throw new Error('backend unavailable'); } + }, []), /backend unavailable/); +}); + +test('invalid files are rejected before opening an editable list', async () => { + for (const invalid of [{}, [null], [{ cmd_code: 'M1201' }]]) { + await assert.rejects(readCommands({ read: async () => invalid }), /configuration/); + } +}); diff --git a/tests/test_dsf37.py b/tests/test_dsf37.py new file mode 100644 index 0000000..2cdd27a --- /dev/null +++ b/tests/test_dsf37.py @@ -0,0 +1,83 @@ +"""Backend regression checks against the real DSF 3.7 client, without a printer.""" +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +BACKEND = Path(__file__).resolve().parents[1] / "src" / "dsf" +sys.dont_write_bytecode = True +sys.path.insert(0, str(BACKEND)) +import dsf +from dsf.commands.code import Code +from MCodeAction import MCodeAction +from compat import enum_value, resolved_path, major_number, MCODE, SUCCESS + +spec = importlib.util.spec_from_file_location("exec_on_mcode", BACKEND / "execOnMcode.py") +backend = importlib.util.module_from_spec(spec) +spec.loader.exec_module(backend) + + +class Dsf37Tests(unittest.TestCase): + def test_linux_entrypoint_has_a_valid_shebang(self): + source = (BACKEND / "execOnMcode.py").read_bytes() + self.assertEqual(source.split(b"\n")[0], b"#!/usr/bin/python3") + self.assertNotIn(b"\r\n", source) + + def test_real_protocol_supported(self): + self.assertGreaterEqual(dsf.PROTOCOL_VERSION, 12) + + def test_legacy_and_current_client_api_shapes(self): + self.assertEqual(enum_value(SimpleNamespace(Success=0), "SUCCESS", "Success"), 0) + self.assertEqual(enum_value(SimpleNamespace(SUCCESS=0), "SUCCESS", "Success"), 0) + self.assertEqual(resolved_path("/config.json"), "/config.json") + self.assertEqual(resolved_path(SimpleNamespace(result="/config.json")), "/config.json") + self.assertEqual(major_number(SimpleNamespace(majorNumber=1200)), 1200) + self.assertEqual(major_number(SimpleNamespace(major_number=1200)), 1200) + + def test_resolve_path_returns_string_and_existing_config_is_preserved(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "ExecOnMcode.json" + contents = json.dumps([{"cmd_code": "M1215", "cmd_command": "echo existing", "cmd_timeout": 0}]) + config.write_text(contents) + backend.cmd_conn = Mock(resolve_path=Mock(return_value=str(config))) + actions = backend.get_actions_from_config() + self.assertEqual(actions[0].cmd_code, "M1215") + self.assertIsNone(actions[0].cmd_timeout) + self.assertEqual(config.read_text(), contents) + + def test_missing_config_is_created_using_resolved_path(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "nested" / "ExecOnMcode.json" + backend.cmd_conn = Mock(resolve_path=Mock(return_value=str(config))) + actions = backend.get_actions_from_config() + self.assertTrue(config.is_file()) + self.assertEqual([action.cmd_code for action in actions], ["M1201", "M1202"]) + + def test_intercepted_code_uses_current_code_type_and_result(self): + code = Code.from_json({"command": "Code", "type": "M", "majorNumber": 1201, "result": None, "parameters": []}) + self.assertEqual(code.type, MCODE) + self.assertEqual(major_number(code), 1201) + action = MCodeAction({"cmd_code": "M1201", "cmd_command": "echo example"}) + connection = Mock() + with patch.object(backend.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "example", "")) as run: + getattr(backend, "__do_action_for_code")(connection, [action], code) + run.assert_called_once_with("echo example", shell=True, timeout=30, capture_output=True, text=True) + connection.resolve_code.assert_called_once_with(SUCCESS, "[ExecOnMcode]: example") + + def test_disabled_code_never_runs(self): + code = Code.from_json({"command": "Code", "type": "M", "majorNumber": 1201, "result": None, "parameters": []}) + action = MCodeAction({"cmd_code": "M1201", "cmd_command": "echo example", "cmd_enabled": False}) + connection = Mock() + with patch.object(backend.subprocess, "run") as run: + getattr(backend, "__do_action_for_code")(connection, [action], code) + run.assert_not_called() + connection.ignore_code.assert_called_once() + + +if __name__ == "__main__": + unittest.main()