diff --git a/.changeset/v1.md b/.changeset/v1.md index 642a579..31b8e86 100644 --- a/.changeset/v1.md +++ b/.changeset/v1.md @@ -14,6 +14,7 @@ How it works now, and what breaks: - **Ids.** `eventIndex` is now `id`: from 1, valid for one parse. `log.event(id)`, `log.events`. `log.columns` gives times and tree links as typed arrays. - **Times.** `timestamp` → `startTimestamp`, `exitStamp` → `endTimestamp`. A leaf ends where it starts. - **`null` means not stated.** Not `''`, `0` or `'default'`: `text`, `namespace`, `lineNumber`, a limit's `limit`. +- **`FLOW_ELEMENT_ERROR`.** `text` is the message only; it ended with the element type and name, now `details.elementType` and `details.elementName`. - **Removed:** `suffix`, `category`, `executionEndTime`, parser state on events (`isExit`, `acceptsText` and so on). `cpuType` is now `eventType(type).consumesCpuTime`. `hasValidSymbols` is now `isApexCall`. - **Renamed:** `logIssues` → `issues`, `governorLimits` → `limits`, `LOG_LEVEL` → `LEVEL`. `./next` paths are gone: import from the root. - **Types:** `LogEventType` → `EventType`, `LogLevel` → `Level`, `GovernorSnapshot` → `LimitSnapshot`, `Truncation` → `LogTruncation`, `TruncationRegion` → `LogTruncationRegion`, `LimitMetricKey` → `LimitMetric`, `LimitObservation` → `LimitUsage`, `RunningTotalObservation` → `RunningUsage`. Removed: `LIMIT_METRIC`, `ALL_LIMIT_METRICS`, `LimitMetricUnit`, `LimitMetricMeta`, `DebugLevels`, `LineNumber`. diff --git a/src/__tests__/Buffers.test.ts b/src/__tests__/Buffers.test.ts index 1582534..433560b 100644 --- a/src/__tests__/Buffers.test.ts +++ b/src/__tests__/Buffers.test.ts @@ -20,8 +20,8 @@ const LIMITS = [ '09:00:00.0 (900)|CUMULATIVE_LIMIT_USAGE_END', ]; -// Every part of a build: the header, namespaces, counts, heap, details, a flow running total, -// limits, a skipped block, a frame left open and a line the parser cannot read. +// Every part of a build: the header, namespaces, counts, heap, details, a flow running total, a +// flow error, limits, a skipped block, a frame left open and a line the parser cannot read. const LOG = [ '64.0 APEX_CODE,FINE;APEX_PROFILING,INFO;CALLOUT,INFO;DB,INFO;SYSTEM,DEBUG;WORKFLOW,INFO', '09:15:30.25 (1)|USER_INFO|[EXTERNAL]|005000000000AAA|user@example.com|(GMT-08:00) Pacific Standard Time (America/Los_Angeles)|GMT-08:00', @@ -39,6 +39,7 @@ const LOG = [ '09:00:00.0 (100)|FLOW_ELEMENT_BEGIN|abc-1|FlowRecordUpdate|Update_Account', '09:00:00.0 (110)|FLOW_ELEMENT_LIMIT_USAGE|1 DML statements, total 1 out of 150', '09:00:00.0 (120)|FLOW_ELEMENT_END|abc-1|FlowRecordUpdate|Update_Account', + '09:00:00.0 (130)|FLOW_ELEMENT_ERROR|Required fields are missing.|FlowRecordUpdate|Update_Account', ...LIMITS, '*** Skipped 1,024 bytes of detailed log', '09:00:00.0 (1000)|NOT_A_REAL_EVENT|x', @@ -113,6 +114,7 @@ it('the fixture states every part of a build', async () => { expect(log.heapPeak).toBeGreaterThan(0); expect(log.ofType('FLOW_ELEMENT_BEGIN')[0]?.dmlCount.self).toBe(1); expect(log.ofType('SOQL_EXECUTE_EXPLAIN')[0]?.details).not.toBeNull(); + expect(log.ofType('FLOW_ELEMENT_ERROR')[0]?.details.elementName).toBe('Update_Account'); }); describe.each([ diff --git a/src/__tests__/Details.test.ts b/src/__tests__/Details.test.ts index 93405a3..00640b0 100644 --- a/src/__tests__/Details.test.ts +++ b/src/__tests__/Details.test.ts @@ -141,6 +141,52 @@ describe('CODE_UNIT_STARTED details', () => { }); }); +describe('FLOW_ELEMENT_ERROR details', () => { + it.each([ + [ + 'a one-line message', + [ + '09:18:22.6 (200)|FLOW_ELEMENT_ERROR|Required fields are missing.|FlowRecordCreate|Create_Account', + ], + { elementType: 'FlowRecordCreate', elementName: 'Create_Account' }, + ], + [ + 'a message over lines', + [ + '09:18:22.6 (200)|FLOW_ELEMENT_ERROR|You have reached the limit.', + 'Try again later.|FlowActionCall|myRule_1_A1', + ], + { elementType: 'FlowActionCall', elementName: 'myRule_1_A1' }, + ], + [ + 'a message that holds a |', + ['09:18:22.6 (200)|FLOW_ELEMENT_ERROR|Bad value: a|b|FlowDecision|Check_Status'], + { elementType: 'FlowDecision', elementName: 'Check_Status' }, + ], + [ + 'no element', + ['09:18:22.6 (200)|FLOW_ELEMENT_ERROR|An error occurred.'], + { elementType: null, elementName: null }, + ], + [ + 'an empty message', + ['09:18:22.6 (200)|FLOW_ELEMENT_ERROR||FlowDecision|Check_Status'], + { elementType: 'FlowDecision', elementName: 'Check_Status' }, + ], + [ + 'an element line that another line follows', + [ + '09:18:22.6 (200)|FLOW_ELEMENT_ERROR|You have reached the limit.', + '|FlowActionCall|myRule_1_A1', + 'Try again later.', + ], + { elementType: null, elementName: null }, + ], + ])('reads the element after %s', (_name, lines, expected) => { + expect(parse(inExecution(...lines)).ofType('FLOW_ELEMENT_ERROR')[0]?.details).toEqual(expected); + }); +}); + describe('limit and heap details', () => { const log = parse( inExecution( diff --git a/src/__tests__/TextRules.test.ts b/src/__tests__/TextRules.test.ts index 0d590e0..71688da 100644 --- a/src/__tests__/TextRules.test.ts +++ b/src/__tests__/TextRules.test.ts @@ -4,17 +4,15 @@ import { parse } from './helpers.js'; describe('FLOW_ELEMENT_ERROR', () => { - it('keeps the error message, element type and element name', () => { + it('keeps the error message only', () => { const log = parse( '09:18:22.6 (100)|FLOW_ELEMENT_ERROR|Required fields are missing: [Name]|FlowRecordCreate|Create_Account\n', ); - expect(log.children[0]?.text).toBe( - 'Required fields are missing: [Name] FlowRecordCreate Create_Account', - ); + expect(log.children[0]?.text).toBe('Required fields are missing: [Name]'); }); - it('reads the element type and name after a message that spans lines', () => { + it('keeps the whole message when it spans lines', () => { const log = parse( '09:18:22.6 (100)|FLOW_ELEMENT_ERROR|You have reached the limit.\n' + 'Actions will start again in the next hour.\n' + @@ -23,8 +21,16 @@ describe('FLOW_ELEMENT_ERROR', () => { ); expect(log.children[0]?.text).toBe( - 'You have reached the limit.\nActions will start again in the next hour. FlowActionCall myRule_1_A1', + 'You have reached the limit.\nActions will start again in the next hour.', + ); + }); + + it('keeps a | in the message', () => { + const log = parse( + '09:18:22.6 (100)|FLOW_ELEMENT_ERROR|Bad value: a|b|FlowDecision|Check_Status\n', ); + + expect(log.children[0]?.text).toBe('Bad value: a|b'); }); it('keeps a message that states no element', () => { @@ -35,10 +41,10 @@ describe('FLOW_ELEMENT_ERROR', () => { expect(log.children[0]?.text).toBe('An error occurred.\n --- An Apex error occurred'); }); - it('leaves no leading space when the message is empty', () => { + it('states no text when the message is empty', () => { const log = parse('09:18:22.6 (100)|FLOW_ELEMENT_ERROR||FlowDecision|Check_Status\n'); - expect(log.children[0]?.text).toBe('FlowDecision Check_Status'); + expect(log.children[0]?.text).toBeNull(); }); }); diff --git a/src/catalog/catalog.ts b/src/catalog/catalog.ts index 9960d4e..606a6b4 100644 --- a/src/catalog/catalog.ts +++ b/src/catalog/catalog.ts @@ -20,6 +20,9 @@ import { vfApexCallText, wfFormulaAfter, } from './text.js'; + +export { splitTrailingFields } from './text.js'; + import type { DebugCategory, EventType, @@ -1909,19 +1912,24 @@ export function idOfType(name: string): number { } /** - * An event's text: its type's rule, then its continuation lines if the type takes text, then its - * type's rewrite. Null when neither gives any text. + * An event's text before its type's rewrite: its type's rule, then its continuation lines if the + * type takes text. Null when neither gives any text. */ -export function eventText(typeId: number, f: Fields): string | null { +export function eventBody(typeId: number, f: Fields): string | null { const text = LINE_RULES[typeId]; const grammar = GRAMMAR[typeId]; if (!text || !grammar) return null; // An empty field is no text, so that a missing field and an absent rule read the same. const base = text.rule?.(f) || null; const more = grammar.acceptsText ? f.continuation() : ''; - const joined = base === null ? more : more ? `${base}\n${more}` : base; - if (!joined) return null; - return text.after ? text.after(joined) : joined; + return (base === null ? more : more ? `${base}\n${more}` : base) || null; +} + +/** An event's text: its body, then its type's rewrite. Null when no text is left. */ +export function eventText(typeId: number, f: Fields): string | null { + const body = eventBody(typeId, f); + const after = LINE_RULES[typeId]?.after; + return body !== null && after ? after(body) || null : body; } /** diff --git a/src/catalog/text.ts b/src/catalog/text.ts index cdc6080..4b3dcfc 100644 --- a/src/catalog/text.ts +++ b/src/catalog/text.ts @@ -31,7 +31,7 @@ export function lineText(field: string): string { } /** Splits the last `count` `|` fields off text, or null when a field spans lines. */ -function splitTrailingFields(text: string, count: number): string[] | null { +export function splitTrailingFields(text: string, count: number): string[] | null { const parts = text.split('|'); if (parts.length <= count) return null; const fields = parts.splice(-count); @@ -124,8 +124,8 @@ export const flowInterviewsErrorAfter: AfterRule = (text) => { return split ? `${split[0]} - ${split[2]}` : text; }; -export const flowElementErrorAfter: AfterRule = (text) => - splitTrailingFields(text, 2)?.filter(Boolean).join(' ') ?? text; +// The element type and name are details, not text. +export const flowElementErrorAfter: AfterRule = (text) => splitTrailingFields(text, 2)?.[0] ?? text; export const validationFormulaAfter: AfterRule = (text) => { const split = splitTrailingFields(text, 1); diff --git a/src/views/details.ts b/src/views/details.ts index 542beb0..f961554 100644 --- a/src/views/details.ts +++ b/src/views/details.ts @@ -1,7 +1,14 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { codeUnitType, EVENT_TYPES, fieldPosition, idOfType } from '../catalog/catalog.js'; +import { + codeUnitType, + EVENT_TYPES, + eventBody, + fieldPosition, + idOfType, + splitTrailingFields, +} from '../catalog/catalog.js'; import type { EventType, Fields } from '../catalog/types.js'; import type { LimitUsage, RunningUsage } from '../limits.js'; import { codedUsage, labelledUsage, runningUsage } from '../limits.js'; @@ -46,6 +53,11 @@ export interface EventDetails { readonly FLOW_INTERVIEW_FINISHED_LIMIT_USAGE: LimitUsage | null; readonly FLOW_ELEMENT_LIMIT_USAGE: RunningUsage | null; readonly FLOW_BULK_ELEMENT_LIMIT_USAGE: RunningUsage | null; + /** The element that failed. Its text holds the message only. */ + readonly FLOW_ELEMENT_ERROR: { + readonly elementType: string | null; + readonly elementName: string | null; + }; } /** The details of an event of type `T`: null for a type that states none. */ @@ -174,6 +186,13 @@ for (const [type, parse] of FLOW_USAGE) { }); } +reader('FLOW_ELEMENT_ERROR', (t) => (f) => { + // A message can span lines; then the element follows it on the last line. + const body = eventBody(t, f); + const split = body === null ? null : splitTrailingFields(body, 2); + return { elementType: split?.[1] || null, elementName: split?.[2] || null }; +}); + /** The details `typeId`'s line states, or null for a type that has none. Frozen: views share them. */ export function eventDetails( typeId: number,