diff --git a/eslint.config.mjs b/eslint.config.mjs index 4b5f654bd..e0835de2c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -74,6 +74,24 @@ export default tseslint.config( '@typescript-eslint/unbound-method': 'warn', 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', + // `crypto.randomUUID` is secure-context-only. autonomy-node serves the web + // bundle over plain HTTP, so on a node reached by IP the global is absent and + // the call throws — which is how a build in the web editor once aborted in + // silence. openplc-editor's own renderer always has a secure context, but + // `frontend/` and `middleware/shared/` are byte-identical across the two + // repos, so a call added in either one ships that silent failure in the web + // bundle. One guarded call site did not stop the other 62 from shipping + // unguarded; the rule is what holds the line. Every mint goes through + // `newUuid()`, which names this API nowhere, so the rule needs no exception. + 'no-restricted-properties': [ + 'error', + { + object: 'crypto', + property: 'randomUUID', + message: + 'crypto.randomUUID does not exist outside a secure context (autonomy-node serves over plain HTTP). Use newUuid() from frontend/utils/new-uuid.ts, or let the owning store mint the id.', + }, + ], }, }, eslintConfigPrettier, diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx index d0f3ebe61..f961225f9 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx @@ -12,6 +12,7 @@ import { useOpenPLCStore } from '../../../../../store' import type { CreateGraphicalVariableModalData } from '../../../../../store/slices/modal/types' import { cn } from '../../../../../utils/cn' import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords' +import { newUuid } from '../../../../../utils/new-uuid' import type { BoundBlockPin } from '../../../../../utils/PLC/validate-variable-type' import { isGenericTypeName } from '../../../../../utils/PLC/validate-variable-type' import { toast } from '../../../../_features/[app]/toast/use-toast' @@ -260,7 +261,7 @@ const FBDBlockAutoComplete = forwardRef { const newBlock = buildGenericNode({ - id: crypto.randomUUID(), + id: newUuid(), position: block.positionAbsoluteX && block.positionAbsoluteY ? { x: block.positionAbsoluteX, y: block.positionAbsoluteY + (block.height ?? 0) + 16 } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index bd9669f33..1430acd32 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -1,5 +1,4 @@ import { FocusEvent, memo, useEffect, useMemo, useRef, useState } from 'react' -import { v4 as uuidv4 } from 'uuid' import type { PLCVariable } from '../../../../../middleware/shared/ports/types' import { RefreshIcon } from '../../../../assets/icons/interface/Refresh' @@ -12,6 +11,7 @@ import { rewireInOutReads, } from '../../../../utils/graphical/in-out-pin-rules' import { isLegalIdentifier } from '../../../../utils/keywords' +import { newUuid } from '../../../../utils/new-uuid' import { toast } from '../../../_features/[app]/toast/use-toast' import { useBoundEditorModel, useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' import { HighlightedTextArea } from '../../highlighted-textarea' @@ -228,7 +228,7 @@ export const BlockNodeElement = ({ * The new block node have a new ID to not conflict with the old block node and to no occur any error of rendering */ const newBlockNode = buildBlockNode({ - id: `BLOCK_${crypto.randomUUID()}`, + id: `BLOCK_${newUuid()}`, position: { x: node.position.x, y: node.position.y, @@ -554,7 +554,7 @@ const Block = (block: BlockProps) => { const creationResult = createVariable({ data: { - id: uuidv4(), + id: newUuid(), name: variableNameToSubmit, type: { definition: 'derived', value: blockType }, class: 'local', @@ -660,7 +660,7 @@ const Block = (block: BlockProps) => { } const updatedNewNode = buildBlockNode({ - id: `BLOCK_${crypto.randomUUID()}`, + id: `BLOCK_${newUuid()}`, position: { x: node.position.x, y: node.position.y, @@ -792,7 +792,6 @@ const Block = (block: BlockProps) => { // has to be told rather than left to notice a missing connection later. if (reads.rewired > 0 || reads.dropped > 0) { addLog({ - id: crypto.randomUUID(), level: reads.dropped > 0 ? 'warning' : 'info', message: `${blockVariantName}: VAR_IN_OUT pins no longer have an output side. ` + diff --git a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx index 5b136687e..60a3ab847 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx @@ -600,7 +600,6 @@ const RemoteDeviceEditor = () => { setSerialPortOptions(options) } else { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `Failed to fetch serial ports: ${result.error || 'Unknown error'}`, }) @@ -608,7 +607,6 @@ const RemoteDeviceEditor = () => { } } catch (error) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `Error fetching serial ports: ${getErrorMessage(error)}`, }) diff --git a/src/frontend/components/_organisms/plc-logs/index.tsx b/src/frontend/components/_organisms/plc-logs/index.tsx index ac69f67d2..105652528 100644 --- a/src/frontend/components/_organisms/plc-logs/index.tsx +++ b/src/frontend/components/_organisms/plc-logs/index.tsx @@ -4,6 +4,7 @@ import { memo, useEffect, useMemo, useRef } from 'react' import { isV4Logs, RuntimeLogEntry, RuntimeLogLevel } from '../../../../middleware/shared/ports' import { useOpenPLCStore } from '../../../store' import formatTimestamp from '../../../utils/format-timestamp' +import { newUuid } from '../../../utils/new-uuid' import { LogComponent, LogLevel } from '../console/log' const mapV4LevelToLogLevel = (level: RuntimeLogLevel): LogLevel => { @@ -70,7 +71,7 @@ const PlcLogs = memo(() => { // Generate stable UUIDs for v3 log lines const getV3LogKey = (index: number): string => { if (!v3LogKeysRef.current.has(index)) { - v3LogKeysRef.current.set(index, crypto.randomUUID()) + v3LogKeysRef.current.set(index, newUuid()) } return v3LogKeysRef.current.get(index)! } diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index c15de0d05..16cbeba25 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -160,7 +160,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (!useOpenPLCStore.getState().workspace.isDebuggerVisible) return addLog({ - id: crypto.randomUUID(), level: 'warning', message: 'Device disconnected — stopping the debug session (serial debugging runs over the device connection).', }) @@ -176,7 +175,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (wasSimulator && !isSimulatorBoard && simulator.isRunning()) { addLog({ - id: crypto.randomUUID(), level: 'info', message: 'Board changed from simulator. Stopping simulator.', }) @@ -278,7 +276,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } if (!stopResult.success) { addLog({ - id: crypto.randomUUID(), level: 'error', message: `Failed to stop PLC: ${stopResult.error ?? 'Unknown error'}`, }) @@ -286,11 +283,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } useOpenPLCStore.getState().deviceActions.setPlcRuntimeStatus('STOPPED') - addLog({ id: crypto.randomUUID(), level: 'info', message: 'PLC stopped before build.' }) + addLog({ level: 'info', message: 'PLC stopped before build.' }) } } - addLog({ id: crypto.randomUUID(), level: 'info', message: 'Build process started' }) + addLog({ level: 'info', message: 'Build process started' }) // Compile-time alias resolution: snapshot the project with every // variable's `location` resolved to a concrete IEC address (alias name @@ -364,7 +361,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa void simulator.loadFirmware(event.firmwarePath).then((loadResult) => { if (loadResult.success) { setSimulatorRunning(true) - addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) + addLog({ level: 'info', message: 'Simulator is running.' }) if (pendingSimulatorDebugRef.current) { pendingSimulatorDebugRef.current = false // Rides the emulator's session, so it ends when the emulator @@ -377,7 +374,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } else { pendingSimulatorDebugRef.current = false addLog({ - id: crypto.randomUUID(), level: 'error', message: `Failed to start simulator: ${loadResult.error ?? 'Unknown error'}`, }) @@ -388,7 +384,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ) if (!result.success && !streamedError) { - addLog({ id: crypto.randomUUID(), level: 'error', message: result.error ?? 'Compilation failed' }) + addLog({ level: 'error', message: result.error ?? 'Compilation failed' }) } // Serial handoff (D72): if we released a held device connection for this @@ -417,7 +413,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } } catch (err: unknown) { - addLog({ id: crypto.randomUUID(), level: 'error', message: `Build error: ${getErrorMessage(err)}` }) + addLog({ level: 'error', message: `Build error: ${getErrorMessage(err)}` }) } finally { setIsCompiling(false) } @@ -478,7 +474,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (!compiler.compileLibrary) { addLog({ - id: crypto.randomUUID(), level: 'error', message: 'Current platform does not implement library builds.', }) @@ -487,7 +482,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa setIsCompiling(true) addLog({ - id: crypto.randomUUID(), level: 'info', message: overrides?.cleanBuild ? 'Library build started (clean)' : 'Library build started', }) @@ -498,7 +492,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa (event) => { if (!event.message) return addLog({ - id: crypto.randomUUID(), level: event.level === 'error' || event.stage === 'error' ? 'error' : 'info', message: event.message, }) @@ -506,20 +499,17 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ) if (!result.success) { addLog({ - id: crypto.randomUUID(), level: 'error', message: result.error ?? 'Library build failed.', }) } else if (result.verification && !result.verification.success) { addLog({ - id: crypto.randomUUID(), level: 'warning', message: `Library built, but verification reported: ${result.verification.message ?? 'unknown'}`, }) } } catch (err) { addLog({ - id: crypto.randomUUID(), level: 'error', message: `Library build error: ${getErrorMessage(err)}`, }) @@ -594,7 +584,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (result.unsupported) { addLog({ - id: crypto.randomUUID(), level: 'info', message: 'This firmware predates run/stop control. Rebuild and upload the program to enable Start/Stop.', }) @@ -608,7 +597,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } if (!result.success) { addLog({ - id: crypto.randomUUID(), level: 'error', message: `Failed to ${wantRun ? 'start' : 'stop'} PLC: ${result.error ?? 'Unknown error'}`, }) @@ -629,7 +617,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ) } } catch (error: unknown) { - addLog({ id: crypto.randomUUID(), level: 'error', message: `PLC control error: ${getErrorMessage(error)}` }) + addLog({ level: 'error', message: `PLC control error: ${getErrorMessage(error)}` }) } }, [ deviceDefinitions.configuration.deviceBoard, @@ -681,7 +669,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa debugSessionRidesDeviceRef.current = false setSimulatorRunning(false) - addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator stopped.' }) + addLog({ level: 'info', message: 'Simulator stopped.' }) } else { pendingSimulatorDebugRef.current = true handleBuildRef.current().catch(() => { @@ -690,7 +678,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } catch (error: unknown) { pendingSimulatorDebugRef.current = false - addLog({ id: crypto.randomUUID(), level: 'error', message: `Simulator control error: ${getErrorMessage(error)}` }) + addLog({ level: 'error', message: `Simulator control error: ${getErrorMessage(error)}` }) } }, [debugSession, simulator, simulatorRunning, addLog]) @@ -711,12 +699,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ['Yes', 'No'], ) if (response === 1) { - consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Debugger session cancelled.' }) + consoleActions.addLog({ level: 'info', message: 'Debugger session cancelled.' }) setIsDebuggerProcessing(false) return } - consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting PLC...' }) + consoleActions.addLog({ level: 'info', message: 'Starting PLC...' }) const startResult = (await debuggerPort.setPlcState?.('RUNNING')) ?? { success: false, error: 'This target does not support run/stop control', @@ -736,7 +724,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } // Read local MD5 - consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Verifying program MD5...' }) + consoleActions.addLog({ level: 'info', message: 'Verifying program MD5...' }) const md5Result = await debuggerPort.readProgramMd5(projectPath, boardTarget) if (!md5Result.success || !md5Result.md5) { await showDeviceDialog('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) @@ -773,7 +761,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } if (verifyResult.match) { - consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) + consoleActions.addLog({ level: 'info', message: 'MD5 verified. Starting debugger...' }) // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to @@ -787,7 +775,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa await debuggerPort.disconnect() consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `MD5 mismatch. Target: ${verifyResult.targetMd5}, Expected: ${md5Result.md5}`, }) @@ -816,7 +803,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ) if (compileResult.success) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: 'Upload completed. Re-verifying...', }) @@ -824,7 +810,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa void handleMd5Verification(projectPath, boardTarget, isRuntimeTarget) } else { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `Upload failed: ${compileResult.error ?? 'Unknown error'}`, }) @@ -837,7 +822,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } catch (error: unknown) { await debuggerPort.disconnect() consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `MD5 verification error: ${getErrorMessage(error)}`, }) @@ -904,7 +888,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // login — all three publish the same status. const sessionStatus = useOpenPLCStore.getState().deviceConnection.status addLog({ - id: crypto.randomUUID(), level: 'info', message: `[connection] debug session requested for ${boardTarget}; session is "${sessionStatus}"`, }) @@ -928,14 +911,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // addresses first (same pre-compile snapshot the build/upload paths // use) — the compiler only understands `%…` literals, not alias names. const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() - consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting debug compilation...' }) + consoleActions.addLog({ level: 'info', message: 'Starting debug compilation...' }) const debugCompileResult = await compiler.compileForDebug( { projectData: freshProjectData, boardTarget, projectPath }, (event) => logCompilerEvent(event, consoleActions.addLog), ) if (!debugCompileResult.success) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `Debug compilation failed: ${debugCompileResult.error ?? 'Unknown error'}`, }) @@ -949,7 +931,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa void handleMd5Verification(projectPath, boardTarget, isRuntime) } catch (error: unknown) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `Debugger init error: ${getErrorMessage(error)}`, }) diff --git a/src/frontend/hooks/use-device-connection-monitor.ts b/src/frontend/hooks/use-device-connection-monitor.ts index b866df4ef..60b836bc3 100644 --- a/src/frontend/hooks/use-device-connection-monitor.ts +++ b/src/frontend/hooks/use-device-connection-monitor.ts @@ -59,7 +59,6 @@ const useRuntimeSession = (): void => { // command answered "not connected" on a target the user had just uploaded to. if (!address) { store.consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: '[connection] runtime is connected but has no address recorded; no session opened', }) @@ -68,7 +67,6 @@ const useRuntimeSession = (): void => { const debugChannel = resolveRuntimeDebugChannel(boardTarget, boardInfo) if (!debugChannel) { store.consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `[connection] no debug channel could be described for ${boardTarget}; debugging will not be available`, }) @@ -78,7 +76,6 @@ const useRuntimeSession = (): void => { void device.openRuntimeSession({ address, debug: debugChannel }).then((result) => { if (!result.success) { store.consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`, }) @@ -106,7 +103,7 @@ export const useDeviceConnectionMonitor = (): void => { useEffect(() => { if (!device.onLinkLog) return return device.onLinkLog((message) => { - addLog({ id: crypto.randomUUID(), level: 'info', message: `[connection] ${message}` }) + addLog({ level: 'info', message: `[connection] ${message}` }) }) }, [device, addLog]) diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index d02334bcc..1cd28486b 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -188,7 +188,6 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void currentBatchSize = Math.max(MIN_BATCH_SIZE, Math.floor(currentBatchSize / 2)) batchSizeRef.current = currentBatchSize consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `Reduced debug batch size to ${currentBatchSize} due to runtime memory error.`, }) diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index aff9c47ea..121c577b7 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -64,13 +64,13 @@ export function useDebugSession(): UseDebugSessionReturn { const boardTarget = deviceDefinitions.configuration.deviceBoard const projectPath = project.meta.path - logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) + logActions.addLog({ level: 'info', message: 'Connecting debugger...' }) try { const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) if (!debugFileResult.success || !debugFileResult.content) { const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + logActions.addLog({ level: 'error', message: error }) return { success: false, error } } @@ -81,13 +81,12 @@ export function useDebugSession(): UseDebugSessionReturn { const debugMap = parseDebugMap(debugFileResult.content) if (!debugMap) { const error = 'Invalid debug-map.json (expected schema version 2)' - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + logActions.addLog({ level: 'error', message: error }) return { success: false, error } } const entriesForTree = debugMapToEntries(debugMap) logActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, }) @@ -116,17 +115,15 @@ export function useDebugSession(): UseDebugSessionReturn { } for (const w of treeResult.warnings) { - logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) + logActions.addLog({ level: 'warning', message: w }) } logActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, }) } catch { logActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: 'Debug tree builder encountered errors.', }) @@ -144,7 +141,6 @@ export function useDebugSession(): UseDebugSessionReturn { const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) if (fbTypesCount > 0) { logActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, }) @@ -154,7 +150,7 @@ export function useDebugSession(): UseDebugSessionReturn { const connectResult = await debuggerPort.connect() if (!connectResult.success) { const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + logActions.addLog({ level: 'error', message: error }) return { success: false, error } } @@ -183,7 +179,6 @@ export function useDebugSession(): UseDebugSessionReturn { // medium was not yet known silently poll as if it were the simulator. wsActions.setDebuggerVisible(true) logActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `Debugger connected. Found ${indexMap.size} debug variables.`, }) @@ -191,7 +186,7 @@ export function useDebugSession(): UseDebugSessionReturn { return { success: true } } catch (err: unknown) { const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + logActions.addLog({ level: 'error', message: error }) return { success: false, error } } }, [debuggerPort, deviceDefinitions, projectData, projectMeta]) @@ -219,7 +214,6 @@ export function useDebugSession(): UseDebugSessionReturn { valueBuffer = encodeForceValue(value ?? '0', type ?? 'BOOL', enumValues) } catch (err) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `Force input error: ${err instanceof Error ? err.message : String(err)}`, }) @@ -229,14 +223,12 @@ export function useDebugSession(): UseDebugSessionReturn { const result = await debuggerPort.setVariable(index, force, valueBuffer) if (result.success) { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: 'Variable force applied successfully', }) return true } else { consoleActions.addLog({ - id: crypto.randomUUID(), level: 'error', message: `Failed to set variable: ${result.error}`, }) diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index d219e63e4..aa121ed31 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -454,7 +454,6 @@ const WorkspaceScreen = () => { if (cancelled) return for (const packageId of removed) { addLog({ - id: crypto.randomUUID(), level: 'warning', message: `Removed untrusted VPP package "${packageId}": its signature is missing or invalid.`, }) diff --git a/src/frontend/services/device-link-resolution.ts b/src/frontend/services/device-link-resolution.ts index c882b7d17..2c5c76374 100644 --- a/src/frontend/services/device-link-resolution.ts +++ b/src/frontend/services/device-link-resolution.ts @@ -180,7 +180,6 @@ async function handleInteractiveOutcome(outcome: InteractiveOutcome, boardTarget */ function trace(message: string): void { useOpenPLCStore.getState().consoleActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `[connection] ${message}`, }) diff --git a/src/frontend/store/__tests__/console-slice.test.ts b/src/frontend/store/__tests__/console-slice.test.ts index 1fe08ac4c..5aa03b1f9 100644 --- a/src/frontend/store/__tests__/console-slice.test.ts +++ b/src/frontend/store/__tests__/console-slice.test.ts @@ -10,7 +10,6 @@ function makeStore() { function makeLog(overrides?: Partial): LogObject { return { - id: overrides?.id ?? 'log-1', level: overrides?.level ?? 'info', message: overrides?.message ?? 'Test message', tstamp: overrides?.tstamp, @@ -47,12 +46,10 @@ describe('createConsoleSlice', () => { // addLog // ------------------------------------------------------------------------- it('addLog appends a log entry', () => { - const log = makeLog({ id: 'log-1', message: 'Hello' }) - store.getState().consoleActions.addLog(log) + store.getState().consoleActions.addLog(makeLog({ message: 'Hello' })) const { logs } = store.getState() expect(logs).toHaveLength(1) - expect(logs[0].id).toBe('log-1') expect(logs[0].message).toBe('Hello') expect(logs[0].level).toBe('info') }) @@ -77,19 +74,16 @@ describe('createConsoleSlice', () => { }) it('addLog appends multiple logs in order', () => { - store.getState().consoleActions.addLog(makeLog({ id: 'a', message: 'first' })) - store.getState().consoleActions.addLog(makeLog({ id: 'b', message: 'second' })) - store.getState().consoleActions.addLog(makeLog({ id: 'c', message: 'third' })) + store.getState().consoleActions.addLog(makeLog({ message: 'first' })) + store.getState().consoleActions.addLog(makeLog({ message: 'second' })) + store.getState().consoleActions.addLog(makeLog({ message: 'third' })) const { logs } = store.getState() - expect(logs).toHaveLength(3) - expect(logs[0].id).toBe('a') - expect(logs[1].id).toBe('b') - expect(logs[2].id).toBe('c') + expect(logs.map((l) => l.message)).toEqual(['first', 'second', 'third']) }) it('addLog handles log without level', () => { - store.getState().consoleActions.addLog({ id: 'no-level', message: 'bare' }) + store.getState().consoleActions.addLog({ message: 'bare' }) const { logs } = store.getState() expect(logs).toHaveLength(1) @@ -97,42 +91,45 @@ describe('createConsoleSlice', () => { }) // ------------------------------------------------------------------------- - // removeLog + // Entry ids — minted by the slice, never by the caller. The list owns its + // own React keys; a caller logging "Build process started" does not. // ------------------------------------------------------------------------- - it('removeLog removes a log by id', () => { - store.getState().consoleActions.addLog(makeLog({ id: 'keep' })) - store.getState().consoleActions.addLog(makeLog({ id: 'remove' })) - store.getState().consoleActions.addLog(makeLog({ id: 'also-keep' })) + it('addLog mints an id the caller never supplied', () => { + store.getState().consoleActions.addLog(makeLog({ message: 'Build process started' })) - store.getState().consoleActions.removeLog('remove') - - const { logs } = store.getState() - expect(logs).toHaveLength(2) - expect(logs.map((l) => l.id)).toEqual(['keep', 'also-keep']) + const [log] = store.getState().logs + expect(log.id).toEqual(expect.any(String)) + expect(log.id).not.toBe('') }) - it('removeLog does nothing when id does not exist', () => { - store.getState().consoleActions.addLog(makeLog({ id: 'existing' })) + it('addLog gives identical messages distinct ids', () => { + const { addLog } = store.getState().consoleActions + addLog(makeLog({ message: 'same' })) + addLog(makeLog({ message: 'same' })) + addLog(makeLog({ message: 'same' })) - store.getState().consoleActions.removeLog('nonexistent') - - const { logs } = store.getState() - expect(logs).toHaveLength(1) - expect(logs[0].id).toBe('existing') + const ids = store.getState().logs.map((l) => l.id) + expect(new Set(ids).size).toBe(3) }) - it('removeLog on empty logs array does not throw', () => { - expect(() => store.getState().consoleActions.removeLog('anything')).not.toThrow() - expect(store.getState().logs).toEqual([]) + it('addLog does not reuse an id after clearLogs', () => { + const { addLog, clearLogs } = store.getState().consoleActions + addLog(makeLog({ message: 'before' })) + const beforeId = store.getState().logs[0].id + + clearLogs() + addLog(makeLog({ message: 'after' })) + + expect(store.getState().logs[0].id).not.toBe(beforeId) }) // ------------------------------------------------------------------------- // clearLogs // ------------------------------------------------------------------------- it('clearLogs removes all logs', () => { - store.getState().consoleActions.addLog(makeLog({ id: '1' })) - store.getState().consoleActions.addLog(makeLog({ id: '2' })) - store.getState().consoleActions.addLog(makeLog({ id: '3' })) + store.getState().consoleActions.addLog(makeLog()) + store.getState().consoleActions.addLog(makeLog()) + store.getState().consoleActions.addLog(makeLog()) store.getState().consoleActions.clearLogs() @@ -147,7 +144,7 @@ describe('createConsoleSlice', () => { it('clearLogs does not affect filters', () => { store.getState().consoleActions.setLevelFilter('debug', false) store.getState().consoleActions.setSearchTerm('query') - store.getState().consoleActions.addLog(makeLog({ id: '1' })) + store.getState().consoleActions.addLog(makeLog()) store.getState().consoleActions.clearLogs() @@ -258,7 +255,7 @@ describe('createConsoleSlice', () => { }) it('requestConsoleFollow does not affect logs or filters', () => { - store.getState().consoleActions.addLog({ id: '1', level: 'info', message: 'kept' }) + store.getState().consoleActions.addLog({ level: 'info', message: 'kept' }) store.getState().consoleActions.setSearchTerm('term') store.getState().consoleActions.requestConsoleFollow() @@ -271,37 +268,52 @@ describe('createConsoleSlice', () => { // Carriage-return redraws — a progress bar must stay on one line. // ------------------------------------------------------------------------- describe('addLog with a carriage-return redraw', () => { - const frame = (id: string, message: string, transient = true) => - [{ id, level: 'info' as const, message, transient }, { redraw: true }] as const + const frame = (message: string, transient = true) => + [{ level: 'info' as const, message, transient }, { redraw: true }] as const it('overwrites the open line instead of appending', () => { const { addLog } = store.getState().consoleActions - addLog(...frame('1', 'Downloading 10%')) - addLog(...frame('2', 'Downloading 60%')) - addLog(...frame('3', 'Downloading 100%')) + addLog(...frame('Downloading 10%')) + addLog(...frame('Downloading 60%')) + addLog(...frame('Downloading 100%')) expect(store.getState().logs).toHaveLength(1) expect(store.getState().logs[0].message).toBe('Downloading 100%') }) + // A progress bar redraws many times a second. Handing each frame a fresh + // id would make React tear the line's node down and rebuild it every + // frame; keeping the replaced entry's id updates it in place instead. + it('keeps the overwritten line id so React updates the node in place', () => { + const { addLog } = store.getState().consoleActions + addLog(...frame('Downloading 10%')) + const openLineId = store.getState().logs[0].id + + addLog(...frame('Downloading 60%')) + addLog(...frame('Downloading 100%')) + + expect(store.getState().logs[0].id).toBe(openLineId) + }) + it('starts a new line once a newline has committed the previous one', () => { const { addLog } = store.getState().consoleActions - addLog(...frame('1', 'Downloading A 50%')) + addLog(...frame('Downloading A 50%')) // The frame that arrived with a trailing newline: it still overwrites, // but closes the line behind it. - addLog(...frame('2', 'Downloading A done', false)) - addLog(...frame('3', 'Downloading B 50%')) + addLog(...frame('Downloading A done', false)) + addLog(...frame('Downloading B 50%')) const { logs } = store.getState() expect(logs).toHaveLength(2) expect(logs[0].message).toBe('Downloading A done') expect(logs[1].message).toBe('Downloading B 50%') + expect(logs[0].id).not.toBe(logs[1].id) }) it('never overwrites an ordinary log line', () => { const { addLog } = store.getState().consoleActions - addLog({ id: '1', level: 'info', message: 'Compiling...' }) - addLog(...frame('2', 'Downloading 10%')) + addLog({ level: 'info', message: 'Compiling...' }) + addLog(...frame('Downloading 10%')) expect(store.getState().logs).toHaveLength(2) expect(store.getState().logs[0].message).toBe('Compiling...') @@ -309,8 +321,8 @@ describe('createConsoleSlice', () => { it('appends when the write is not a redraw, even above an open line', () => { const { addLog } = store.getState().consoleActions - addLog(...frame('1', 'Downloading 10%')) - addLog({ id: '2', level: 'info', message: 'Unrelated output' }) + addLog(...frame('Downloading 10%')) + addLog({ level: 'info', message: 'Unrelated output' }) expect(store.getState().logs).toHaveLength(2) }) @@ -324,7 +336,6 @@ describe('createConsoleSlice', () => { it('stores clean text and keeps the styling in segments', () => { store.getState().consoleActions.addLog({ - id: '1', level: 'info', message: `${ESC}[93marduino:avr${ESC}[0m 1.8.8`, }) @@ -336,7 +347,7 @@ describe('createConsoleSlice', () => { }) it('leaves uncoloured logs exactly as they were — no segments allocated', () => { - store.getState().consoleActions.addLog({ id: '1', level: 'info', message: 'plain' }) + store.getState().consoleActions.addLog({ level: 'info', message: 'plain' }) const [log] = store.getState().logs expect(log.message).toBe('plain') diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 3a2779100..53a5e229a 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -2179,7 +2179,7 @@ describe('createSharedSlice', () => { describe('clearStatesOnCloseProject', () => { it('resets all slice states', () => { store.getState().pouActions.create({ type: 'program', name: 'TestPou', language: 'st' }) - store.getState().consoleActions.addLog({ id: '1', level: 'info', message: 'test' }) + store.getState().consoleActions.addLog({ level: 'info', message: 'test' }) store.getState().sharedWorkspaceActions.clearStatesOnCloseProject() diff --git a/src/frontend/store/slices/console/slice.ts b/src/frontend/store/slices/console/slice.ts index afaeca186..bce0d38f3 100644 --- a/src/frontend/store/slices/console/slice.ts +++ b/src/frontend/store/slices/console/slice.ts @@ -1,9 +1,8 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { LogObject } from '../../../../middleware/shared/ports/types' import { hasAnsi, parseAnsi, stripAnsi } from '../../../utils/terminal-output' -import type { ConsoleSlice } from './types' +import type { ConsoleSlice, LogEntry } from './types' /** * Split any SGR colour off the raw text. @@ -13,11 +12,22 @@ import type { ConsoleSlice } from './types' * only when there was any. Uncoloured logs (the overwhelming majority) keep * the exact shape they had before and allocate nothing extra. */ -function normalizeLogEntry(log: LogObject): LogObject { +function normalizeLogEntry(log: LogEntry): LogEntry { if (!hasAnsi(log.message)) return log return { ...log, message: stripAnsi(log.message), segments: parseAnsi(log.message) } } +/** + * Entry ids are React keys for one in-memory list, nothing more: the store is + * never persisted, and no code outside the console renderer reads an id. A + * sequence is therefore both sufficient and better than a UUID — it can't + * collide, costs nothing, and reads legibly in a test failure. It deliberately + * does NOT reset on `clearLogs`, so a cleared line can never share a key with + * a later one while React still holds the old nodes. + */ +let logSequence = 0 +const nextLogId = () => `log-${++logSequence}` + const createConsoleSlice: StateCreator = (setState) => ({ logs: [], filters: { @@ -35,17 +45,23 @@ const createConsoleSlice: StateCreator = (se addLog: (log, options) => { setState( produce((state: ConsoleSlice) => { - const entry = normalizeLogEntry({ - ...log, - tstamp: log.tstamp ?? new Date(), - }) - // A carriage-return redraw overwrites the in-place line a terminal // would still have the cursor on, instead of stacking another // entry. That collapses a whole download's worth of progress // frames into one live-updating line. const lastIndex = state.logs.length - 1 - if (options?.redraw && state.logs[lastIndex]?.transient) { + const overwritten = options?.redraw && state.logs[lastIndex]?.transient ? state.logs[lastIndex] : undefined + + const entry = normalizeLogEntry({ + ...log, + // A redraw keeps the replaced line's id so React updates that node + // in place rather than unmounting and remounting it on every + // progress frame. + id: overwritten?.id ?? nextLogId(), + tstamp: log.tstamp ?? new Date(), + }) + + if (overwritten) { state.logs[lastIndex] = entry return } @@ -53,13 +69,6 @@ const createConsoleSlice: StateCreator = (se }), ) }, - removeLog: (id) => { - setState( - produce((state: ConsoleSlice) => { - state.logs = state.logs.filter((log) => log.id !== id) - }), - ) - }, clearLogs: () => { setState( produce((state: ConsoleSlice) => { diff --git a/src/frontend/store/slices/console/types.ts b/src/frontend/store/slices/console/types.ts index da8d33ca8..f308763aa 100644 --- a/src/frontend/store/slices/console/types.ts +++ b/src/frontend/store/slices/console/types.ts @@ -10,8 +10,16 @@ export type ConsoleFilters = { timestampFormat: TimestampFormat } +/** + * A log as the store holds it: the caller's {@link LogObject} plus the `id` + * the slice minted for it. The id exists only so the console's list has a + * stable React key — nothing outside the renderer reads it, which is why + * callers neither supply nor see it. + */ +export type LogEntry = LogObject & { id: string } + export type ConsoleState = { - logs: LogObject[] + logs: LogEntry[] filters: ConsoleFilters // Monotonic nonce bumped each time something (e.g. a build start) wants the // console to become visible and re-attach to the tail. Consumers compare it @@ -34,7 +42,6 @@ export type AddLogOptions = { export type ConsoleActions = { addLog: (log: LogObject, options?: AddLogOptions) => void - removeLog: (id: string) => void clearLogs: () => void setLevelFilter: (level: LogLevel, enabled: boolean) => void setSearchTerm: (term: string) => void diff --git a/src/frontend/store/slices/fbd/utils/index.ts b/src/frontend/store/slices/fbd/utils/index.ts index 450f85e6a..d65a33e0c 100644 --- a/src/frontend/store/slices/fbd/utils/index.ts +++ b/src/frontend/store/slices/fbd/utils/index.ts @@ -10,6 +10,7 @@ import { import { BlockVariant } from '../../../../components/_atoms/graphical-editor/types/block' import { buildGenericNode } from '../../../../components/_molecules/graphical-editor/fbd/fbd-utils/nodes' import { newGraphicalEditorNodeID } from '../../../../utils/new-graphical-editor-node-id' +import { newUuid } from '../../../../utils/new-uuid' import { FBDRungState } from '../types' export const pasteNodesAtFBD = (nodes: Node[], edges: Edge[], mouse: { x: number; y: number }) => { @@ -77,7 +78,7 @@ export const pasteNodesAtFBD = (nodes: Node[], edges: Edge[], mouse: { x: number } export const duplicateFBDRung = (rung: FBDRungState) => { - const newRung = { ...rung, id: `rung_${crypto.randomUUID()}` } + const newRung = { ...rung, id: `rung_${newUuid()}` } newRung.selectedNodes = [] const newNodes = newRung.nodes.map((node) => { diff --git a/src/frontend/store/slices/ladder/utils/index.ts b/src/frontend/store/slices/ladder/utils/index.ts index 964151d8d..027571868 100644 --- a/src/frontend/store/slices/ladder/utils/index.ts +++ b/src/frontend/store/slices/ladder/utils/index.ts @@ -18,6 +18,7 @@ import type { import { updateDiagramElementsPosition } from '../../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram' import { generateNumericUUID } from '../../../../utils/generate-uuid' import { newGraphicalEditorNodeID } from '../../../../utils/new-graphical-editor-node-id' +import { newUuid } from '../../../../utils/new-uuid' import { RungLadderState } from '../types' export const duplicateLadderRung = (editorName: string, rung: RungLadderState): RungLadderState => { @@ -204,7 +205,7 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): })) const newRung = { - id: `rung_${editorName}_${crypto.randomUUID()}`, + id: `rung_${editorName}_${newUuid()}`, comment: rung.comment, defaultBounds: rung.defaultBounds, reactFlowViewport: rung.reactFlowViewport, diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 523ecf583..fd15e92a9 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -10,6 +10,7 @@ import { generateIecVariablesToString } from '../../../utils/generate-iec-variab import { hasLegacyInOutOutputHandle } from '../../../utils/graphical/in-out-pin-rules' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' import { isLegalIdentifier } from '../../../utils/keywords' +import { newUuid } from '../../../utils/new-uuid' import { findGlobalVariableListReferences } from '../../../utils/PLC/global-variable-list-references' import { globalVariableListTypeName } from '../../../utils/PLC/global-variable-list-serializer' import { restampFlowLibraryVariants } from '../../../utils/PLC/restamp-library-variants' @@ -232,10 +233,10 @@ function duplicateRemoteDeviceIdentity(device: PLCRemoteDevice, takenSlaveNames: ...next.modbusTcpConfig, ioGroups: (next.modbusTcpConfig.ioGroups ?? []).map((group) => ({ ...group, - id: crypto.randomUUID(), + id: newUuid(), ioPoints: (group.ioPoints ?? []).map((point) => ({ ...point, - id: crypto.randomUUID(), + id: newUuid(), iecLocation: '', alias: undefined, })), @@ -257,7 +258,7 @@ function duplicateRemoteDeviceIdentity(device: PLCRemoteDevice, takenSlaveNames: taken.add(name) return { ...slave, - id: crypto.randomUUID(), + id: newUuid(), name, channelMappings: (slave.channelMappings ?? []).map((mapping) => ({ ...mapping, @@ -1086,7 +1087,7 @@ const createSharedSlice: StateCreator = (s // Log any parsing warnings to the app console (after clear so they aren't wiped) if (data.warnings) { for (const message of data.warnings) { - getState().consoleActions.addLog({ id: crypto.randomUUID(), level: 'warning', message }) + getState().consoleActions.addLog({ level: 'warning', message }) } } @@ -1182,7 +1183,6 @@ const createSharedSlice: StateCreator = (s if (restampedCount > 0) { getState().consoleActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `Refreshed ${restampedCount} library block pin type(s) from the current library definitions.`, }) @@ -1190,7 +1190,6 @@ const createSharedSlice: StateCreator = (s if (convertibleInOutPous.size > 0) { getState().consoleActions.addLog({ - id: crypto.randomUUID(), level: 'warning', message: `A VAR_IN_OUT parameter is now drawn as a single input-side pin. ` + @@ -1203,7 +1202,6 @@ const createSharedSlice: StateCreator = (s if (libraryInOutBlocks.size > 0) { getState().consoleActions.addLog({ - id: crypto.randomUUID(), level: 'info', message: `${[...libraryInOutBlocks].sort().join(', ')}: this project places library blocks with a ` + diff --git a/src/frontend/utils/__tests__/debugger-session.test.ts b/src/frontend/utils/__tests__/debugger-session.test.ts index 8da79331d..20dd04797 100644 --- a/src/frontend/utils/__tests__/debugger-session.test.ts +++ b/src/frontend/utils/__tests__/debugger-session.test.ts @@ -1,4 +1,4 @@ -import type { PLCDataType, PLCInstance, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' +import type { LogObject, PLCDataType, PLCInstance, PLCPou, PLCVariable } from '../../../middleware/shared/ports/types' import { openPLCStoreBase } from '../../store' import type { DebugMap, DebugVariableEntry } from '../debug-parser' import { packDebugAddr } from '../debug-parser' @@ -78,18 +78,8 @@ function makeInstance(name: string, program: string, task = 'Task0'): PLCInstanc /** Simple log collector — NOT jest.fn(), just a plain function with a captured array. */ function createLogCollector() { - const entries: { - id: string - level: string - message: string - compileError?: import('../../../middleware/shared/ports/types').StructuredCompileError - }[] = [] - const log = (entry: { - id: string - level: 'error' | 'debug' | 'info' | 'warning' - message: string - compileError?: import('../../../middleware/shared/ports/types').StructuredCompileError - }) => { + const entries: LogObject[] = [] + const log = (entry: LogObject) => { entries.push(entry) } return { entries, log } @@ -146,13 +136,16 @@ describe('logCompilerEvent', () => { expect(entries[0].message).toBe('hello') }) - it('generates unique IDs for each log entry', () => { + // Entry ids are the console slice's business, not this helper's. It used to + // mint one per line with `crypto.randomUUID` — which does not exist when the + // node serves the bundle over plain HTTP. What it owes callers now is one + // entry per line; the store keys them. + it('emits one entry per line and supplies no id of its own', () => { const { entries, log } = createLogCollector() logCompilerEvent({ message: 'a\nb' }, log) - expect(entries[0].id).toBeTruthy() - expect(entries[1].id).toBeTruthy() - expect(entries[0].id).not.toBe(entries[1].id) + expect(entries.map((e) => e.message)).toEqual(['a', 'b']) + expect(entries.every((e) => !('id' in e))).toBe(true) }) // ------------------------------------------------------------------------- diff --git a/src/frontend/utils/__tests__/new-graphical-editor-node-id.test.ts b/src/frontend/utils/__tests__/new-graphical-editor-node-id.test.ts index 5535d1f94..65eca6183 100644 --- a/src/frontend/utils/__tests__/new-graphical-editor-node-id.test.ts +++ b/src/frontend/utils/__tests__/new-graphical-editor-node-id.test.ts @@ -1,7 +1,7 @@ import { newGraphicalEditorNodeID } from '../new-graphical-editor-node-id' describe('newGraphicalEditorNodeID', () => { - it('generates ID with default prefix and separator using crypto.randomUUID', () => { + it('generates ID with default prefix and separator', () => { const id = newGraphicalEditorNodeID() expect(id).toMatch(/^NODE_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) }) @@ -11,13 +11,8 @@ describe('newGraphicalEditorNodeID', () => { expect(id).toMatch(/^BLOCK-/) }) - it('falls back to uuidv4 when crypto.randomUUID is unavailable', () => { - const orig = crypto.randomUUID.bind(crypto) - Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) - try { - expect(newGraphicalEditorNodeID('TEST')).toMatch(/^TEST_[0-9a-f]{8}-/) - } finally { - Object.defineProperty(crypto, 'randomUUID', { value: orig, configurable: true }) - } + it('does not repeat itself across calls', () => { + const ids = new Set(Array.from({ length: 50 }, () => newGraphicalEditorNodeID())) + expect(ids.size).toBe(50) }) }) diff --git a/src/frontend/utils/__tests__/new-uuid.test.ts b/src/frontend/utils/__tests__/new-uuid.test.ts new file mode 100644 index 000000000..ded3658d1 --- /dev/null +++ b/src/frontend/utils/__tests__/new-uuid.test.ts @@ -0,0 +1,48 @@ +import { newUuid } from '../new-uuid' + +describe('newUuid', () => { + it('returns a v4 UUID', () => { + expect(newUuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + + it('does not repeat itself across calls', () => { + const ids = new Set(Array.from({ length: 100 }, () => newUuid())) + expect(ids.size).toBe(100) + }) + + // The regression this whole helper exists for: `crypto.randomUUID` is + // secure-context-only and autonomy-node serves the web bundle over plain + // HTTP. `uuid` decides which generator to use at module-evaluation time, so + // the module graph has to be re-imported with the global already gone — + // deleting it after the fact would leave the bound reference in place and + // the test would pass without ever touching the fallback. + // + // The editor's renderer is always a secure context, so this guards the + // shared helper rather than a defect reachable from this app. + it('still works when crypto.randomUUID is absent (plain-HTTP node access)', async () => { + const original = Object.getOwnPropertyDescriptor(crypto, 'randomUUID') + Object.defineProperty(crypto, 'randomUUID', { value: undefined, configurable: true }) + jest.resetModules() + try { + const { newUuid: freshNewUuid } = await import('../new-uuid') + expect(freshNewUuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + } finally { + // `randomUUID` lives on `Crypto.prototype`, not on the `crypto` instance, + // so there is no own descriptor to put back — restoring only when one + // existed would leave our `undefined` own property shadowing the real + // method for every later test in this file. + if (original) { + Object.defineProperty(crypto, 'randomUUID', original) + } else { + Reflect.deleteProperty(crypto, 'randomUUID') + } + jest.resetModules() + } + }) + + // Guards the teardown above: if the property is ever left shadowed, this + // fails right here instead of surfacing as an unrelated test breaking later. + it('leaves crypto.randomUUID intact after that test', () => { + expect(typeof crypto.randomUUID).toBe('function') + }) +}) diff --git a/src/frontend/utils/debugger-session.ts b/src/frontend/utils/debugger-session.ts index 9aa8461d2..fd4af22d0 100644 --- a/src/frontend/utils/debugger-session.ts +++ b/src/frontend/utils/debugger-session.ts @@ -10,10 +10,12 @@ import type { SystemLibrary } from '../../middleware/shared/ports/library-types' import type { DebugTreeNode, FbInstanceInfo, + LogObject, PLCDataType, PLCInstance, PLCPou, PLCVariable, + StructuredCompileError, } from '../../middleware/shared/ports/types' import type { DebugMap, DebugVariableEntry } from './debug-parser' import { packDebugAddr } from './debug-parser' @@ -51,25 +53,18 @@ export function logCompilerEvent( event: { message?: string level?: string - compileError?: import('../../middleware/shared/ports/types').StructuredCompileError + compileError?: StructuredCompileError }, - log: ( - entry: { - id: string - level: 'error' | 'debug' | 'info' | 'warning' - message: string - compileError?: import('../../middleware/shared/ports/types').StructuredCompileError - transient?: boolean - }, - options?: { redraw?: boolean }, - ) => void, + // Structurally the console slice's `addLog`. Typed off `LogObject` rather + // than a hand-copied shape so the two can't drift — the copy is how these + // call sites kept minting their own `id` after the store took that over. + log: (entry: LogObject, options?: { redraw?: boolean }) => void, ): void { if (!event.message) return const level = (event.level as 'error' | 'debug' | 'info' | 'warning') ?? 'info' if (event.compileError) { log({ - id: crypto.randomUUID(), level, message: event.message.trim(), compileError: event.compileError, @@ -104,7 +99,6 @@ export function logCompilerEvent( const isFinalLine = index === lines.length - 1 log( { - id: crypto.randomUUID(), level, message, // Only a redraw leaves an open line. A plain partial line is left diff --git a/src/frontend/utils/new-graphical-editor-node-id.ts b/src/frontend/utils/new-graphical-editor-node-id.ts index 19c33ddab..7a72a862a 100644 --- a/src/frontend/utils/new-graphical-editor-node-id.ts +++ b/src/frontend/utils/new-graphical-editor-node-id.ts @@ -1,6 +1,4 @@ -import { v4 as uuidv4 } from 'uuid' +import { newUuid } from './new-uuid' -export const newGraphicalEditorNodeID = (prefix = 'NODE', sep = '_'): string => { - const rand = crypto && crypto.randomUUID ? crypto.randomUUID() : uuidv4() - return `${String(prefix).toUpperCase()}${sep}${rand}` -} +export const newGraphicalEditorNodeID = (prefix = 'NODE', sep = '_'): string => + `${String(prefix).toUpperCase()}${sep}${newUuid()}` diff --git a/src/frontend/utils/new-uuid.ts b/src/frontend/utils/new-uuid.ts new file mode 100644 index 000000000..c33cec8cc --- /dev/null +++ b/src/frontend/utils/new-uuid.ts @@ -0,0 +1,23 @@ +import { v4 as uuidv4 } from 'uuid' + +/** + * The one place in `src/` allowed to mint a UUID. + * + * `crypto.randomUUID` is secure-context-only, and autonomy-node serves this + * bundle over plain HTTP — so on a node accessed by IP the global is simply + * absent and any direct call throws. `uuid`'s v4 already handles that: it + * uses `crypto.randomUUID` when present and otherwise falls back to + * `crypto.getRandomValues`, which every context provides. Routing every call + * through here keeps that single decision in one file instead of asking each + * call site to remember it (see the `no-restricted-properties` lint guard). + * + * What actually warrants a UUID is an id persisted into a project file, which + * must not collide across sessions, machines or copy-paste — FBD blocks, + * ladder/FBD rungs, graphical editor nodes. A purely in-memory id is better + * minted by whoever owns the list (the console slice keys its own entries off + * a sequence for that reason). One call site is neither: `plc-logs` memoises a + * UUID per v3 log line to key a list it does not own; it is routed here so the + * lint guard stays absolute, but a sequence owned by that component would suit + * it better. + */ +export const newUuid = (): string => uuidv4() diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 2180af983..191053bfe 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -1360,8 +1360,14 @@ export interface LogSegment { className?: string } +/** + * What a caller hands to `addLog`. Deliberately has no `id`: the entry id is + * a rendering key the console's own list owns, so the store mints it (see + * `frontend/store/slices/console`). A caller logging "Build process started" + * has no business generating one — and every caller that did was reaching for + * `crypto.randomUUID`, which does not exist outside a secure context. + */ export interface LogObject { - id: string level?: 'debug' | 'info' | 'warning' | 'error' message: string tstamp?: Date