Skip to content

Commit 1ff43b6

Browse files
fix(app-showcase): write the expense approvals' decision back onto the report (#25525)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Fixes #25469 Clause-②: no Metadata-protocol: none ## What changed `showcase_expense_signoff` (per-group 会签, node `dual_signoff`) and `showcase_committee_quorum` (2-of-3, node `committee`) ran `start → approval → end`, with bare `end` nodes on both outcome arms. The approval request finalized, but nothing wrote the decision onto the expense report, so EXP-2001 kept reading `status: submitted` after both groups approved. Each flow now runs an `update_record` on each outcome arm and has `runAs: 'system'`: - approve → `mark_approved` → `status: 'approved'` - reject → `return_to_draft` → `status: 'draft'` (the report goes back to the submitter, who can rework and resubmit; this is the same revert-on-reject `showcase_invoice_signoff`'s `flag_held` does) `runAs: 'system'` follows the invoice flow's precedent. The write-back is an outcome of the approval, not an act of the submitter, so it has to land whatever the submitter's edit rights are. Landing: `examples/app-showcase/src/automation/flows/index.ts` (the file the claim names) plus the new pin `examples/app-showcase/test/expense-approval-write-back.test.ts`. No package source changed. ## Why `update_record` per branch and not the `approvalStatusField` mirror I checked how the mirror behaves in `packages/plugins/plugin-approvals/src/approval-service.ts` (`mirrorStatusField` / `mirroredStatusValue`): - It writes the request's status words exactly as they are: `pending` when the request opens, `approved` / `rejected` on a decision, `recalled` on a recall or dead-run sweep, `returned` on a send-back, and `rejected` on the revision-limit auto-reject. - It has no value mapping. The spec key is a bare `approvalStatusField: z.string()`, and adding a mapping would mean editing `packages/spec`, which is out of bounds for this card. - It writes only values the target field lists among its options. Any other value is skipped and the service logs a warning (`approval status 'X' was not written to OBJECT.FIELD`). The only exception is a transition that abandons the request, where it restores the value from before submission. `showcase_expense_report.status` declares `draft / submitted / approved / reimbursed`. So a mirror on `status` would: - skip `pending` and log a warning on every submit - write `approved` correctly - skip `rejected`, so a **rejected** report would keep reading "Submitted", which is the same defect on the other arm The spec also says the mirror target "should be readonly on the object", and `status` is the field the submitter edits. When the decision drives a business-lifecycle field, the right tool is an explicit write per branch. Both flows' docblocks now explain this. ## Committee quorum flow `showcase_committee_quorum` had the same gap, with bare `end` nodes on both arms. It gets the same fix. ## Pin `test/expense-approval-write-back.test.ts` boots the production stack: a real `ObjectKernel` with the ObjectQL, automation and approvals plugins over real sqlite, and the app's real `ExpenseReport` / `ExpenseLine` objects and real flows. It opens each request the way the seed's `launchSignoff` does, then decides it through `ApprovalService.decide`. `describe.each` runs it over both flows: - **approve**: after the first of two approvals the report still reads `submitted` and the request is not finalized. After the finalizing approval the run is `completed`, it stepped `mark_approved` and not `return_to_draft`, and the report reads `approved`. - **reject**: the run steps `return_to_draft` and the report reads `draft`, a value the status field declares. - **CONTROL**: the flow is rebuilt in its pre-fix shape (bare `end` arms, no `update_record` nodes) on the same harness. The same approvals finalize and resume the run, and the report stays at `submitted`, which is the defect as reported. The committee case adds an expense line of 8,900 so the `total_amount >= 5000` start gate opens (the seeded EXP-DEMO is 8,900). The per-group case uses 1,500, under the threshold like EXP-2001. ### Ablation (`scripts/ablation-replace.mjs`, on committed HEAD `3e661a73f6`) The mutation pointed the signoff approve edge back at the bare end node: anchor `{ id: 'e2', source: 'dual_signoff', target: 'mark_approved', label: 'approve' },` was replaced with `target: 'approved'`. ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob a47c39e -> 78466ece32b2df854bf4e326c1c6ab5f8618a75f × approve: the report reads `approved` once the approval finalizes, not before AssertionError: expected [ 'start', 'dual_signoff' ] to include 'mark_approved' Tests 1 failed | 5 passed (6) ablation-replace: ok restored: blob == HEAD (a47c39e) and `git diff HEAD` is empty ``` The test went red under the mutation and green again once the file was restored. The example is source-imported by the test (relative `../src` import), so there is no `dist/` leg. ## Verification (head `3e661a73f6`) - `pnpm --filter @objectstack/example-showcase exec vitest run test/expense-approval-write-back.test.ts`: 6/6 passed - `pnpm --filter @objectstack/example-showcase typecheck && pnpm --filter @objectstack/example-showcase test`: lock VERDICT command-exit 0; Test Files 38/38, Tests 461/461. The new test file is in the tsc program (`--listFiles`: 1 hit). - `node scripts/pm/dispatch-gates.mjs --commands`: 45 commands, each run with its exit code recorded. 44 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: unbuilt packages outside this diff's closure), so it is **NOT MEASURED** locally and left to CI. - `--ran` reconciliation: `45 derived famil(ies) accounted for — 44 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. No changeset: `@objectstack/example-showcase` is `private: true`, so nothing publishes (`skip-changeset`). ## Acceptance notes - `docs/qa/platform-checklist/areas/approvals.json` → `approvals.status-mirror-field` still records under `knownGaps` that no showcase flow declares `approvalStatusField`. That stays true after this PR, deliberately (see above). The checklist is not edited here. - `showcase_expense_signoff`'s start gate has no amount ceiling, so a report of $5,000 or more matches both flows' start conditions on submit. The seed routes the two demos to different records on purpose. I did not measure what a live submit of such a report does, so this is noted here and not filed. - On the issue's `lockRecord` question: the write-back runs after the request has left `pending`, and the lock is keyed on pending. It also runs as the system principal, which the lock hook exempts. The pin shows the write lands under `lockRecord: true`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 794b769 commit 1ff43b6

