diff --git a/src/__tests__/Details.test.ts b/src/__tests__/Details.test.ts index 1a0eed6..19cc598 100644 --- a/src/__tests__/Details.test.ts +++ b/src/__tests__/Details.test.ts @@ -187,6 +187,61 @@ describe('FLOW_ELEMENT_ERROR details', () => { }); }); +describe('FLOW_START_INTERVIEWS_BEGIN details', () => { + const interviews = (unit: string | null): string => { + const body = [ + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEWS_END|1', + ]; + return unit === null + ? body.join('\n') + : [ + `09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|${unit}`, + ...body, + `09:18:22.6 (400)|CODE_UNIT_FINISHED|${unit}`, + ].join('\n'); + }; + const flowTypes = (text: string) => + parse(text) + .ofType('FLOW_START_INTERVIEWS_BEGIN') + .map((e) => e.details.flowType); + + it.each([ + ['a Flow code unit', 'Flow:01I000000000AAA', 'flow'], + ['a Workflow code unit', 'Workflow:01I000000000AAA', 'processBuilder'], + ['a code unit of another kind', 'Validation:MyObject:a00000000000AAA', null], + ['no code unit', null, null], + ])('is %s: %s', (_name, unit, expected) => { + expect(flowTypes(interviews(unit))).toEqual([expected]); + }); + + it('is null when the nearest code unit is of another kind, though a Flow is above it', () => { + const text = [ + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|Flow:01I000000000AAA', + '09:18:22.6 (210)|CODE_UNIT_STARTED|[EXTERNAL]|Validation:MyObject:a00000000000AAA', + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (390)|CODE_UNIT_FINISHED|Validation:MyObject:a00000000000AAA', + '09:18:22.6 (400)|CODE_UNIT_FINISHED|Flow:01I000000000AAA', + ].join('\n'); + expect(flowTypes(text)).toEqual([null]); + }); + + it('is flow for interviews inside another interviews frame, whatever started it', () => { + const text = [ + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|Workflow:01I000000000AAA', + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEW_BEGIN|01I000000000AAA|Outer', + '09:18:22.6 (320)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (330)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (340)|FLOW_START_INTERVIEW_END|01I000000000AAA|Outer', + '09:18:22.6 (350)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (400)|CODE_UNIT_FINISHED|Workflow:01I000000000AAA', + ].join('\n'); + expect(flowTypes(text)).toEqual(['processBuilder', 'flow']); + }); +}); + describe('limit and heap details', () => { const log = parse( inExecution( diff --git a/src/views/details.ts b/src/views/details.ts index 26cac1f..7456127 100644 --- a/src/views/details.ts +++ b/src/views/details.ts @@ -29,6 +29,18 @@ export interface ExplainPlan { readonly relativeCost: number | null; } +/** What started a flow interviews frame. */ +export type FlowType = 'flow' | 'processBuilder'; + +const FLOW_OF_UNIT = new Map([ + ['Flow', 'flow'], + ['Workflow', 'processBuilder'], +]); + +/** The flow type a code unit of `unit` type starts; null for any other unit, or none. */ +export const flowTypeOfUnit = (unit: string | null): FlowType | null => + FLOW_OF_UNIT.get(unit ?? '') ?? null; + /** * The values each type's line states beyond its text, or derives from the rows after it, by type. A * value is null when the log does not state it, or states it malformed; `field()` still gives the @@ -45,6 +57,8 @@ export interface EventDetails { readonly DML_BEGIN: { readonly operation: string | null; readonly sObjectType: string | null }; /** The kind of code, as `Flow` or `Validation`, from the unit's name. */ readonly CODE_UNIT_STARTED: { readonly codeUnitType: string | null }; + /** What started the interviews, from the code unit above them: `Flow` or nested is `flow`, `Workflow` is `processBuilder`. Null for any other unit. */ + readonly FLOW_START_INTERVIEWS_BEGIN: { readonly flowType: FlowType | null }; /** Bytes, as the line states them: positive for a deallocation. */ readonly HEAP_ALLOCATE: { readonly bytes: number | null }; readonly HEAP_DEALLOCATE: { readonly bytes: number | null }; @@ -80,10 +94,16 @@ export type AnyDetails = EventDetails[keyof EventDetails]; type Read = (f: Fields) => T; /** - * Reads a line's details; `explain` gives a query's explain line, or null when it has none, and - * `isCaught` the reading of a throw. + * Reads a line's details; `explain` gives a query's explain line, or null when it has none, + * `isCaught` the reading of a throw, and `flowType` what started an interviews frame. `explain` + * and `flowType` move the shared cursor, so read `f` first. */ -type Reader = (f: Fields, explain: () => Fields | null, isCaught: () => boolean | null) => T; +type Reader = ( + f: Fields, + explain: () => Fields | null, + isCaught: () => boolean | null, + flowType: () => FlowType | null, +) => T; /** Field `name`'s position in `typeId`'s line; throws at load for a name the entry does not list. */ function position(typeId: number, name: string): { at: number; last: boolean } { @@ -175,6 +195,9 @@ reader('DML_BEGIN', (t) => { return (f) => ({ operation: operation(f), sObjectType: sObjectType(f) }); }); reader('CODE_UNIT_STARTED', () => (f) => ({ codeUnitType: codeUnitType(f) })); +reader('FLOW_START_INTERVIEWS_BEGIN', () => (_f, _explain, _isCaught, flowType) => ({ + flowType: flowType(), +})); for (const type of ['HEAP_ALLOCATE', 'HEAP_DEALLOCATE', 'BULK_HEAP_ALLOCATE'] as const) { reader(type, (t) => { const bytes = int(t, 'bytes', 'Bytes:'); @@ -216,8 +239,9 @@ export function eventDetails( f: () => Fields, explain: () => Fields | null, isCaught: () => boolean | null, + flowType: () => FlowType | null, ): AnyDetails | null { const read = READERS[typeId]; - const details = read ? read(f(), explain, isCaught) : null; + const details = read ? read(f(), explain, isCaught, flowType) : null; return details && Object.freeze(details); } diff --git a/src/views/lines.ts b/src/views/lines.ts index c154d3e..9cbf8f8 100644 --- a/src/views/lines.ts +++ b/src/views/lines.ts @@ -3,15 +3,22 @@ */ import { ByteFields } from '../bytes/cursor.js'; import type { Source } from '../bytes/source.js'; -import { EVENT_TYPES, eventText, fieldPosition, idOfType } from '../catalog/catalog.js'; +import { + codeUnitType, + EVENT_TYPES, + eventText, + fieldPosition, + idOfType, +} from '../catalog/catalog.js'; import type { Issues } from '../engine/issues.js'; import { throwOutcomes } from '../engine/throws.js'; import type { Store } from '../store/store.js'; -import type { AnyDetails } from './details.js'; -import { eventDetails } from './details.js'; +import type { AnyDetails, FlowType } from './details.js'; +import { eventDetails, flowTypeOfUnit } from './details.js'; const INTERVIEWS = idOfType('FLOW_START_INTERVIEWS_BEGIN'); const INTERVIEW = idOfType('FLOW_START_INTERVIEW_BEGIN'); +const CODE_UNIT_STARTED = idOfType('CODE_UNIT_STARTED'); const EXPLAIN = idOfType('SOQL_EXECUTE_EXPLAIN'); /** @@ -71,6 +78,7 @@ export class EventLines { return plan < 0 ? null : this.at(plan); }, () => (this.outcomes ??= throwOutcomes(this.store, this.issues)).get(id) ?? null, + () => this.flowTypeOf(id), ); } @@ -86,6 +94,18 @@ export class EventLines { return this.fields; } + /** What started the interviews: the nearest code unit or interviews frame above them. */ + private flowTypeOf(id: number): FlowType | null { + const { parent } = this.store; + // Every parent is a row; row 0, the log, ends the walk. + for (let up = parent[id]!; up > 0; up = parent[up]!) { + const type = this.typeOf(up); + if (type === INTERVIEWS) return 'flow'; + if (type === CODE_UNIT_STARTED) return flowTypeOfUnit(codeUnitType(this.at(up))); + } + return null; + } + private firstChildOf(id: number, type: number): number { const { subtreeEnd } = this.store; // Each child's subtree ends where its next sibling starts; every id here is a row.