From 93b72bbd95e40083dff1391be6d67b0f674f9a8e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 28 Jul 2026 17:09:42 +0200 Subject: [PATCH 1/9] feat(scripts): add the scripts domain layer, gateway helpers and store wiring Types re-exported from the generated client, narrowing helpers for the free-form execution result and error fields, pure reducers for the client-side execution history, per-entity-type dispatch helpers for the eight scripts endpoints, a unit-tested polling cycle, and the store state and actions that tie them together. The gateway has no endpoint listing executions, so the UI remembers the ids it started and one interval in the store polls them. History trimming never drops an execution that is still active, because losing its id would orphan the process for good. --- src/lib/api-dispatch.test.ts | 253 +++++++++++++++++++++- src/lib/api-dispatch.ts | 189 ++++++++++++++++- src/lib/schema-utils.test.ts | 92 ++++++++ src/lib/script-language.test.ts | 72 +++++++ src/lib/script-language.ts | 78 +++++++ src/lib/scripts-polling.test.ts | 294 ++++++++++++++++++++++++++ src/lib/scripts-polling.ts | 95 +++++++++ src/lib/scripts.test.ts | 288 ++++++++++++++++++++++++++ src/lib/scripts.ts | 177 ++++++++++++++++ src/lib/store-scripts.test.ts | 103 +++++++++ src/lib/store.ts | 357 ++++++++++++++++++++++++++++++++ src/lib/types.ts | 47 +++++ src/test/setup.ts | 49 +++++ 13 files changed, 2092 insertions(+), 2 deletions(-) create mode 100644 src/lib/schema-utils.test.ts create mode 100644 src/lib/script-language.test.ts create mode 100644 src/lib/script-language.ts create mode 100644 src/lib/scripts-polling.test.ts create mode 100644 src/lib/scripts-polling.ts create mode 100644 src/lib/scripts.test.ts create mode 100644 src/lib/scripts.ts create mode 100644 src/lib/store-scripts.test.ts diff --git a/src/lib/api-dispatch.test.ts b/src/lib/api-dispatch.test.ts index 37fbdd6..48bfdc1 100644 --- a/src/lib/api-dispatch.test.ts +++ b/src/lib/api-dispatch.test.ts @@ -13,7 +13,7 @@ // limitations under the License. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { SovdResourceEntityType } from './types'; +import type { SovdResourceEntityType, ScriptEntityType } from './types'; import { getEntityDetail, getEntityData, @@ -37,6 +37,14 @@ import { getEntityLogs, getEntityLogsConfiguration, putEntityLogsConfiguration, + getEntityScripts, + getEntityScript, + uploadEntityScript, + deleteEntityScript, + startScriptExecution, + getScriptExecution, + controlScriptExecution, + deleteScriptExecution, } from './api-dispatch'; // --------------------------------------------------------------------------- @@ -728,3 +736,246 @@ describe('putEntityLogsConfiguration', () => { }); }); }); + +// ============================================================================= +// Scripts +// ============================================================================= + +const SCRIPT_ENTITY_TYPES: ScriptEntityType[] = ['apps', 'components']; + +const SCRIPT_PATHS: Record = { + apps: { + list: '/apps/{app_id}/scripts', + item: '/apps/{app_id}/scripts/{script_id}', + execs: '/apps/{app_id}/scripts/{script_id}/executions', + exec: '/apps/{app_id}/scripts/{script_id}/executions/{execution_id}', + }, + components: { + list: '/components/{component_id}/scripts', + item: '/components/{component_id}/scripts/{script_id}', + execs: '/components/{component_id}/scripts/{script_id}/executions', + exec: '/components/{component_id}/scripts/{script_id}/executions/{execution_id}', + }, +}; + +const SCRIPT_ID_PARAM_MAP: Record = { + apps: 'app_id', + components: 'component_id', +}; + +describe('getEntityScripts', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('getEntityScripts calls the list path for %s', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityScripts(client as any, entityType, 'my-entity'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].list); + expect(opts.params.path).toEqual({ [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity' }); + }); + + it.each(SCRIPT_ENTITY_TYPES)('getEntityScripts forwards the abort signal for %s', async (entityType) => { + const controller = new AbortController(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityScripts(client as any, entityType, 'my-entity', controller.signal); + const opts = client.GET.mock.calls[0]![1]; + expect(opts.signal).toBe(controller.signal); + }); +}); + +// ============================================================================= +// getEntityScript +// ============================================================================= + +describe('getEntityScript', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('getEntityScript calls the item path for %s', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getEntityScript(client as any, entityType, 'my-entity', 'script-1'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].item); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + }); + }); +}); + +// ============================================================================= +// deleteEntityScript +// ============================================================================= + +describe('deleteEntityScript', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('deleteEntityScript deletes the item path for %s', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteEntityScript(client as any, entityType, 'my-entity', 'script-1'); + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].item); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + }); + }); +}); + +// ============================================================================= +// startScriptExecution +// ============================================================================= + +describe('startScriptExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('startScriptExecution posts the execution body for %s', async (entityType) => { + const request = { execution_type: 'diagnostic', parameters: { foo: 'bar' } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await startScriptExecution(client as any, entityType, 'my-entity', 'script-1', request); + expect(client.POST).toHaveBeenCalledTimes(1); + const [path, opts] = client.POST.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].execs); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + }); + expect(opts.body).toEqual(request); + }); +}); + +// ============================================================================= +// getScriptExecution +// ============================================================================= + +describe('getScriptExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('getScriptExecution reads the execution path for %s', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getScriptExecution(client as any, entityType, 'my-entity', 'script-1', 'exec-1'); + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].exec); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + execution_id: 'exec-1', + }); + }); + + it.each(SCRIPT_ENTITY_TYPES)('getScriptExecution forwards the abort signal for %s', async (entityType) => { + const controller = new AbortController(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getScriptExecution(client as any, entityType, 'my-entity', 'script-1', 'exec-1', controller.signal); + const opts = client.GET.mock.calls[0]![1]; + expect(opts.signal).toBe(controller.signal); + }); +}); + +// ============================================================================= +// controlScriptExecution +// ============================================================================= + +describe('controlScriptExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)( + 'controlScriptExecution puts {action} on the execution path for %s', + async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await controlScriptExecution(client as any, entityType, 'my-entity', 'script-1', 'exec-1', 'stop'); + expect(client.PUT).toHaveBeenCalledTimes(1); + const [path, opts] = client.PUT.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].exec); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + execution_id: 'exec-1', + }); + expect(opts.body).toEqual({ action: 'stop' }); + } + ); +}); + +// ============================================================================= +// deleteScriptExecution +// ============================================================================= + +describe('deleteScriptExecution', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('deleteScriptExecution deletes the execution path for %s', async (entityType) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await deleteScriptExecution(client as any, entityType, 'my-entity', 'script-1', 'exec-1'); + expect(client.DELETE).toHaveBeenCalledTimes(1); + const [path, opts] = client.DELETE.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].exec); + expect(opts.params.path).toEqual({ + [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity', + script_id: 'script-1', + execution_id: 'exec-1', + }); + }); +}); + +// ============================================================================= +// uploadEntityScript +// ============================================================================= + +describe('uploadEntityScript', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it.each(SCRIPT_ENTITY_TYPES)('uploadEntityScript posts FormData to the list path for %s', async (entityType) => { + const form = new FormData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await uploadEntityScript(client as any, entityType, 'my-entity', form); + expect(client.POST).toHaveBeenCalledTimes(1); + const [path, opts] = client.POST.mock.calls[0]!; + expect(path).toBe(SCRIPT_PATHS[entityType].list); + expect(opts.params.path).toEqual({ [SCRIPT_ID_PARAM_MAP[entityType]]: 'my-entity' }); + }); + + it('uploadEntityScript passes FormData through the body serializer untouched', async () => { + const form = new FormData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await uploadEntityScript(client as any, 'apps', 'my-entity', form); + const opts = client.POST.mock.calls[0]![1]; + expect(opts.body).toBeInstanceOf(FormData); + expect(opts.bodySerializer(form)).toBe(form); + }); + + it('uploadEntityScript sets no content type header in any casing', async () => { + const form = new FormData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await uploadEntityScript(client as any, 'apps', 'my-entity', form); + const opts = client.POST.mock.calls[0]![1]; + expect(Object.keys(opts.headers ?? {}).map((k) => k.toLowerCase())).not.toContain('content-type'); + }); +}); diff --git a/src/lib/api-dispatch.ts b/src/lib/api-dispatch.ts index 33d2a2c..f38183e 100644 --- a/src/lib/api-dispatch.ts +++ b/src/lib/api-dispatch.ts @@ -22,7 +22,7 @@ */ import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; -import type { SovdResourceEntityType, LifecycleAction } from './types'; +import type { SovdResourceEntityType, LifecycleAction, ScriptEntityType, StartScriptExecutionRequest } from './types'; import type { LogsQueryParams, LogsConfiguration } from './log-types'; // ============================================================================= @@ -690,3 +690,190 @@ export function setStatus( return client.PUT('/components/{component_id}/status/force-shutdown', { params, signal }); } } + +// ============================================================================= +// Scripts +// ============================================================================= + +export function getEntityScripts( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + signal?: AbortSignal +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/scripts', { params: { path: { app_id: entityId } }, signal }); + case 'components': + return client.GET('/components/{component_id}/scripts', { + params: { path: { component_id: entityId } }, + signal, + }); + } +} + +export function getEntityScript( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/scripts/{script_id}', { + params: { path: { app_id: entityId, script_id: scriptId } }, + }); + case 'components': + return client.GET('/components/{component_id}/scripts/{script_id}', { + params: { path: { component_id: entityId, script_id: scriptId } }, + }); + } +} + +/** + * Multipart upload. + * + * The spec declares the body as `{type: object, additionalProperties: true}`, so + * the generated type is `{ [key: string]: unknown }` and FormData (a DOM interface) + * is not assignable to it. bodySerializer returns the FormData unchanged so fetch + * sets Content-Type with the multipart boundary itself - the gateway rejects the + * request without it. + */ +export function uploadEntityScript( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + form: FormData +) { + const body = form as unknown as Record; + const bodySerializer = (value: unknown) => value as FormData; + switch (entityType) { + case 'apps': + return client.POST('/apps/{app_id}/scripts', { + params: { path: { app_id: entityId } }, + body, + bodySerializer, + }); + case 'components': + return client.POST('/components/{component_id}/scripts', { + params: { path: { component_id: entityId } }, + body, + bodySerializer, + }); + } +} + +export function deleteEntityScript( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string +) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/scripts/{script_id}', { + params: { path: { app_id: entityId, script_id: scriptId } }, + }); + case 'components': + return client.DELETE('/components/{component_id}/scripts/{script_id}', { + params: { path: { component_id: entityId, script_id: scriptId } }, + }); + } +} + +/** + * Start an execution. + * + * The spec declares this request body as a bare `type: object`, so the generated + * type is `Record` and any real body fails the type check. The cast + * keeps the runtime payload correct; removing it requires a spec fix in the gateway. + */ +export function startScriptExecution( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + request: StartScriptExecutionRequest +) { + const body = request as unknown as Record; + switch (entityType) { + case 'apps': + return client.POST('/apps/{app_id}/scripts/{script_id}/executions', { + params: { path: { app_id: entityId, script_id: scriptId } }, + body, + }); + case 'components': + return client.POST('/components/{component_id}/scripts/{script_id}/executions', { + params: { path: { component_id: entityId, script_id: scriptId } }, + body, + }); + } +} + +export function getScriptExecution( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string, + signal?: AbortSignal +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { app_id: entityId, script_id: scriptId, execution_id: executionId } }, + signal, + }); + case 'components': + return client.GET('/components/{component_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { component_id: entityId, script_id: scriptId, execution_id: executionId } }, + signal, + }); + } +} + +/** + * `action` is a plain string, not a union: the gateway forwards it verbatim to + * plugin backends, which may support control actions beyond stop and + * forced_termination. + */ +export function controlScriptExecution( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string, + action: string +) { + switch (entityType) { + case 'apps': + return client.PUT('/apps/{app_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { app_id: entityId, script_id: scriptId, execution_id: executionId } }, + body: { action }, + }); + case 'components': + return client.PUT('/components/{component_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { component_id: entityId, script_id: scriptId, execution_id: executionId } }, + body: { action }, + }); + } +} + +export function deleteScriptExecution( + client: MedkitClient, + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string +) { + switch (entityType) { + case 'apps': + return client.DELETE('/apps/{app_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { app_id: entityId, script_id: scriptId, execution_id: executionId } }, + }); + case 'components': + return client.DELETE('/components/{component_id}/scripts/{script_id}/executions/{execution_id}', { + params: { path: { component_id: entityId, script_id: scriptId, execution_id: executionId } }, + }); + } +} diff --git a/src/lib/schema-utils.test.ts b/src/lib/schema-utils.test.ts new file mode 100644 index 0000000..88dd377 --- /dev/null +++ b/src/lib/schema-utils.test.ts @@ -0,0 +1,92 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'vitest'; +import { convertJsonSchemaToTopicSchema } from './schema-utils'; + +describe('convertJsonSchemaToTopicSchema', () => { + it('converts a flat object schema with typed properties', () => { + const result = convertJsonSchemaToTopicSchema({ + type: 'object', + properties: { + verbose: { type: 'boolean' }, + retries: { type: 'integer' }, + }, + }); + + expect(result).toEqual({ + verbose: { type: 'bool' }, + retries: { type: 'int32' }, + }); + }); + + it('converts nested object properties', () => { + const result = convertJsonSchemaToTopicSchema({ + type: 'object', + properties: { + target: { + type: 'object', + properties: { + host: { type: 'string' }, + port: { type: 'integer' }, + }, + }, + }, + }); + + expect(result).toEqual({ + target: { + type: 'object', + fields: { + host: { type: 'string' }, + port: { type: 'int32' }, + }, + }, + }); + }); + + it('converts array properties through items', () => { + const result = convertJsonSchemaToTopicSchema({ + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + }); + + expect(result).toEqual({ + tags: { + type: 'array', + items: { type: 'string' }, + }, + }); + }); + + it('returns undefined for null and for a non-object input', () => { + expect(convertJsonSchemaToTopicSchema(null)).toBeUndefined(); + expect(convertJsonSchemaToTopicSchema('not an object')).toBeUndefined(); + }); + + it('passes a schema without properties through unchanged', () => { + // Documents today's pass-through behaviour: a schema with no `properties` + // key falls through to the final `return jsonSchema as TopicSchema` line + // unchanged, even though `{type: 'object'}` is not a valid TopicSchema + // (its "type" entry is a bare string, not a SchemaFieldType object). + const result = convertJsonSchemaToTopicSchema({ type: 'object' }); + + expect(result).toEqual({ type: 'object' }); + }); +}); diff --git a/src/lib/script-language.test.ts b/src/lib/script-language.test.ts new file mode 100644 index 0000000..1818606 --- /dev/null +++ b/src/lib/script-language.test.ts @@ -0,0 +1,72 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'vitest'; +import { languageForFilename, hasExtension, templateFor } from './script-language'; + +describe('languageForFilename', () => { + it('returns python for .py', () => { + expect(languageForFilename('check.py')).toBe('python'); + }); + + it('returns shell for .sh and .bash', () => { + expect(languageForFilename('check.sh')).toBe('shell'); + expect(languageForFilename('check.bash')).toBe('shell'); + }); + + it('returns plain for an unknown extension and for no extension', () => { + expect(languageForFilename('check.exe')).toBe('plain'); + expect(languageForFilename('check')).toBe('plain'); + }); + + it('is case insensitive', () => { + expect(languageForFilename('Check.PY')).toBe('python'); + expect(languageForFilename('Check.SH')).toBe('shell'); + expect(languageForFilename('Check.BASH')).toBe('shell'); + }); +}); + +describe('hasExtension', () => { + it('accepts a name with a non-empty extension', () => { + expect(hasExtension('check.sh')).toBe(true); + }); + + it('rejects a name with no dot', () => { + expect(hasExtension('check')).toBe(false); + }); + + it('rejects a name ending in a dot', () => { + expect(hasExtension('check.')).toBe(false); + }); + + it('rejects an empty name', () => { + expect(hasExtension('')).toBe(false); + }); + + it('rejects a dotfile with no extension', () => { + expect(hasExtension('.bashrc')).toBe(false); + }); +}); + +describe('templateFor', () => { + it('returns a python template for a .py name', () => { + const template = templateFor('check.py'); + expect(template).toContain('sys.stdin'); + }); + + it('returns a bash template otherwise', () => { + const template = templateFor('check.sh'); + expect(template).toContain('cat'); + }); +}); diff --git a/src/lib/script-language.ts b/src/lib/script-language.ts new file mode 100644 index 0000000..6abdb06 --- /dev/null +++ b/src/lib/script-language.ts @@ -0,0 +1,78 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * The gateway picks the interpreter from the uploaded file's extension: + * `.py` runs under python3, `.bash` under bash, and everything else - including + * no extension at all - under sh. `languageForFilename` mirrors that split so + * the editor's syntax highlighting matches what will actually execute. + */ +export type ScriptLanguage = 'python' | 'shell' | 'plain'; + +function extensionOf(filename: string): string { + const dot = filename.lastIndexOf('.'); + if (dot <= 0 || dot === filename.length - 1) return ''; + return filename.slice(dot + 1).toLowerCase(); +} + +export function languageForFilename(filename: string): ScriptLanguage { + const ext = extensionOf(filename); + if (ext === 'py') return 'python'; + if (ext === 'sh' || ext === 'bash') return 'shell'; + return 'plain'; +} + +/** + * True when `filename` has a non-empty extension after a non-leading dot. + * A leading dot alone (`.bashrc`) does not count: the gateway needs a real + * suffix to pick an interpreter, not a hidden-file marker. + */ +export function hasExtension(filename: string): boolean { + return extensionOf(filename) !== ''; +} + +const PYTHON_TEMPLATE = `#!/usr/bin/env python3 +import json +import sys + + +def main() -> int: + raw = sys.stdin.read() + params = json.loads(raw) if raw.strip() else {} + result = {"received": params} + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +`; + +const SHELL_TEMPLATE = `#!/bin/sh +set -eu + +params=$(cat) + +printf '{"received": %s}\\n' "\${params:-null}" +`; + +/** + * Starter content for write mode. Parameters entered in the Run form reach a + * script as JSON on stdin, never as argv, and the gateway discards stdout + * entirely when a script exits non-zero - so the template must read stdin, + * print JSON on stdout, and exit zero, or it teaches the wrong thing. + */ +export function templateFor(filename: string): string { + return languageForFilename(filename) === 'python' ? PYTHON_TEMPLATE : SHELL_TEMPLATE; +} diff --git a/src/lib/scripts-polling.test.ts b/src/lib/scripts-polling.test.ts new file mode 100644 index 0000000..a535ed3 --- /dev/null +++ b/src/lib/scripts-polling.test.ts @@ -0,0 +1,294 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { pollScriptExecutionsOnce, collectActiveExecutions } from './scripts-polling'; +import { SCRIPT_ERROR_CODE } from './scripts'; +import type { ScriptEntityType, ScriptExecutionRecord } from './types'; + +// --------------------------------------------------------------------------- +// Mock client factory - same shape as api-dispatch.test.ts +// --------------------------------------------------------------------------- + +function createMockClient() { + return { + GET: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + POST: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + PUT: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + DELETE: vi.fn().mockResolvedValue({ data: { ok: true }, error: undefined }), + streams: {}, + }; +} + +type MockClient = ReturnType; +type HistoryMap = Map; + +function record( + id: string, + status = 'running', + entityType: ScriptEntityType = 'components', + entityId = 'ecu' +): ScriptExecutionRecord { + return { + execution: { id, status }, + scriptId: 'diag', + scriptName: 'diag', + entityType, + entityId, + }; +} + +describe('collectActiveExecutions', () => { + it('keeps only active, non-lost records across every key', () => { + const history: HistoryMap = new Map([ + ['components/ecu', [record('e1', 'running'), record('e2', 'completed')]], + [ + 'apps/talker', + [ + { ...record('e3', 'prepared', 'apps', 'talker'), lost: true }, + record('e4', 'prepared', 'apps', 'talker'), + ], + ], + ]); + + const active = collectActiveExecutions(history); + + expect(active.map((r) => r.execution.id)).toEqual(['e1', 'e4']); + }); +}); + +describe('pollScriptExecutionsOnce', () => { + let client: MockClient; + beforeEach(() => { + client = createMockClient(); + }); + + it('returns null when no execution is active', async () => { + const history: HistoryMap = new Map([['components/ecu', [record('e1', 'completed')]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toBeNull(); + expect(client.GET).not.toHaveBeenCalled(); + }); + + it('skips records already marked lost', async () => { + const lost = { ...record('e1', 'running'), lost: true }; + const history: HistoryMap = new Map([['components/ecu', [lost]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toBeNull(); + expect(client.GET).not.toHaveBeenCalled(); + }); + + it('queries every active execution once per call', async () => { + client.GET.mockResolvedValue({ + data: { id: 'x', status: 'running' }, + error: undefined, + response: { status: 200 }, + }); + const history: HistoryMap = new Map([ + ['components/ecu', [record('e1', 'running'), record('e2', 'prepared')]], + ['apps/talker', [record('e3', 'running', 'apps', 'talker')]], + ]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await pollScriptExecutionsOnce(client as any, history); + + expect(client.GET).toHaveBeenCalledTimes(3); + }); + + it('returns the fresh execution payload as an outcome, without touching the input', async () => { + const fresh = { id: 'e1', status: 'completed', progress: 100 }; + client.GET.mockResolvedValue({ data: fresh, error: undefined, response: { status: 200 } }); + const original = record('e1', 'running'); + const originalExecution = original.execution; + const history: HistoryMap = new Map([['components/ecu', [original]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const outcome = result![0]!; + expect('lost' in outcome).toBe(false); + if (!('lost' in outcome)) { + expect(outcome.execution).toEqual(fresh); + } + // The caller re-derives the key from outcome.record, so it must be the + // same tracked record - not a copy. + expect(outcome.record).toBe(original); + + // The input map and the original record must be untouched - the function must never mutate its arguments. + expect(history.get('components/ecu')![0]).toBe(original); + expect(original.execution).toBe(originalExecution); + expect(original.execution.status).toBe('running'); + }); + + it('marks a record lost on a resource-not-found 404 and leaves the others untouched', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client.GET.mockImplementation((_path: string, opts: any) => { + const execId = opts.params.path.execution_id as string; + if (execId === 'e1') { + return Promise.resolve({ + data: undefined, + error: { message: 'Not found', error_code: SCRIPT_ERROR_CODE.resourceNotFound }, + response: { status: 404 }, + }); + } + return Promise.resolve({ + data: { id: execId, status: 'prepared' }, + error: undefined, + response: { status: 200 }, + }); + }); + const r1 = record('e1', 'running'); + const r2 = record('e2', 'running'); + const history: HistoryMap = new Map([['components/ecu', [r1, r2]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toHaveLength(2); + const outcome1 = result!.find((o) => o.record.execution.id === 'e1')!; + const outcome2 = result!.find((o) => o.record.execution.id === 'e2')!; + expect('lost' in outcome1 && outcome1.lost).toBe(true); + expect('lost' in outcome2).toBe(false); + if (!('lost' in outcome2)) { + expect(outcome2.execution.status).toBe('prepared'); + } + + // The input map and the original records must be untouched. + expect(history.get('components/ecu')).toEqual([r1, r2]); + expect(r1.lost).toBeUndefined(); + expect(r2.execution.status).toBe('running'); + }); + + it('omits the record on a 404 whose error_code is entity-not-found, since the entity may reappear', async () => { + client.GET.mockResolvedValue({ + data: undefined, + error: { message: 'Entity not found', error_code: SCRIPT_ERROR_CODE.entityNotFound }, + response: { status: 404 }, + }); + const original = record('e1', 'running'); + const history: HistoryMap = new Map([['components/ecu', [original]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toEqual([]); + // Not marked lost and not evicted from the caller's perspective: the + // original record is untouched, unlike the resource-not-found case above. + expect(history.get('components/ecu')![0]).toBe(original); + expect(original.lost).toBeUndefined(); + }); + + it('omits the record from outcomes when the request fails with 500', async () => { + client.GET.mockResolvedValue({ + data: undefined, + error: { message: 'Internal Server Error' }, + response: { status: 500 }, + }); + const original = record('e1', 'running'); + const history: HistoryMap = new Map([['components/ecu', [original]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toEqual([]); + // The input map and the original record must be untouched. + expect(history.get('components/ecu')![0]).toBe(original); + expect(original.execution.status).toBe('running'); + }); + + it('uses the entity type and id carried by the record', async () => { + client.GET.mockResolvedValue({ + data: { id: 'e1', status: 'running' }, + error: undefined, + response: { status: 200 }, + }); + const appRecord = record('e1', 'running', 'apps', 'talker'); + const history: HistoryMap = new Map([['apps/talker', [appRecord]]]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await pollScriptExecutionsOnce(client as any, history); + + expect(client.GET).toHaveBeenCalledTimes(1); + const [path, opts] = client.GET.mock.calls[0]!; + expect(path).toBe('/apps/{app_id}/scripts/{script_id}/executions/{execution_id}'); + expect(opts.params.path).toEqual({ app_id: 'talker', script_id: 'diag', execution_id: 'e1' }); + }); + + it('returns independent outcomes for records tracked under different keys', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client.GET.mockImplementation((_path: string, opts: any) => { + const execId = opts.params.path.execution_id as string; + if (execId === 'e1') { + return Promise.resolve({ + data: undefined, + error: { message: 'gone', error_code: SCRIPT_ERROR_CODE.resourceNotFound }, + response: { status: 404 }, + }); + } + return Promise.resolve({ + data: { id: execId, status: 'completed' }, + error: undefined, + response: { status: 200 }, + }); + }); + const r1 = record('e1', 'running', 'components', 'ecu'); + const r2 = record('e2', 'prepared', 'apps', 'talker'); + const history: HistoryMap = new Map([ + ['components/ecu', [r1]], + ['apps/talker', [r2]], + ]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await pollScriptExecutionsOnce(client as any, history); + + expect(result).toHaveLength(2); + const outcome1 = result!.find((o) => o.record === r1)!; + const outcome2 = result!.find((o) => o.record === r2)!; + expect('lost' in outcome1 && outcome1.lost).toBe(true); + expect('lost' in outcome2).toBe(false); + if (!('lost' in outcome2)) { + expect(outcome2.execution.status).toBe('completed'); + } + + // The input map and the original records must be untouched. + expect(history.get('components/ecu')).toEqual([r1]); + expect(history.get('apps/talker')).toEqual([r2]); + expect(r1.lost).toBeUndefined(); + expect(r2.execution.status).toBe('prepared'); + }); + + it('returns null when the abort signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const history: HistoryMap = new Map([['components/ecu', [record('e1', 'running')]]]); + + const result = await pollScriptExecutionsOnce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client as any, + history, + { signal: controller.signal } + ); + + expect(result).toBeNull(); + expect(client.GET).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/scripts-polling.ts b/src/lib/scripts-polling.ts new file mode 100644 index 0000000..512f969 --- /dev/null +++ b/src/lib/scripts-polling.ts @@ -0,0 +1,95 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Polling cycle for tracked script executions, kept out of the zustand store + * so it can be unit tested directly (this repo has no pattern for testing + * store actions). The store wraps this in a setInterval. + * + * This module only issues the requests and reports what happened per record; + * it never folds results onto a history map itself. Folding requires + * re-reading the store's state after the await (the map may have changed + * while requests were in flight - a sibling action may have added, removed, + * or already updated a record), so that responsibility belongs to the + * caller, not to a function that only ever sees a single snapshot. + */ + +import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; +import { getScriptExecution } from './api-dispatch'; +import { isActiveScriptStatus, scriptErrorCode, SCRIPT_ERROR_CODE } from './scripts'; +import type { ScriptExecution, ScriptExecutionRecord } from './types'; + +type HistoryMap = Map; + +export function collectActiveExecutions(history: HistoryMap): ScriptExecutionRecord[] { + const active: ScriptExecutionRecord[] = []; + for (const records of history.values()) { + for (const record of records) { + if (!record.lost && isActiveScriptStatus(record.execution.status)) active.push(record); + } + } + return active; +} + +/** Outcome of polling a single tracked execution. */ +export type ScriptPollOutcome = + | { record: ScriptExecutionRecord; execution: ScriptExecution } + | { record: ScriptExecutionRecord; lost: true }; + +/** + * One polling cycle. Returns the outcomes of the requests made, or null when + * there was nothing to do (so the caller can stop the interval). A record + * whose request failed with anything other than `resource-not-found` (the + * script or execution itself is gone) is simply omitted from the result - + * the caller keeps whatever it already has for it. In particular + * `entity-not-found` - the entity is momentarily absent, which happens under + * runtime discovery when a node restarts - must not evict the record, since + * the entity can reappear on the next tick. + */ +export async function pollScriptExecutionsOnce( + client: MedkitClient, + history: HistoryMap, + options: { signal?: AbortSignal } = {} +): Promise { + if (options.signal?.aborted) return null; + + const active = collectActiveExecutions(history); + if (active.length === 0) return null; + + const results = await Promise.all( + active.map(async (record): Promise => { + try { + const { data, error } = await getScriptExecution( + client, + record.entityType, + record.entityId, + record.scriptId, + record.execution.id, + options.signal + ); + if (error) { + return scriptErrorCode(error) === SCRIPT_ERROR_CODE.resourceNotFound + ? { record, lost: true as const } + : null; + } + return data ? { record, execution: data as ScriptExecution } : null; + } catch (err) { + console.error('[scriptPolling] failed:', err); + return null; + } + }) + ); + + return results.filter((result): result is ScriptPollOutcome => result !== null); +} diff --git a/src/lib/scripts.test.ts b/src/lib/scripts.test.ts new file mode 100644 index 0000000..e2b60f5 --- /dev/null +++ b/src/lib/scripts.test.ts @@ -0,0 +1,288 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'vitest'; +import { + ScriptsApiError, + toScriptsApiError, + scriptErrorMessage, + ACTIVE_SCRIPT_STATUSES, + isActiveScriptStatus, + scriptEntityKey, + SCRIPT_ERROR_CODE, + scriptOutput, + scriptFailure, + upsertExecutionRecord, + markExecutionLost, + removeExecutionRecord, + MAX_EXECUTION_HISTORY, +} from './scripts'; +import type { ScriptExecution, ScriptExecutionRecord } from './types'; + +function record(id: string, status = 'running', scriptId = 'diag'): ScriptExecutionRecord { + return { + execution: { id, status }, + scriptId, + scriptName: scriptId, + entityType: 'components', + entityId: 'ecu', + }; +} + +describe('isActiveScriptStatus', () => { + it('treats prepared and running as active', () => { + expect(isActiveScriptStatus('prepared')).toBe(true); + expect(isActiveScriptStatus('running')).toBe(true); + expect(ACTIVE_SCRIPT_STATUSES).toEqual(['prepared', 'running']); + }); + + it('treats terminal and unknown statuses as inactive', () => { + expect(isActiveScriptStatus('completed')).toBe(false); + expect(isActiveScriptStatus('failed')).toBe(false); + expect(isActiveScriptStatus('terminated')).toBe(false); + expect(isActiveScriptStatus('aborted-by-plugin')).toBe(false); + expect(isActiveScriptStatus('')).toBe(false); + }); +}); + +describe('scriptEntityKey', () => { + it('joins entity type and id', () => { + expect(scriptEntityKey('apps', 'talker')).toBe('apps/talker'); + }); +}); + +describe('toScriptsApiError', () => { + it('takes the status from the response and the code from the body', () => { + const err = toScriptsApiError({ error_code: SCRIPT_ERROR_CODE.managed, message: 'Managed script' }, 409); + expect(err).toBeInstanceOf(ScriptsApiError); + expect(err.status).toBe(409); + expect(err.errorCode).toBe('x-medkit-managed-script'); + expect(err.message).toBe('Managed script'); + }); + + it('falls back to HTTP when the body carries no message', () => { + const err = toScriptsApiError({}, 502); + expect(err.status).toBe(502); + expect(err.errorCode).toBe(''); + expect(err.message).toBe('HTTP 502'); + }); + + it('falls back for a non-object error value', () => { + const err = toScriptsApiError('boom', 502); + expect(err.status).toBe(502); + expect(err.errorCode).toBe(''); + expect(err.message).toBe('HTTP 502'); + }); +}); + +describe('scriptErrorMessage', () => { + it('uses the gateway message when present', () => { + const err = new ScriptsApiError('Managed script', 409, SCRIPT_ERROR_CODE.managed); + expect(scriptErrorMessage(err, 'fallback')).toBe('Managed script'); + }); + + it('uses the fallback for an empty message', () => { + const err = new ScriptsApiError('', 500, ''); + expect(scriptErrorMessage(err, 'fallback')).toBe('fallback'); + }); + + it('uses the fallback for a non-error value', () => { + expect(scriptErrorMessage(undefined, 'fallback')).toBe('fallback'); + }); + + it('uses the fallback for a bare network Error', () => { + const err = new TypeError('Failed to fetch'); + expect(scriptErrorMessage(err, 'fallback')).toBe('fallback'); + }); +}); + +describe('scriptOutput', () => { + it('returns null when parameters is null', () => { + const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: null }; + expect(scriptOutput(execution)).toBeNull(); + }); + + it('returns null for an empty object', () => { + const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: {} }; + expect(scriptOutput(execution)).toBeNull(); + }); + + it('returns a stdout entry when stdout is the only key', () => { + const execution: ScriptExecution = { id: 'e1', status: 'completed', parameters: { stdout: 'hi' } }; + expect(scriptOutput(execution)).toEqual({ kind: 'stdout', text: 'hi' }); + }); + + it('returns a json entry when stdout appears with other keys', () => { + const execution: ScriptExecution = { + id: 'e1', + status: 'completed', + parameters: { stdout: 'hi', exit_code: 0 }, + }; + expect(scriptOutput(execution)).toEqual({ kind: 'json', value: { stdout: 'hi', exit_code: 0 } }); + }); + + it('returns a json entry for a non-object payload', () => { + expect(scriptOutput({ id: 'e1', status: 'completed', parameters: 42 })).toEqual({ + kind: 'json', + value: 42, + }); + expect(scriptOutput({ id: 'e1', status: 'completed', parameters: [1, 2] })).toEqual({ + kind: 'json', + value: [1, 2], + }); + expect(scriptOutput({ id: 'e1', status: 'completed', parameters: 'text' })).toEqual({ + kind: 'json', + value: 'text', + }); + }); +}); + +describe('scriptFailure', () => { + it('returns null when error is null', () => { + const execution: ScriptExecution = { id: 'e1', status: 'failed', error: null }; + expect(scriptFailure(execution)).toBeNull(); + }); + + it('extracts message and exit code from a failed execution', () => { + const execution: ScriptExecution = { + id: 'e1', + status: 'failed', + error: { message: 'Script exited with an error', exit_code: 3 }, + }; + expect(scriptFailure(execution)).toEqual({ message: 'Script exited with an error', exitCode: 3 }); + }); + + it('returns exitCode null for a stop or timeout error', () => { + const execution: ScriptExecution = { + id: 'e1', + status: 'terminated', + error: { message: 'Execution stopped by user' }, + }; + expect(scriptFailure(execution)).toEqual({ message: 'Execution stopped by user', exitCode: null }); + }); + + it('returns null when error is not an object', () => { + const execution: ScriptExecution = { id: 'e1', status: 'failed', error: 'boom' }; + expect(scriptFailure(execution)).toBeNull(); + }); +}); + +describe('execution history reducers', () => { + it('prepends a new record and returns a new map and a new array', () => { + const existing = record('e1'); + const initial: Map = new Map([['components/ecu', [existing]]]); + const incoming = record('e2'); + + const next = upsertExecutionRecord(initial, 'components/ecu', incoming); + + expect(next).not.toBe(initial); + expect(next.get('components/ecu')).not.toBe(initial.get('components/ecu')); + expect(next.get('components/ecu')).toEqual([incoming, existing]); + }); + + it('replaces an existing record in place, preserving order', () => { + const r1 = record('e1'); + const r2 = record('e2'); + const initial: Map = new Map([['components/ecu', [r1, r2]]]); + const updated: ScriptExecutionRecord = { ...r1, execution: { ...r1.execution, status: 'completed' } }; + + const next = upsertExecutionRecord(initial, 'components/ecu', updated); + + expect(next.get('components/ecu')).toEqual([updated, r2]); + }); + + it('trims history to MAX_EXECUTION_HISTORY, dropping the oldest inactive record', () => { + // records[0] is the newest, the last element is the oldest. + const records = Array.from({ length: MAX_EXECUTION_HISTORY }, (_, i) => + record(`c${MAX_EXECUTION_HISTORY - 1 - i}`, 'completed') + ); + const initial: Map = new Map([['components/ecu', records]]); + + const next = upsertExecutionRecord(initial, 'components/ecu', record('c-new', 'completed')); + + const arr = next.get('components/ecu')!; + expect(arr).toHaveLength(MAX_EXECUTION_HISTORY); + expect(arr[0]!.execution.id).toBe('c-new'); + expect(arr.some((r) => r.execution.id === 'c0')).toBe(false); + expect(arr.some((r) => r.execution.id === 'c1')).toBe(true); + }); + + it('never drops a record that is still active when trimming', () => { + const completed = Array.from({ length: 25 }, (_, i) => record(`c${i}`, 'completed')); + const running = record('running-1', 'running'); + const initial: Map = new Map([['components/ecu', [...completed, running]]]); + + const next = upsertExecutionRecord(initial, 'components/ecu', record('c-new', 'completed')); + + const arr = next.get('components/ecu')!; + expect(arr).toHaveLength(MAX_EXECUTION_HISTORY); + expect(arr.some((r) => r.execution.id === 'running-1')).toBe(true); + }); + + it('marks a single record as lost without touching its neighbours', () => { + const r1 = record('e1'); + const r2 = record('e2'); + const initial: Map = new Map([['components/ecu', [r1, r2]]]); + + const next = markExecutionLost(initial, 'components/ecu', 'e1'); + + expect(next).not.toBe(initial); + const arr = next.get('components/ecu')!; + expect(arr).not.toBe(initial.get('components/ecu')); + expect(arr[0]).toEqual({ ...r1, lost: true }); + expect(arr[1]).toBe(r2); + }); + + it('returns the same map when marking an unknown key', () => { + const initial: Map = new Map([['components/ecu', [record('e1')]]]); + + const next = markExecutionLost(initial, 'apps/other', 'e1'); + + expect(next).toBe(initial); + }); + + it('removes only the target record', () => { + const r1 = record('e1'); + const r2 = record('e2'); + const initial: Map = new Map([['components/ecu', [r1, r2]]]); + + const next = removeExecutionRecord(initial, 'components/ecu', 'e1'); + + expect(next).not.toBe(initial); + const arr = next.get('components/ecu')!; + expect(arr).not.toBe(initial.get('components/ecu')); + expect(arr).toEqual([r2]); + }); + + it('drops the key entirely when the last record is removed', () => { + const initial: Map = new Map([['components/ecu', [record('e1')]]]); + + const next = removeExecutionRecord(initial, 'components/ecu', 'e1'); + + expect(next.has('components/ecu')).toBe(false); + }); + + it('keeps other entities untouched', () => { + const ecuRecords = [record('e1')]; + const otherRecords = [record('e2', 'running', 'other')]; + const initial: Map = new Map([ + ['components/ecu', ecuRecords], + ['apps/talker', otherRecords], + ]); + + const next = removeExecutionRecord(initial, 'components/ecu', 'e1'); + + expect(next.get('apps/talker')).toBe(otherRecords); + }); +}); diff --git a/src/lib/scripts.ts b/src/lib/scripts.ts new file mode 100644 index 0000000..0fdf828 --- /dev/null +++ b/src/lib/scripts.ts @@ -0,0 +1,177 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ScriptEntityType, ScriptExecution, ScriptExecutionRecord } from './types'; + +/** Wire values of GenericError.error_code used by the scripts endpoints. */ +export const SCRIPT_ERROR_CODE = { + notImplemented: 'not-implemented', + entityNotFound: 'entity-not-found', + resourceNotFound: 'resource-not-found', + invalidRequest: 'invalid-request', + invalidParameter: 'invalid-parameter', + managed: 'x-medkit-managed-script', + running: 'x-medkit-script-running', + notRunning: 'x-medkit-script-not-running', + alreadyExists: 'x-medkit-script-already-exists', + tooLarge: 'x-medkit-script-too-large', + concurrencyLimit: 'x-medkit-concurrency-limit', +} as const; + +/** + * Error carrying the gateway status and wire error code. The `throw new + * Error(message)` pattern used elsewhere in the store drops both, and the + * scripts UI needs them to tell 409-managed from 409-running. + */ +export class ScriptsApiError extends Error { + readonly status: number; + readonly errorCode: string; + + constructor(message: string, status: number, errorCode: string) { + super(message); + this.name = 'ScriptsApiError'; + this.status = status; + this.errorCode = errorCode; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Extract GenericError.error_code from an openapi-fetch error body of unknown shape. */ +export function scriptErrorCode(error: unknown): string { + return isRecord(error) && typeof error.error_code === 'string' ? error.error_code : ''; +} + +/** + * Build a ScriptsApiError from an openapi-fetch error body and the response + * status. GenericError has no `status` field, so the status always comes from + * the response. + */ +export function toScriptsApiError(error: unknown, status: number): ScriptsApiError { + if (isRecord(error)) { + const message = typeof error.message === 'string' && error.message ? error.message : `HTTP ${status}`; + return new ScriptsApiError(message, status, scriptErrorCode(error)); + } + return new ScriptsApiError(`HTTP ${status}`, status, ''); +} + +/** Message for the user: gateway text when present, otherwise the caller's fallback. */ +export function scriptErrorMessage(err: unknown, fallback: string): string { + return err instanceof ScriptsApiError && err.message ? err.message : fallback; +} + +/** + * Statuses worth polling. Deliberately a white list: `status` is a free-form + * string on the wire (plugin backends return their own values) and a black list + * of terminal statuses would poll an unknown status forever. + */ +export const ACTIVE_SCRIPT_STATUSES = ['prepared', 'running'] as const; + +export function isActiveScriptStatus(status: string): boolean { + return (ACTIVE_SCRIPT_STATUSES as readonly string[]).includes(status); +} + +export function scriptEntityKey(entityType: ScriptEntityType, entityId: string): string { + return `${entityType}/${entityId}`; +} + +export type ScriptOutput = { kind: 'stdout'; text: string } | { kind: 'json'; value: unknown }; + +/** + * Narrow `ScriptExecution.parameters` (typed `unknown | null`, because the spec + * declares it as free-form). The gateway parses stdout as JSON and falls back to + * `{stdout: "..."}` when it is not JSON, so the stdout-only shape gets rendered + * as text and everything else as JSON. + */ +export function scriptOutput(execution: ScriptExecution): ScriptOutput | null { + const value = execution.parameters; + if (value === null || value === undefined) return null; + if (isRecord(value)) { + const keys = Object.keys(value); + if (keys.length === 0) return null; + if (keys.length === 1 && keys[0] === 'stdout' && typeof value.stdout === 'string') { + return { kind: 'stdout', text: value.stdout }; + } + } + return { kind: 'json', value }; +} + +export interface ScriptFailure { + message: string; + /** Present only for a non-zero exit; stop, force kill and timeout carry no exit code. */ + exitCode: number | null; +} + +/** Narrow `ScriptExecution.error` (typed `unknown | null`). */ +export function scriptFailure(execution: ScriptExecution): ScriptFailure | null { + const value = execution.error; + if (!isRecord(value)) return null; + const message = typeof value.message === 'string' ? value.message : ''; + if (!message) return null; + return { message, exitCode: typeof value.exit_code === 'number' ? value.exit_code : null }; +} + +/** Upper bound on tracked executions per entity; a record can hold a full stdout dump. */ +export const MAX_EXECUTION_HISTORY = 20; + +type HistoryMap = Map; + +/** + * Trim to MAX_EXECUTION_HISTORY, dropping the oldest *inactive* records first. + * Dropping a running execution would orphan the process: the gateway has no + * endpoint to list executions, so its id could never be recovered. + */ +function trimHistory(records: ScriptExecutionRecord[]): ScriptExecutionRecord[] { + if (records.length <= MAX_EXECUTION_HISTORY) return records; + const active = records.filter((r) => isActiveScriptStatus(r.execution.status)); + const inactive = records.filter((r) => !isActiveScriptStatus(r.execution.status)); + const keepInactive = inactive.slice(0, Math.max(0, MAX_EXECUTION_HISTORY - active.length)); + return records.filter((r) => active.includes(r) || keepInactive.includes(r)); +} + +/** + * Insert or replace a record. Always returns a new Map and a new array for the + * touched key so zustand's reference comparison re-renders subscribers. + */ +export function upsertExecutionRecord(map: HistoryMap, key: string, record: ScriptExecutionRecord): HistoryMap { + const current = map.get(key) ?? []; + const index = current.findIndex((r) => r.execution.id === record.execution.id); + const next = index >= 0 ? current.map((r, i) => (i === index ? record : r)) : [record, ...current]; + const result = new Map(map); + result.set(key, trimHistory(next)); + return result; +} + +export function markExecutionLost(map: HistoryMap, key: string, executionId: string): HistoryMap { + const current = map.get(key); + if (!current || !current.some((r) => r.execution.id === executionId)) return map; + const result = new Map(map); + result.set( + key, + current.map((r) => (r.execution.id === executionId ? { ...r, lost: true } : r)) + ); + return result; +} + +export function removeExecutionRecord(map: HistoryMap, key: string, executionId: string): HistoryMap { + const current = map.get(key); + if (!current) return map; + const next = current.filter((r) => r.execution.id !== executionId); + const result = new Map(map); + if (next.length === 0) result.delete(key); + else result.set(key, next); + return result; +} diff --git a/src/lib/store-scripts.test.ts b/src/lib/store-scripts.test.ts new file mode 100644 index 0000000..fe82c93 --- /dev/null +++ b/src/lib/store-scripts.test.ts @@ -0,0 +1,103 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useAppStore } from './store'; +import { SCRIPT_ERROR_CODE } from './scripts'; +import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; +import type { ScriptExecutionRecord } from './types'; + +// ----------------------------------------------------------------------------- +// refreshScriptExecution is a store action, not a pure helper, so it cannot be +// exercised through store-helpers.test.ts. It is tested here against the real +// store (setState/getState) rather than through a mock, since the bug this +// covers - the `lost` flag never clearing, and any 404 (not just +// resource-not-found) setting it - lives in the wiring between the fetch +// result and the state update, not in a function that could be pulled out +// in isolation. +// ----------------------------------------------------------------------------- + +function makeRecord(overrides: Partial = {}): ScriptExecutionRecord { + return { + execution: { id: 'e1', status: 'completed' }, + scriptId: 'diag', + scriptName: 'diag', + entityType: 'components', + entityId: 'ecu', + ...overrides, + }; +} + +function makeClient(response: unknown): MedkitClient { + return { GET: vi.fn().mockResolvedValue(response) } as unknown as MedkitClient; +} + +describe('refreshScriptExecution', () => { + beforeEach(() => { + useAppStore.getState().stopScriptPolling(); + useAppStore.setState({ client: null, scriptExecutions: new Map(), scriptPollingIntervalId: null }); + }); + + afterEach(() => { + useAppStore.getState().stopScriptPolling(); + useAppStore.setState({ client: null, scriptExecutions: new Map(), scriptPollingIntervalId: null }); + }); + + it('clears a stale lost flag once the gateway answers successfully', async () => { + const client = makeClient({ + data: { id: 'e1', status: 'completed' }, + error: undefined, + response: { status: 200 }, + }); + useAppStore.setState({ + client, + scriptExecutions: new Map([['components/ecu', [makeRecord({ lost: true })]]]), + }); + + await useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1'); + + const updated = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]; + expect(updated?.lost).toBe(false); + }); + + it('leaves an untracked record alone on entity-not-found, since the entity may reappear', async () => { + const client = makeClient({ + data: undefined, + error: { message: 'Entity not found', error_code: SCRIPT_ERROR_CODE.entityNotFound }, + response: { status: 404 }, + }); + const original = makeRecord(); + useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) }); + + await useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1'); + + const record = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]; + expect(record?.lost).toBeUndefined(); + }); + + it('marks a record lost when the 404 is resource-not-found', async () => { + const client = makeClient({ + data: undefined, + error: { message: 'gone', error_code: SCRIPT_ERROR_CODE.resourceNotFound }, + response: { status: 404 }, + }); + const original = makeRecord(); + useAppStore.setState({ client, scriptExecutions: new Map([['components/ecu', [original]]]) }); + + await useAppStore.getState().refreshScriptExecution('components', 'ecu', 'diag', 'e1'); + + const record = useAppStore.getState().scriptExecutions.get('components/ecu')?.[0]; + expect(record?.lost).toBe(true); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index c5d20f9..a28521c 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -17,6 +17,14 @@ import type { VersionInfo, SovdFunction, EntityStatusValue, + ScriptEntityType, + ScriptExecution, + ScriptExecutionRecord, + ScriptMetadata, + ScriptsFetchResult, + ScriptUploadMetadata, + ScriptUploadResponse, + StartScriptExecutionRequest, } from './types'; import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; import type { SovdResourceEntityType } from './types'; @@ -50,8 +58,26 @@ import { putEntityLogsConfiguration, getStatus, type LifecycleEntityType, + getEntityScripts, + uploadEntityScript, + deleteEntityScript, + startScriptExecution, + getScriptExecution, + controlScriptExecution, + deleteScriptExecution, } from './api-dispatch'; import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types'; +import { collectActiveExecutions, pollScriptExecutionsOnce } from './scripts-polling'; +import { + ScriptsApiError, + toScriptsApiError, + scriptErrorCode, + scriptEntityKey, + upsertExecutionRecord, + markExecutionLost, + removeExecutionRecord, + SCRIPT_ERROR_CODE, +} from './scripts'; const STORAGE_KEY = 'ros2_medkit_web_ui_server_url'; const EXECUTION_POLL_INTERVAL_MS = 1000; @@ -61,6 +87,7 @@ const EXECUTION_POLL_INTERVAL_MS = 1000; // after a transition, which is why it is well under the time a node takes to // come back up rather than a round number. const STATUS_POLL_INTERVAL_MS = 5000; +const SCRIPT_POLL_INTERVAL_MS = 1000; const EXECUTION_CLEANUP_AFTER_MS = 5 * 60 * 1000; // 5 minutes export type TreeViewMode = 'logical' | 'functional'; @@ -112,6 +139,13 @@ export interface AppState { autoRefreshExecutions: boolean; // flag for auto-refresh polling executionPollingIntervalId: ReturnType | null; // polling interval ID + // Scripts state (diagnostic scripts uploaded per app/component). In-memory + // only: the gateway has no endpoint listing executions, so history does + // not survive a reload. + scriptsSupported: boolean; // gateway capability from GET / + scriptExecutions: Map; // scriptEntityKey(entityType, entityId) -> history + scriptPollingIntervalId: ReturnType | null; // polling interval ID + // Faults state (diagnostic trouble codes) faults: Fault[]; isLoadingFaults: boolean; @@ -197,6 +231,46 @@ export interface AppState { // Records whether an entity supports lifecycle actuation (see actuationByEntity). setEntityActuation: (entityType: LifecycleEntityType, entityId: string, supported: boolean) => void; + // Scripts actions + fetchEntityScripts: ( + entityType: ScriptEntityType, + entityId: string, + signal?: AbortSignal + ) => Promise; + uploadScript: ( + entityType: ScriptEntityType, + entityId: string, + file: File, + metadata?: ScriptUploadMetadata + ) => Promise; + deleteScript: (entityType: ScriptEntityType, entityId: string, scriptId: string) => Promise; + startScriptExecutionAction: ( + entityType: ScriptEntityType, + entityId: string, + script: ScriptMetadata, + request: StartScriptExecutionRequest + ) => Promise; + stopScriptExecution: ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string, + action: string + ) => Promise; + removeScriptExecution: ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string + ) => Promise; + refreshScriptExecution: ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string + ) => Promise; + startScriptPolling: () => void; + stopScriptPolling: () => void; // Faults actions fetchFaults: () => Promise; @@ -939,6 +1013,11 @@ export const useAppStore = create()( autoRefreshExecutions: true, executionPollingIntervalId: null, + // Scripts state + scriptsSupported: false, + scriptExecutions: new Map(), + scriptPollingIntervalId: null, + // Faults state faults: [], isLoadingFaults: false, @@ -994,6 +1073,32 @@ export const useAppStore = create()( client, }); + // A reconnect must not carry executions of the previous gateway: + // ids would 404 and show up as ghost records under matching entity ids. + get().stopScriptPolling(); + set({ scriptExecutions: new Map(), scriptsSupported: false }); + + // Scripts tab visibility follows the gateway capability. Guarded by + // the same 5s timeout as the health check so a slow gateway cannot + // stall entity loading. + try { + const capsController = new AbortController(); + const capsTimeout = setTimeout(() => capsController.abort(), 5000); + const { data: root } = await client + .GET('/', { signal: capsController.signal }) + .finally(() => clearTimeout(capsTimeout)); + // A disconnect or a newer connect() may have completed while this + // probe was in flight; a stale result must not clobber the state + // of the session that is current now. + if (get().client === client) { + set({ scriptsSupported: root?.capabilities.scripts === true }); + } + } catch { + if (get().client === client) { + set({ scriptsSupported: false }); + } + } + // Load root entities after successful connection await get().loadRootEntities(); @@ -1026,6 +1131,8 @@ export const useAppStore = create()( // an in-flight request must not be handed to the next session. get().stopStatusPolling(); __resetStatusRequestCache(); + // Stop script execution polling + get().stopScriptPolling(); // Unsubscribe from fault stream get().unsubscribeFaultStream(); @@ -1044,6 +1151,8 @@ export const useAppStore = create()( activeExecutions: new Map(), actuationByEntity: {}, statusByEntity: {}, + scriptsSupported: false, + scriptExecutions: new Map(), }); }, @@ -2088,6 +2197,254 @@ export const useAppStore = create()( set((s) => ({ actuationByEntity: { ...s.actuationByEntity, [key]: supported } })); }, + // =========================================================================== + // SCRIPTS ACTIONS (diagnostic scripts uploaded per app/component) + // =========================================================================== + + fetchEntityScripts: async (entityType: ScriptEntityType, entityId: string, signal?: AbortSignal) => { + const { client } = get(); + if (!client) return { items: [], errorStatus: -1 }; + try { + const { data, error, response } = await getEntityScripts(client, entityType, entityId, signal); + if (error) { + return { items: [], errorStatus: response?.status ?? -1 }; + } + return { items: data ? unwrapItems(data) : [] }; + } catch (err) { + if ((err as { name?: string }).name === 'AbortError') throw err; + console.error('[store] fetchEntityScripts failed', err); + return { items: [], errorStatus: -1 }; + } + }, + + uploadScript: async ( + entityType: ScriptEntityType, + entityId: string, + file: File, + metadata?: ScriptUploadMetadata + ) => { + const { client } = get(); + if (!client) throw new ScriptsApiError('Not connected', 0, ''); + + const form = new FormData(); + form.append('file', file, file.name); + if (metadata?.name || metadata?.description) { + form.append('metadata', JSON.stringify(metadata)); + } + + const { data, error, response } = await uploadEntityScript(client, entityType, entityId, form); + if (error) throw toScriptsApiError(error, response?.status ?? 0); + return data as ScriptUploadResponse; + }, + + deleteScript: async (entityType: ScriptEntityType, entityId: string, scriptId: string) => { + const { client } = get(); + if (!client) throw new ScriptsApiError('Not connected', 0, ''); + + const { error, response } = await deleteEntityScript(client, entityType, entityId, scriptId); + if (error) throw toScriptsApiError(error, response?.status ?? 0); + }, + + startScriptExecutionAction: async ( + entityType: ScriptEntityType, + entityId: string, + script: ScriptMetadata, + request: StartScriptExecutionRequest + ) => { + const { client } = get(); + if (!client) throw new ScriptsApiError('Not connected', 0, ''); + + const { data, error, response } = await startScriptExecution( + client, + entityType, + entityId, + script.id, + request + ); + if (error) throw toScriptsApiError(error, response?.status ?? 0); + + const key = scriptEntityKey(entityType, entityId); + const record: ScriptExecutionRecord = { + execution: data as ScriptExecution, + scriptId: script.id, + scriptName: script.name, + entityType, + entityId, + }; + set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) }); + get().startScriptPolling(); + }, + + stopScriptExecution: async ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string, + action: string + ) => { + const { client } = get(); + if (!client) throw new ScriptsApiError('Not connected', 0, ''); + + const { data, error, response } = await controlScriptExecution( + client, + entityType, + entityId, + scriptId, + executionId, + action + ); + if (error) throw toScriptsApiError(error, response?.status ?? 0); + + const key = scriptEntityKey(entityType, entityId); + const existing = get() + .scriptExecutions.get(key) + ?.find((r) => r.execution.id === executionId); + if (existing) { + const record: ScriptExecutionRecord = { ...existing, execution: data as ScriptExecution }; + set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) }); + get().startScriptPolling(); + } + }, + + removeScriptExecution: async ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string + ) => { + const { client } = get(); + if (!client) throw new ScriptsApiError('Not connected', 0, ''); + + const { error, response } = await deleteScriptExecution( + client, + entityType, + entityId, + scriptId, + executionId + ); + // A 404 means the gateway already forgot this execution - treat as success. + if (error && response?.status !== 404) throw toScriptsApiError(error, response?.status ?? 0); + + const key = scriptEntityKey(entityType, entityId); + set({ scriptExecutions: removeExecutionRecord(get().scriptExecutions, key, executionId) }); + }, + + refreshScriptExecution: async ( + entityType: ScriptEntityType, + entityId: string, + scriptId: string, + executionId: string + ) => { + const { client } = get(); + if (!client) return; + + const key = scriptEntityKey(entityType, entityId); + try { + const { data, error } = await getScriptExecution( + client, + entityType, + entityId, + scriptId, + executionId + ); + if (error) { + // Only resource-not-found means the execution itself is gone. + // entity-not-found means the entity is momentarily absent (a + // node restarting under runtime discovery, for example) and + // must not evict a record Refresh is meant to rescue. + if (scriptErrorCode(error) === SCRIPT_ERROR_CODE.resourceNotFound) { + set({ scriptExecutions: markExecutionLost(get().scriptExecutions, key, executionId) }); + } + return; + } + if (!data) return; + + const existing = get() + .scriptExecutions.get(key) + ?.find((r) => r.execution.id === executionId); + if (existing) { + // Clear `lost`: a successful refresh means the gateway answered, + // so this is exactly the rescue path a lost record needs. + const record: ScriptExecutionRecord = { + ...existing, + execution: data as ScriptExecution, + lost: false, + }; + set({ scriptExecutions: upsertExecutionRecord(get().scriptExecutions, key, record) }); + get().startScriptPolling(); + } + } catch (err) { + console.error('[store] refreshScriptExecution failed', err); + } + }, + + startScriptPolling: () => { + const { scriptPollingIntervalId, client } = get(); + if (scriptPollingIntervalId || !client) return; + if (collectActiveExecutions(get().scriptExecutions).length === 0) return; + + let inFlight = false; + const intervalId = setInterval(async () => { + const currentClient = get().client; + if (!currentClient) { + get().stopScriptPolling(); + return; + } + // Skip the tick while the tab is hidden, but keep the interval + // running so returning to the tab resumes polling by itself. + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + // Never let two cycles overlap: on a slow link the responses would + // arrive out of order and an old "running" could overwrite a fresh + // terminal status, freezing the card forever. + if (inFlight) return; + + inFlight = true; + try { + const outcomes = await pollScriptExecutionsOnce(currentClient, get().scriptExecutions); + if (outcomes === null) { + get().stopScriptPolling(); + return; + } + // The client may have changed while the cycle was in flight + // (disconnect or reconnect); dropping the result keeps the + // fresh session clean. + if (get().client !== currentClient) return; + + // Fold onto state read AFTER the await, not the snapshot the cycle + // started with: a sibling action (start/stop/remove) may have + // written to scriptExecutions while the requests were in flight, + // and that write must not be discarded. + let next = get().scriptExecutions; + for (const outcome of outcomes) { + const key = scriptEntityKey(outcome.record.entityType, outcome.record.entityId); + const stillTracked = next + .get(key) + ?.some((r) => r.execution.id === outcome.record.execution.id); + // If the user removed the record mid-cycle, the tick must not resurrect it. + if (!stillTracked) continue; + next = + 'lost' in outcome + ? markExecutionLost(next, key, outcome.record.execution.id) + : upsertExecutionRecord(next, key, { + ...outcome.record, + execution: outcome.execution, + }); + } + if (next !== get().scriptExecutions) set({ scriptExecutions: next }); + } finally { + inFlight = false; + } + }, SCRIPT_POLL_INTERVAL_MS); + + set({ scriptPollingIntervalId: intervalId }); + }, + + stopScriptPolling: () => { + const { scriptPollingIntervalId } = get(); + if (scriptPollingIntervalId) clearInterval(scriptPollingIntervalId); + set({ scriptPollingIntervalId: null }); + }, + // =========================================================================== // FAULTS ACTIONS (Diagnostic Trouble Codes) // =========================================================================== diff --git a/src/lib/types.ts b/src/lib/types.ts index 7d75b98..2115cbc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1014,3 +1014,50 @@ export interface UpdateEntry { id: string; status: UpdateStatus | null; // null = status fetch failed } + +// ============================================================================= +// Scripts +// ============================================================================= + +export type ScriptMetadata = components['schemas']['ScriptMetadata']; +export type ScriptExecution = components['schemas']['ScriptExecution']; +/** Wire wrapper of the list endpoint: {items, _links}. */ +export type ScriptList = components['schemas']['ScriptList']; +export type ScriptUploadResponse = components['schemas']['ScriptUploadResponse']; + +/** + * Entity types exposing the scripts collection. + * The gateway registers script routes for apps and components only. + */ +export type ScriptEntityType = Extract; + +/** + * Execution record tracked client-side. The gateway has no endpoint listing + * executions, so the UI remembers the ids it started. In-memory only. + */ +export interface ScriptExecutionRecord { + execution: ScriptExecution; + scriptId: string; + scriptName: string; + entityType: ScriptEntityType; + entityId: string; + /** Set when polling returned 404: the gateway no longer knows this execution. */ + lost?: boolean; +} + +export interface StartScriptExecutionRequest { + execution_type: string; + parameters?: Record; + proximity_response?: string; +} + +/** errorStatus lets the panel tell 501 from an empty list. */ +export interface ScriptsFetchResult { + items: ScriptMetadata[]; + errorStatus?: number; +} + +export interface ScriptUploadMetadata { + name?: string; + description?: string; +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 5ce1669..8bfc92c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -22,3 +22,52 @@ if (typeof globalThis.ResizeObserver === 'undefined') { disconnect(): void {} }; } + +/** + * Recent Node versions ship an experimental global `localStorage` backed by a + * file that vitest-environment-jsdom's globals never get a chance to + * override, since it already occupies the slot before the environment is + * installed. Left in place, it throws "Cannot read properties of undefined + * (reading 'setItem')" the moment anything (e.g. zustand's persist + * middleware) touches it - jsdom's own window.localStorage works fine, it is + * only the copy merged onto the global object that is broken. Replace it + * with a minimal in-memory Storage only when it is actually broken, so this + * is a no-op on Node versions where jsdom's localStorage already works. + */ +function storageWorks(): boolean { + try { + globalThis.localStorage.setItem('__storage_probe__', '1'); + globalThis.localStorage.removeItem('__storage_probe__'); + return true; + } catch { + return false; + } +} + +if (!storageWorks()) { + class MemoryStorage implements Storage { + private readonly store = new Map(); + get length(): number { + return this.store.size; + } + clear(): void { + this.store.clear(); + } + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + removeItem(key: string): void { + this.store.delete(key); + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + } + + for (const prop of ['localStorage', 'sessionStorage'] as const) { + Object.defineProperty(globalThis, prop, { configurable: true, value: new MemoryStorage() }); + } +} From 8a2b7fc5e4dfef609db4979f241e161a9e088159 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Tue, 28 Jul 2026 17:09:44 +0200 Subject: [PATCH 2/9] feat(scripts): add the Scripts tab with run, upload and in-browser editing Adds the panel, the expandable script row with its parameter form, the execution status card and the upload dialog, which can either take a file or let the user write a script in a lazily loaded CodeMirror editor. The tab appears only when the gateway reports capabilities.scripts, and only on apps and components, which are the entity types the gateway registers script routes for. Playwright ships as a development dependency here as well, ahead of the end-to-end harness that uses it. --- package-lock.json | 311 +++++++++++- package.json | 7 + src/components/AppsPanel.test.tsx | 62 +++ src/components/AppsPanel.tsx | 36 +- src/components/EntityDetailPanel.test.tsx | 54 ++- src/components/EntityDetailPanel.tsx | 25 +- src/components/EntityResourceTabs.tsx | 3 + src/components/ResourceTabs.test.tsx | 47 ++ src/components/ResourceTabs.tsx | 27 +- src/components/ScriptEditor.tsx | 92 ++++ src/components/ScriptExecutionCard.test.tsx | 255 ++++++++++ src/components/ScriptExecutionCard.tsx | 202 ++++++++ src/components/ScriptRow.test.tsx | 455 ++++++++++++++++++ src/components/ScriptRow.tsx | 274 +++++++++++ src/components/ScriptUploadDialog.test.tsx | 497 ++++++++++++++++++++ src/components/ScriptUploadDialog.tsx | 319 +++++++++++++ src/components/ScriptsPanel.test.tsx | 298 ++++++++++++ src/components/ScriptsPanel.tsx | 179 +++++++ 18 files changed, 3121 insertions(+), 22 deletions(-) create mode 100644 src/components/AppsPanel.test.tsx create mode 100644 src/components/ResourceTabs.test.tsx create mode 100644 src/components/ScriptEditor.tsx create mode 100644 src/components/ScriptExecutionCard.test.tsx create mode 100644 src/components/ScriptExecutionCard.tsx create mode 100644 src/components/ScriptRow.test.tsx create mode 100644 src/components/ScriptRow.tsx create mode 100644 src/components/ScriptUploadDialog.test.tsx create mode 100644 src/components/ScriptUploadDialog.tsx create mode 100644 src/components/ScriptsPanel.test.tsx create mode 100644 src/components/ScriptsPanel.tsx diff --git a/package-lock.json b/package-lock.json index 6624018..779d3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.6.0", "license": "Apache-2.0", "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -17,6 +20,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@selfpatch/ros2-medkit-client-ts": "^0.6.0", "@tailwindcss/vite": "^4.1.14", + "@uiw/react-codemirror": "^4.25.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -31,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.14", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -381,7 +386,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -445,6 +449,121 @@ "node": ">=18" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", + "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1306,6 +1425,63 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4010,6 +4186,59 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", + "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", + "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.11", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", @@ -4596,6 +4825,21 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4647,6 +4891,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6451,6 +6701,53 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7086,6 +7383,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7579,6 +7882,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 79ea1ce..4bede9e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -b --emitDeclarationOnly false --noEmit", @@ -31,6 +33,9 @@ ] }, "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -39,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@selfpatch/ros2-medkit-client-ts": "^0.6.0", "@tailwindcss/vite": "^4.1.14", + "@uiw/react-codemirror": "^4.25.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -53,6 +59,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.14", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/src/components/AppsPanel.test.tsx b/src/components/AppsPanel.test.tsx new file mode 100644 index 0000000..03594b0 --- /dev/null +++ b/src/components/AppsPanel.test.tsx @@ -0,0 +1,62 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AppsPanel } from './AppsPanel'; + +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); + +const mockState = { + selectEntity: vi.fn(), + configurations: new Map(), + fetchEntityData: vi.fn().mockResolvedValue([]), + fetchEntityOperations: vi.fn().mockResolvedValue([]), + listEntityFaults: vi.fn().mockResolvedValue({ items: [], count: 0 }), + scriptsSupported: false, +}; + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector) => selector(mockState)), +})); + +function renderAppsPanel(overrides: Partial = {}) { + Object.assign(mockState, { scriptsSupported: false }, overrides); + return render(); +} + +describe('AppsPanel scripts tab', () => { + beforeEach(() => vi.clearAllMocks()); + + it('hides the Scripts tab when the gateway does not report the capability', async () => { + renderAppsPanel({ scriptsSupported: false }); + // Let the mount-time loadAppData effect settle before asserting, so its + // state updates don't land after the test body returns (act() warning). + await waitFor(() => { + expect(screen.queryByText(/Loading app resources/i)).not.toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument(); + }); + + it('shows the Scripts tab and renders its content when the capability is reported', async () => { + renderAppsPanel({ scriptsSupported: true }); + await userEvent.click(screen.getByRole('button', { name: /scripts/i })); + expect(screen.getByTestId('scripts-panel')).toHaveTextContent('apps:talker'); + }); +}); diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index d840fa4..8737fb7 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { AlertTriangle, Box, ChevronRight, Cpu, Database, FileCode, Network, Settings, Zap } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; @@ -9,6 +9,7 @@ import { RESOURCE_TABS, renderResourceTabContent, isResourceTabId, + SCRIPTS_TAB, type ResourceTabId, } from '@/components/ResourceTabs'; import { EntityStatusControl } from '@/components/EntityStatusControl'; @@ -22,7 +23,7 @@ interface TabConfig { icon: typeof Database; } -const APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS]; +const BASE_APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS]; interface AppsPanelProps { appId: string; @@ -51,16 +52,29 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(false); - const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults } = useAppStore( - useShallow((state) => ({ - selectEntity: state.selectEntity, - configurations: state.configurations, - fetchEntityData: state.fetchEntityData, - fetchEntityOperations: state.fetchEntityOperations, - listEntityFaults: state.listEntityFaults, - })) + const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults, scriptsSupported } = + useAppStore( + useShallow((state) => ({ + selectEntity: state.selectEntity, + configurations: state.configurations, + fetchEntityData: state.fetchEntityData, + fetchEntityOperations: state.fetchEntityOperations, + listEntityFaults: state.listEntityFaults, + scriptsSupported: state.scriptsSupported, + })) + ); + + const appTabs = useMemo( + () => (scriptsSupported ? [...BASE_APP_TABS, SCRIPTS_TAB] : BASE_APP_TABS), + [scriptsSupported] ); + // Fall back to the default tab when the Scripts tab disappears (e.g. the + // gateway capability flips off) while it is the active tab. + useEffect(() => { + if (!scriptsSupported && activeTab === 'scripts') setActiveTab('overview'); + }, [scriptsSupported, activeTab]); + // Load app resources on mount (configurations are loaded by ConfigurationPanel) useEffect(() => { const loadAppData = async () => { @@ -147,7 +161,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI {/* Tab Navigation */}
- {APP_TABS.map((tab) => { + {appTabs.map((tab) => { const TabIcon = tab.icon; const isActive = activeTab === tab.id; let count = 0; diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx index 2437c0a..215e945 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -13,7 +13,7 @@ // limitations under the License. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { TooltipProvider } from '@/components/ui/tooltip'; import { EntityDetailPanel } from './EntityDetailPanel'; @@ -37,6 +37,11 @@ vi.mock('@/components/ResourceTabs', async () => { renderResourceTabContent: (tab: string) =>
, }; }); +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); const mockPrefetchResourceCounts = vi.fn(); const mockFetchEntityData = vi.fn(); @@ -73,6 +78,7 @@ function setStore(overrides: Record) { statusByEntity: {}, actuationByEntity: {}, watchEntityStatus: vi.fn(() => () => {}), + scriptsSupported: false, ...overrides, }; } @@ -151,3 +157,49 @@ describe('EntityDetailPanel - nested entity types', () => { expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument(); }); }); + +describe('EntityDetailPanel - scripts tab gating (component view)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 }); + mockFetchEntityData.mockResolvedValue([]); + }); + + it('hides the Scripts tab when the gateway does not report the capability', async () => { + setStore({ + selectedPath: '/server/area1/component1', + selectedEntity: { + id: 'component1', + name: 'component1', + type: 'component', + }, + scriptsSupported: false, + }); + + render( {}} />); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument(); + }); + + it('shows the Scripts tab and renders its content when the capability is reported', async () => { + setStore({ + selectedPath: '/server/area1/component1', + selectedEntity: { + id: 'component1', + name: 'component1', + type: 'component', + }, + scriptsSupported: true, + }); + + render( {}} />); + + const scriptsButton = await screen.findByRole('button', { name: /scripts/i }); + fireEvent.click(scriptsButton); + + expect(await screen.findByTestId('tab-content-scripts')).toBeInTheDocument(); + }); +}); diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index b5b39a4..6a419c5 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { Copy, @@ -24,7 +24,7 @@ import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton'; import { DataPanel } from '@/components/DataPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; -import { RESOURCE_TABS, renderResourceTabContent, type ResourceTabId } from '@/components/ResourceTabs'; +import { RESOURCE_TABS, renderResourceTabContent, SCRIPTS_TAB, type ResourceTabId } from '@/components/ResourceTabs'; import { AreasPanel } from '@/components/AreasPanel'; import { AppsPanel } from '@/components/AppsPanel'; import { FunctionsPanel } from '@/components/FunctionsPanel'; @@ -44,7 +44,7 @@ interface TabConfig { description?: string; } -const COMPONENT_TABS: TabConfig[] = RESOURCE_TABS; +const BASE_COMPONENT_TABS: TabConfig[] = RESOURCE_TABS; /** * Determine entity type for API calls based on entity type @@ -377,6 +377,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit configurations: 0, faults: 0, logs: 0, + scripts: 0, }); // Store fetched topics data for the Data tab. `null` means "not yet loaded // for the current entity" so the Data tab can render a skeleton instead of @@ -395,6 +396,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit refreshSelectedEntity, prefetchResourceCounts, fetchEntityData, + scriptsSupported, } = useAppStore( useShallow((state: AppState) => ({ selectedPath: state.selectedPath, @@ -407,9 +409,15 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit refreshSelectedEntity: state.refreshSelectedEntity, prefetchResourceCounts: state.prefetchResourceCounts, fetchEntityData: state.fetchEntityData, + scriptsSupported: state.scriptsSupported, })) ); + const componentTabs = useMemo( + () => (scriptsSupported ? [...BASE_COMPONENT_TABS, SCRIPTS_TAB] : BASE_COMPONENT_TABS), + [scriptsSupported] + ); + // Notify parent when entity is selected useEffect(() => { if (selectedPath && onEntitySelect) { @@ -417,6 +425,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit } }, [selectedPath, onEntitySelect]); + // Fall back to the default tab when the Scripts tab disappears (e.g. the + // gateway capability flips off) while it is the active tab. + useEffect(() => { + if (!scriptsSupported && activeTab === 'scripts') setActiveTab('data'); + }, [scriptsSupported, activeTab]); + // Reset the component-view resource tab to Data when the entity changes, // so switching between components doesn't show stale tab state. useEffect(() => { @@ -431,6 +445,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit configurations: 0, faults: 0, logs: 0, + scripts: 0, }; // Guard against late results from a previous entity overwriting the // current entity's state. The cleanup aborts in-flight requests AND @@ -483,7 +498,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit setTopicsData(fetchedData); // Use the already-fetched data length instead of a separate request - setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 }); + setResourceCounts({ ...counts, data: fetchedData.length, logs: 0, scripts: 0 }); } catch { if (cancelled) return; // On unexpected failure fall back to "loaded empty" so the UI @@ -821,7 +836,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit {isComponent && (
- {COMPONENT_TABS.map((tab) => { + {componentTabs.map((tab) => { const TabIcon = tab.icon; const isActive = activeTab === tab.id; const count = resourceCounts[tab.id]; diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 8aba64b..0ea967a 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -23,6 +23,7 @@ interface LoadedResources { configurations: boolean; faults: boolean; logs: boolean; + scripts: boolean; } /** @@ -40,6 +41,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate configurations: false, faults: false, logs: false, + scripts: false, }); const loadedTabsRef = useRef(loadedTabs); loadedTabsRef.current = loadedTabs; @@ -141,6 +143,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate configurations: false, faults: false, logs: false, + scripts: false, }; setActiveTab('data'); setLoadedTabs(reset); diff --git a/src/components/ResourceTabs.test.tsx b/src/components/ResourceTabs.test.tsx new file mode 100644 index 0000000..2aacf5f --- /dev/null +++ b/src/components/ResourceTabs.test.tsx @@ -0,0 +1,47 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { isResourceTabId, renderResourceTabContent, RESOURCE_TABS, SCRIPTS_TAB } from './ResourceTabs'; + +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); + +describe('isResourceTabId', () => { + it('accepts scripts', () => expect(isResourceTabId('scripts')).toBe(true)); + it('rejects unknown ids', () => expect(isResourceTabId('nope')).toBe(false)); +}); + +describe('RESOURCE_TABS', () => { + it('does not include scripts so areas and functions never show it', () => { + expect(RESOURCE_TABS.map((t) => t.id)).toEqual(['data', 'operations', 'configurations', 'faults', 'logs']); + expect(SCRIPTS_TAB.id).toBe('scripts'); + }); +}); + +describe('renderResourceTabContent for scripts', () => { + it.each(['apps', 'components'] as const)('renders the scripts panel for %s', (entityType) => { + render(<>{renderResourceTabContent('scripts', 'e1', entityType)}); + expect(screen.getByTestId('scripts-panel')).toHaveTextContent(`${entityType}:e1`); + }); + + it.each(['areas', 'functions'] as const)('renders nothing for %s', (entityType) => { + const { container } = render(<>{renderResourceTabContent('scripts', 'e1', entityType)}); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/ResourceTabs.tsx b/src/components/ResourceTabs.tsx index 7fbd5c2..59eb680 100644 --- a/src/components/ResourceTabs.tsx +++ b/src/components/ResourceTabs.tsx @@ -13,11 +13,12 @@ // limitations under the License. import type { ReactNode } from 'react'; -import { AlertTriangle, Database, ScrollText, Settings, Zap } from 'lucide-react'; +import { AlertTriangle, Database, ScrollText, Settings, Terminal, Zap } from 'lucide-react'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; import { LogsPanel } from '@/components/LogsPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; +import { ScriptsPanel } from '@/components/ScriptsPanel'; import type { SovdResourceEntityType } from '@/lib/types'; /** @@ -32,11 +33,11 @@ import type { SovdResourceEntityType } from '@/lib/types'; * rendering stays per-panel because each entity type displays data * differently (apps: topics list, components: DataTabContent grid, * areas: aggregated grid). The helper `renderResourceTabContent` below - * therefore only handles operations / configurations / faults / logs; + * therefore only handles operations / configurations / faults / logs / scripts; * callers are responsible for rendering their own data tab content. */ -export type ResourceTabId = 'data' | 'operations' | 'configurations' | 'faults' | 'logs'; +export type ResourceTabId = 'data' | 'operations' | 'configurations' | 'faults' | 'logs' | 'scripts'; export interface ResourceTabConfig { id: ResourceTabId; @@ -52,8 +53,22 @@ export const RESOURCE_TABS: ResourceTabConfig[] = [ { id: 'logs', label: 'Logs', icon: ScrollText }, ]; +/** + * Not part of RESOURCE_TABS: script routes exist for apps and components only, + * and the tab additionally requires the gateway to report capabilities.scripts. + * Panels append it themselves. + */ +export const SCRIPTS_TAB: ResourceTabConfig = { id: 'scripts', label: 'Scripts', icon: Terminal }; + export function isResourceTabId(id: string): id is ResourceTabId { - return id === 'data' || id === 'operations' || id === 'configurations' || id === 'faults' || id === 'logs'; + return ( + id === 'data' || + id === 'operations' || + id === 'configurations' || + id === 'faults' || + id === 'logs' || + id === 'scripts' + ); } /** @@ -76,6 +91,10 @@ export function renderResourceTabContent( return ; case 'logs': return ; + case 'scripts': + return entityType === 'apps' || entityType === 'components' ? ( + + ) : null; case 'data': return null; } diff --git a/src/components/ScriptEditor.tsx b/src/components/ScriptEditor.tsx new file mode 100644 index 0000000..253dbef --- /dev/null +++ b/src/components/ScriptEditor.tsx @@ -0,0 +1,92 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useMemo, useState } from 'react'; +import CodeMirror, { EditorView } from '@uiw/react-codemirror'; +import type { Extension } from '@uiw/react-codemirror'; +import { python } from '@codemirror/lang-python'; +import { StreamLanguage } from '@codemirror/language'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { languageForFilename } from '@/lib/script-language'; + +interface ScriptEditorProps { + value: string; + onChange: (value: string) => void; + filename: string; + /** + * Accessible name for the editor's contenteditable region. CodeMirror's + * content element is the one that actually carries the textbox role, so + * a wrapping