2 files changed

Lines changed: 307 additions & 4 deletions

File tree

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,13 +1747,37 @@ export const InquiryPurgeFlow = defineFlow({
17471747
* pattern (钉钉/Salesforce style) that needs no parallel branches. Set
17481748
* `minApprovals` > 1 for "two from each group"; use `behavior: 'quorum'` +
17491749
* `minApprovals` for M-of-N collective sign-off.
1750+
*
1751+
* **The decision is written back onto the report** — an explicit
1752+
* `update_record` on each outcome branch: approve → `status: 'approved'`,
1753+
* reject → `status: 'draft'` (back to the submitter, who can rework and
1754+
* resubmit — the same revert-on-reject {@link InvoiceDualSignoffFlow} does).
1755+
* Without these two nodes both outcomes were bare `end`s, so an expense report
1756+
* both groups had approved kept reading "Submitted" forever.
1757+
*
1758+
* Why not the approval node's `approvalStatusField` mirror instead: the mirror
1759+
* writes the REQUEST's status vocabulary verbatim (`pending` on open,
1760+
* `approved` / `rejected` on decide, `recalled`, `returned`), has no value
1761+
* mapping, and writes only values the target field declares among its options
1762+
* (anything else is skipped with a warn). `status` here is the report's own
1763+
* lifecycle select (`draft` / `submitted` / `approved` / `reimbursed`) — it
1764+
* declares neither `pending` nor `rejected`, so a mirror onto it would warn on
1765+
* every submit and leave a REJECTED report reading "Submitted", the very
1766+
* defect being fixed. The spec also advises a mirror target be readonly, and
1767+
* this one is the field the submitter edits. A business-lifecycle field
1768+
* driven by the decision is what `update_record` per branch is for.
1769+
*
1770+
* `runAs: 'system'` for the same reason as the invoice flow: the write-back is
1771+
* an outcome of the approval process, not an act of the submitter, so it must
1772+
* land whatever the submitter's edit rights on a submitted report are.
17501773
*/
17511774
export const ExpenseSignoffFlow = defineFlow({
17521775
name: 'showcase_expense_signoff',
17531776
label: 'Expense Report Sign-off',
17541777
description: 'Manager + finance per-group sign-off (会签) on a submitted expense report (#3266).',
17551778
type: 'autolaunched',
17561779
status: 'active',
1780+
runAs: 'system',
17571781
nodes: [
17581782
{
17591783
id: 'start',
@@ -1779,13 +1803,35 @@ export const ExpenseSignoffFlow = defineFlow({
17791803
lockRecord: true,
17801804
},
17811805
},
1806+
{
1807+
id: 'mark_approved',
1808+
type: 'update_record',
1809+
label: 'Mark Report Approved',
1810+
config: {
1811+
objectName: 'showcase_expense_report',
1812+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1813+
fields: { status: 'approved' },
1814+
},
1815+
},
1816+
{
1817+
id: 'return_to_draft',
1818+
type: 'update_record',
1819+
label: 'Return Report to Draft',
1820+
config: {
1821+
objectName: 'showcase_expense_report',
1822+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1823+
fields: { status: 'draft' },
1824+
},
1825+
},
17821826
{ id: 'approved', type: 'end', label: 'Approved' },
17831827
{ id: 'rejected', type: 'end', label: 'Rejected' },
17841828
],
17851829
edges: [
17861830
{ id: 'e1', source: 'start', target: 'dual_signoff' },
1787-
{ id: 'e2', source: 'dual_signoff', target: 'approved', label: 'approve' },
1788-
{ id: 'e3', source: 'dual_signoff', target: 'rejected', label: 'reject' },
1831+
{ id: 'e2', source: 'dual_signoff', target: 'mark_approved', label: 'approve' },
1832+
{ id: 'e3', source: 'dual_signoff', target: 'return_to_draft', label: 'reject' },
1833+
{ id: 'e4', source: 'mark_approved', target: 'approved' },
1834+
{ id: 'e5', source: 'return_to_draft', target: 'rejected' },
17891835
],
17901836
});
17911837

@@ -1797,13 +1843,19 @@ export const ExpenseSignoffFlow = defineFlow({
17971843
* `minApprovals` clamps to the approver count so a misconfiguration can never
17981844
* deadlock. Where `per_group` requires one from EACH group, `quorum` counts a
17991845
* flat tally across all approvers.
1846+
*
1847+
* The decision is written back onto the report exactly as in
1848+
* {@link ExpenseSignoffFlow} (approve → `approved`, reject → `draft`), and for
1849+
* the same reasons — see that flow's docblock for why this is an
1850+
* `update_record` per branch and not an `approvalStatusField` mirror.
18001851
*/
18011852
export const CommitteeQuorumFlow = defineFlow({
18021853
name: 'showcase_committee_quorum',
18031854
label: 'High-Value Expense — Committee Quorum',
18041855
description: 'Any 2-of-3 committee members approve a high-value expense report (#3266 quorum / M-of-N).',
18051856
type: 'autolaunched',
18061857
status: 'active',
1858+
runAs: 'system',
18071859
nodes: [
18081860
{
18091861
id: 'start',
@@ -1830,13 +1882,35 @@ export const CommitteeQuorumFlow = defineFlow({
18301882
lockRecord: true,
18311883
},
18321884
},
1885+
{
1886+
id: 'mark_approved',
1887+
type: 'update_record',
1888+
label: 'Mark Report Approved',
1889+
config: {
1890+
objectName: 'showcase_expense_report',
1891+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1892+
fields: { status: 'approved' },
1893+
},
1894+
},
1895+
{
1896+
id: 'return_to_draft',
1897+
type: 'update_record',
1898+
label: 'Return Report to Draft',
1899+
config: {
1900+
objectName: 'showcase_expense_report',
1901+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1902+
fields: { status: 'draft' },
1903+
},
1904+
},
18331905
{ id: 'approved', type: 'end', label: 'Approved' },
18341906
{ id: 'rejected', type: 'end', label: 'Rejected' },
18351907
],
18361908
edges: [
18371909
{ id: 'e1', source: 'start', target: 'committee' },
1838-
{ id: 'e2', source: 'committee', target: 'approved', label: 'approve' },
1839-
{ id: 'e3', source: 'committee', target: 'rejected', label: 'reject' },
1910+
{ id: 'e2', source: 'committee', target: 'mark_approved', label: 'approve' },
1911+
{ id: 'e3', source: 'committee', target: 'return_to_draft', label: 'reject' },
1912+
{ id: 'e4', source: 'mark_approved', target: 'approved' },
1913+
{ id: 'e5', source: 'return_to_draft', target: 'rejected' },
18401914
],
18411915
});
18421916

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25469] The expense approvals write their decision back onto the report.
5+
*
6+
* THE DEFECT. `showcase_expense_signoff` (per-group 会签) and
7+
* `showcase_committee_quorum` (2-of-3) both ran `start → approval → end`, with
8+
* bare `end` nodes on the approve and reject arms. The approval request moved
9+
* to approved / rejected, but nothing told the business record: an expense
10+
* report both groups had signed off kept reading `status: 'submitted'`.
11+
*
12+
* THE FIX is an `update_record` on each outcome arm (approve → `approved`,
13+
* reject → `draft`), not an `approvalStatusField` mirror — the flows' own
14+
* docblock says why (the mirror writes the request vocabulary verbatim, and
15+
* `status` declares neither `pending` nor `rejected`, so a rejected report
16+
* would still read "Submitted").
17+
*
18+
* WHY A REAL DECISION AND NOT A METADATA READ. The write-back happens on
19+
* RESUME, after a pause, through the approvals service's `decide` →
20+
* `automation.resume` path, and under the record lock the node takes. Only
21+
* driving a real request to a real decision on the production stack (real
22+
* kernel, ObjectQL, automation and approvals plugins over real sqlite, the
23+
* app's real objects and flows) shows the record actually changes. The
24+
* CONTROL re-authors the pre-fix shape (bare `end` arms) on the same harness
25+
* and shows the same decisions leave the report at `submitted` — so the
26+
* positive assertions can go red.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectKernel } from '@objectstack/core';
31+
import { ObjectQLPlugin } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
34+
import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
35+
36+
import { ExpenseReport, ExpenseLine } from '../src/data/objects/index.js';
37+
import { ExpenseSignoffFlow, CommitteeQuorumFlow } from '../src/automation/flows/index.js';
38+
import { installDefaultOrganization } from './default-organization.testkit.js';
39+
40+
/** Everything the approvals service treats as an unconditional actor. */
41+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
42+
const SYS = { context: SYSTEM_CTX };
43+
44+
const openKernels: Array<{ shutdown?: () => Promise<void> }> = [];
45+
const openDrivers: Array<{ disconnect?: () => Promise<void> }> = [];
46+
afterEach(async () => {
47+
while (openKernels.length) {
48+
try { await openKernels.pop()?.shutdown?.(); } catch { /* noop */ }
49+
}
50+
while (openDrivers.length) {
51+
try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ }
52+
}
53+
});
54+
55+
interface Booted {
56+
automation: AutomationEngine & Record<string, any>;
57+
approvals: any;
58+
data: any;
59+
}
60+
61+
async function boot(flow: any): Promise<Booted> {
62+
const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any);
63+
await kernel.use(new ObjectQLPlugin());
64+
await kernel.use(new AutomationServicePlugin());
65+
await kernel.use(new ApprovalsServicePlugin());
66+
await kernel.bootstrap();
67+
openKernels.push(kernel as any);
68+
69+
const objectql: any = kernel.getService('objectql');
70+
const data: any = kernel.getService('data');
71+
const automation = kernel.getService<AutomationEngine>('automation') as AutomationEngine & Record<string, any>;
72+
const approvals: any = kernel.getService('approvals');
73+
74+
const driver: any = new SqlDriver({
75+
client: 'better-sqlite3',
76+
connection: { filename: ':memory:' },
77+
useNullAsDefault: true,
78+
});
79+
await driver.connect();
80+
objectql.registerDriver(driver, true);
81+
openDrivers.push(driver);
82+
83+
for (const obj of [ExpenseReport, ExpenseLine]) {
84+
objectql.registry.registerObject(obj, 'showcase', 'showcase');
85+
}
86+
await objectql.syncSchemas();
87+
await installDefaultOrganization(objectql);
88+
89+
// One flow per boot: both start on the same record transition, and the
90+
// approvals service dedupes pending requests per record.
91+
automation.registerFlow(flow.name, flow);
92+
return { automation, approvals, data };
93+
}
94+
95+
function one(res: any): any {
96+
return Array.isArray(res) ? res[0] : res;
97+
}
98+
99+
function rows(res: any): any[] {
100+
return Array.isArray(res) ? res : (res?.records ?? []);
101+
}
102+
103+
/** A submitted report, opened into approval the way the seed's `launchSignoff` does. */
104+
async function submitReport(booted: Booted, flowName: string, amount: number): Promise<{ report: any; request: any }> {
105+
const created = one(await booted.data.insert('showcase_expense_report', {
106+
name: 'EXP-TEST',
107+
employee: 'Mei Phone',
108+
status: 'submitted',
109+
}, SYS));
110+
// One line, so the `total_amount` roll-up the committee gate reads is real.
111+
await booted.data.insert('showcase_expense_line', {
112+
expense_report: created.id, merchant: 'Acme Travel', category: 'travel', amount, status: 'submitted',
113+
}, SYS);
114+
const report = one(await booted.data.findOne('showcase_expense_report', { where: { id: created.id }, ...SYS }));
115+
expect(Number(report.total_amount)).toBe(amount);
116+
const launched: any = await booted.automation.execute(flowName, {
117+
record: report,
118+
previous: { ...report, status: 'draft' },
119+
object: 'showcase_expense_report',
120+
organizationId: null,
121+
userId: 'mei@example.com',
122+
} as any);
123+
expect(launched.status, `launch did not park on the approval node: ${launched.error ?? ''}`).toBe('paused');
124+
const request = rows(await booted.data.find('sys_approval_request', { where: { status: 'pending' }, ...SYS }))[0];
125+
expect(request, 'no pending approval request was opened').toBeTruthy();
126+
return { report, request };
127+
}
128+
129+
async function statusOf(booted: Booted, id: string): Promise<string> {
130+
const rec = one(await booted.data.findOne('showcase_expense_report', { where: { id }, ...SYS }));
131+
return rec?.status;
132+
}
133+
134+
async function decide(booted: Booted, requestId: string, decision: 'approve' | 'reject', actorId: string) {
135+
return booted.approvals.decide(requestId, { decision, actorId }, SYSTEM_CTX);
136+
}
137+
138+
/** The pre-fix shape: the outcome arms go straight to bare `end` nodes. */
139+
function withoutWriteBack(flow: any, approvalNodeId: string): any {
140+
const writeBack = new Set(['mark_approved', 'return_to_draft']);
141+
return {
142+
...flow,
143+
nodes: (flow.nodes as any[]).filter((n) => !writeBack.has(n.id)),
144+
edges: [
145+
{ id: 'e1', source: 'start', target: approvalNodeId },
146+
{ id: 'e2', source: approvalNodeId, target: 'approved', label: 'approve' },
147+
{ id: 'e3', source: approvalNodeId, target: 'rejected', label: 'reject' },
148+
],
149+
};
150+
}
151+
152+
const CASES = [
153+
{
154+
flow: ExpenseSignoffFlow,
155+
node: 'dual_signoff',
156+
// Under the $5,000 committee threshold, as the seeded EXP-2001 is.
157+
amount: 1500,
158+
// per_group: one from EACH group finalizes.
159+
approvers: ['position:manager', 'position:auditor'],
160+
rejecter: 'position:auditor',
161+
},
162+
{
163+
flow: CommitteeQuorumFlow,
164+
node: 'committee',
165+
// At or above the $5,000 the committee's start gate requires (EXP-DEMO: 8,900).
166+
amount: 8900,
167+
// quorum: any 2 of 3 finalizes.
168+
approvers: ['position:manager', 'position:legal'],
169+
rejecter: 'position:finance',
170+
},
171+
] as const;
172+
173+
describe.each(CASES)('#25469 — $flow.name writes its decision back onto the report', ({ flow, node, amount, approvers, rejecter }) => {
174+
it('approve: the report reads `approved` once the approval finalizes, not before', async () => {
175+
const booted = await boot(flow);
176+
const { report, request } = await submitReport(booted, flow.name, amount);
177+
178+
const first = await decide(booted, request.id, 'approve', approvers[0]);
179+
expect(first.finalized, 'one approval already finalized the request').toBe(false);
180+
// Still mid-approval: nothing has been decided yet, so nothing is written.
181+
expect(await statusOf(booted, report.id)).toBe('submitted');
182+
183+
const second = await decide(booted, request.id, 'approve', approvers[1]);
184+
expect(second.finalized).toBe(true);
185+
expect(second.resumed, `the run was not resumed: ${second.error ?? second.resumeError ?? ''}`).toBe(true);
186+
187+
const run = (await booted.automation.listRuns(flow.name))[0];
188+
expect(run.status, `run ended ${run.status}: ${run.error ?? ''}`).toBe('completed');
189+
expect(run.steps.map((s: any) => s.nodeId)).toContain('mark_approved');
190+
expect(run.steps.map((s: any) => s.nodeId)).not.toContain('return_to_draft');
191+
192+
expect(await statusOf(booted, report.id)).toBe('approved');
193+
}, 30_000);
194+
195+
it('reject: the report goes back to `draft`, a value its status field declares', async () => {
196+
const booted = await boot(flow);
197+
const { report, request } = await submitReport(booted, flow.name, amount);
198+
199+
const res = await decide(booted, request.id, 'reject', rejecter);
200+
expect(res.finalized).toBe(true);
201+
expect(res.resumed, `the run was not resumed: ${res.error ?? res.resumeError ?? ''}`).toBe(true);
202+
203+
const run = (await booted.automation.listRuns(flow.name))[0];
204+
expect(run.status, `run ended ${run.status}: ${run.error ?? ''}`).toBe('completed');
205+
expect(run.steps.map((s: any) => s.nodeId)).toContain('return_to_draft');
206+
207+
expect(await statusOf(booted, report.id)).toBe('draft');
208+
const declared = ((ExpenseReport as any).fields.status.options as Array<{ value: string }>).map((o) => o.value);
209+
expect(declared).toEqual(expect.arrayContaining(['approved', 'draft']));
210+
}, 30_000);
211+
212+
it('CONTROL — the pre-fix shape (bare `end` arms): the same decisions leave the report `submitted`', async () => {
213+
const preFix = withoutWriteBack(flow, node);
214+
// The rollback must actually be a rollback.
215+
expect((preFix.nodes as any[]).some((n) => n.type === 'update_record')).toBe(false);
216+
217+
const booted = await boot(preFix);
218+
const { report, request } = await submitReport(booted, flow.name, amount);
219+
await decide(booted, request.id, 'approve', approvers[0]);
220+
const second = await decide(booted, request.id, 'approve', approvers[1]);
221+
expect(second.finalized).toBe(true);
222+
expect(second.resumed).toBe(true);
223+
224+
const run = (await booted.automation.listRuns(flow.name))[0];
225+
expect(run.status).toBe('completed');
226+
// The defect as reported: approved request, report still "Submitted".
227+
expect(await statusOf(booted, report.id)).toBe('submitted');
228+
}, 30_000);
229+
});

0 commit comments

Comments
 (0)