Skip to content

Commit 2a2f5c2

Browse files
fix(cli): os lint and os i18n check ask for an approval flow's label and each approval step's label (#24916) (#25157)
Fixes #24916 Clause-②: no Metadata-protocol: none ## What changes The i18n extractor (`packages/cli/src/utils/i18n-extract.ts`) is the one walk behind both `os lint`'s `i18n/missing-*` family and `os i18n check`. It asked for `flows.FLOW.label` only on a flow with a `screen` node, and it never walked `approval` nodes. So neither command ever listed an approval flow, or any approval step, as untranslated. That held even though the approvals door (`plugin-approvals` `reader-locale-labels.ts`, landed for #24863) reads `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label` in the reader's locale, and the key was added to the contract in #24915. - **Flow label.** It is asked for on a flow with a `screen` node **or** an `approval` node (same deep node universe as the screens walk). A flow with neither still owes no label. - **Approval steps.** Every `approval` node with an `id` emits `flows.FLOW.approvals.NODE.label`, seeded with its `FlowNode.label` through the same `pushOptional` the flow label uses. `APPROVAL_NODE_TYPE` is imported from `@objectstack/spec/automation`, not restated. - **`os i18n check --help`.** The flow surface noun now reads "flow screens, approval steps, toasts and refusals" (`SOURCE_SURFACE.flow`, which the help-surface parity pin derives from). - Changeset: `@objectstack/cli` `patch`. ## The mechanism hypotheses, measured (BASE `459afea1f`) - **H1, confirmed.** The gate was the single condition `if (hasScreen)` in `walkScreenFlows`, and no approval-node arm existed. It is now `hasScreen || approvalNodes.length > 0`, plus the approvals walk. - **H2, measured, and the answer is not "every flow".** `Flow.label` is required by the schema, so "carries a label" would mean every flow. The extractor's own rule is to ask only where a reader shows the string, so the readers of `flows.FLOW.label` were counted at objectui's `.objectui-sha` pin `cf153eb2e` and on `origin/main`. There are two: 1. objectui `FlowRunner` (`displayFlowLabel`), which opens only on a run paused at a screen. Every other objectui consumer of the served `flowLabel` (`FlowRunsPage`, `useConsoleActionRuntime`, `RecordDetailView`, `flowResponse.ts`) hands it to that runner. `flowLaunch.ts` reads the `flows` bundle only for `successMessage` / `errorMessage`, and the `refused` arm names no flow. 2. `plugin-approvals` `readerLabelsFor` / `localizeRequestLabels` (the inbox, the record's approval panel and the request page), for a request opened by an `approval` node. A record-triggered, scheduled or API flow with neither node has no surface that shows its translated label, so asking for it would be a stored-never-read demand. The code comment already said the predicate "widens in the same change as that reader", and this change widens it to that second reader. - **H3, confirmed on both doors.** `computeI18nCoverage` builds its expected set from `collectExpectedEntries`, so one change serves both. Measured with the built CLI on a fixture: an approval-only `autolaunched` record-triggered flow `expense_approval` with two approval steps, a screen flow `quick_note`, a scheduled flow `nightly_digest`, and zh-CN. | door | before (BASE build) | after (this branch's build) | |:--|:--|:--| | `os lint --strict --json` | 3 `i18n/missing-flow`, all `quick_note` | 6: `expense_approval` label, `approvals.manager_review.label` and `approvals.finance_review.label`, plus the same 3 `quick_note` | | `os i18n check --show-keys` | zh-CN 2/5, missing 3 (`quick_note` only) | zh-CN 2/8, missing 6 (the same six keys) | | either door, after translating `expense_approval`'s three keys | n/a | only the 3 `quick_note` keys remain | `nightly_digest` is asked for nothing in any leg. - **H4, no row moves.** `authorWarnedProperties('translation')` is empty, and `flows.approvals` / `flows.approvals.label` are `live` in `packages/spec/liveness/translation.json` (flipped by #24979). So the demand side and the warn side still agree, and `lint-liveness-properties.ts` needs no edit. See the acceptance notes for one evidence-text gap. ## A file outside the dispatched surface: `examples/app-showcase/src/system/translations/index.ts` `check:i18n-coverage` runs the built CLI over every example. Once the walk asked for the new keys, it measured `examples/app-showcase/objectstack.config.ts` at **414 → 437**: 8 approval flow labels and 15 approval step labels, all zh-CN, with the identical list from `os lint`. The gate's own prescription for growth is to translate them, and raising the baseline would freeze 23 new strings of debt. So they are translated in the showcase's zh-CN pack. After that, `check:i18n-coverage` reads OK (14 configs, 621 baselined, none new), and showcase's `os lint` drops exactly those 23 findings and adds none. `os validate` on showcase exits 0 with zero `translation-target-unknown`, so every node id names an approval node. `@objectstack/example-showcase` is private, so it needs no changeset. ## Coordination - PR #24947 also edits `i18n-extract.ts`, and rewrites the `@objectstack/spec/automation` import line. This branch leaves that line byte-identical and imports `APPROVAL_NODE_TYPE` on a separate line two unchanged lines below it. A `git merge-file` of this file against #24947's head over BASE exits 0 with no conflict markers. - #24993 (the navigation arm of `collectExpectedEntries`) is not touched. ## Tests New file `packages/cli/test/i18n-flow-approval-coverage.test.ts` (unit tier: no spawn). What it pins: - The walker emits `flows.expense_approval.label` and both `approvals.NODE.label` keys, seeded with the authored text, in the `flow` source. An id-less approval node is skipped. A flow with neither a screen nor an approval node gets no label. - The coverage gate lists the approval flow's label and steps in zh-CN as missing. The steps stay listed while only the flow label is translated, and everything goes quiet once all three are translated. - The screen flow's demand stays the same three keys as before, and the scheduled flow is asked for nothing. - The `os i18n extract` skeleton writes the authored labels in `en` and empty slots in `zh-CN`. Every locale parses through `TranslationDataSchema` with no issues, and so does a translated pack carrying the key. The comment in `i18n-flow-screen-coverage.test.ts` that called `FlowRunner` "the one reader" of the label now names the second reader. No assertion changed. **Ablations** (one per changed behaviour, through `scripts/ablation-replace.mjs`). The subject is imported by relative source path, so no build is involved. Each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - **A**, predicate reverted to `if (hasScreen) {`: 4 failed, 6 passed. Failed: walker emits, id-less skip, gate lists, skeleton writes. - **B**, the approval-step `pushOptional` deleted: 4 failed, 6 passed. Failed: walker emits, gate lists, flow-label-only partial, skeleton writes. ## Verification (final head `f78411843`, which merges `origin/main` `2aaee3b24`; the 5 incoming commits touch none of this diff's paths) All of these ran locally, on a shared box. **Tests and typecheck** | run | head | result | |:--|:--|:--| | `@objectstack/cli` unit tier, whole (`vitest run --project unit`) | `7ed34f9d4` (the same fix as the final head; only an import line moved after it) | 322 files, 4756 tests passed | | the 28 `test/*i18n*.test.ts` files (non-e2e) | `0a4800d41`, then again on `f78411843` | 28 files, 339 tests passed, both times | | `@objectstack/cli` `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) | `0a4800d41` | OK. The new test file is in the `tsconfig.test.json` program (`--listFilesOnly`). | | `@objectstack/example-showcase` `typecheck` and `test` | `0a4800d41` | typecheck exit 0; tests 37 files, 443 passed | | `@objectstack/lint` `src/validate-translatable-sections.test.ts` (imports the showcase bundle) | `0a4800d41` | 33 passed | | ESLint over the five changed source and test files (`--no-inline-config`) | `0a4800d41` | exit 0 | The CLI `integration` tier is left to CI. This diff touches no spawn entry and no integration-tier file. **Gates** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived at `f78411843`, names 70 commands. Each ran with its exit code captured before any pipe. - `--ran` reconciliation: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN", every exit 0. - `check:i18n-coverage`: OK (14 configs, 621 baselined, none new). - `check:i18n`: OK (10 packages in sync). - `check:i18n-walk-parity`: 11 declared groups, 9 walked, 2 exempted. - `check:nul-bytes`: OK. - `check:type-check-debt`: OK. - `check:dual-build-cjs-loads`: OK. On `0a4800d41` it first answered PREREQUISITE NOT MET (exit 3), because 8 unrelated packages were unbuilt in this worktree. After a turbo build of those 8 (all cache hits) it was re-read green. ## Acceptance notes - `packages/spec/liveness/translation.json` `flows.label` still cites only objectui `FlowRunner` as evidence. Its status (`live`) is right, but the evidence omits the second reader, `plugin-approvals` `reader-locale-labels.ts#flowLabelKey`. This is a ledger-text gap, not a defect. Carrier: none. - `skills/objectstack-i18n/SKILL.md` (a governed surface, untouched here) lists the surfaces `os i18n check` covers as "… pages, flow screens". It was already a sample, not the set (no datasets, picklists, toasts or refusals), and now also omits approval steps. Carrier: none. - `walkScreenFlows` now also walks approval flows, as it already walked toasts and refusals of flows with no screen. The name was kept to stay off the lines #24993 touches. - A nested `approval` node (inside an ADR-0031 region) is refused by `defineStack` (the durable-pause-in-region rule), so the deep walk reaches one only on a stack no public door admits. That is consistent with `os validate` accepting the key "at any depth". - `decisionOutputs[].label` (the decision dialog's output labels) still has no translation key. #24915 recorded it as an owed row, waiting on a reader. --- _Generated by [Claude Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2354058 commit 2a2f5c2

6 files changed

Lines changed: 299 additions & 21 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os lint` and `os i18n check` ask for an approval flow's label and each approval step's label (#24916)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The approvals door names a request's flow and its current step in the reader's locale, from `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label`. The i18n extractor asked for `flows.FLOW.label` only on a flow with a `screen` node, and for no approval step at all, so `i18n/missing-flow` never listed an approval flow or its steps as untranslated, and `os i18n extract` never scaffolded those keys. An app's approvals inbox could show the authoring language in every other locale under a green coverage run.
11+
12+
Now:
13+
14+
- `flows.FLOW.label` is asked for on a flow with a `screen` node or an `approval` node, the two surfaces that show it. A flow with neither, such as a record-triggered or scheduled flow that only does work, still owes no label, because nothing shows it.
15+
- `flows.FLOW.approvals.NODE.label` is asked for on every `approval` node that has an `id`, seeded with the node's own `label`.
16+
- Both are reported as `i18n/missing-flow` by `os lint`, listed by `os i18n check`, and written into the skeleton by `os i18n extract`. `os i18n check --help` names approval steps among the surfaces it covers.
17+
18+
**What you may see.** An app that ships an approval flow and declares a locale beyond its default now gets an `i18n/missing-flow` finding for each untranslated approval flow and step. Under `os lint --i18n-strict` or `os i18n check --strict` these are errors, like every other missing translation. To clear them, translate the keys in that locale's pack, for example:
19+
20+
```ts
21+
flows: {
22+
expense_approval: {
23+
label: '报销审批',
24+
approvals: { manager_review: { label: '经理审核' } },
25+
},
26+
},
27+
```
28+
29+
`os i18n extract` writes the empty slots for you and keeps the strings a pack already translates. No key, schema or export changes: both keys were already part of the translation contract, and `os validate` already accepted them.

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,48 @@ export const ShowcaseTranslationBundle = {
11271127
},
11281128
},
11291129
},
1130+
// The approval flows: the flow's name and each approval step's name, as
1131+
// the approvals inbox, the record's approval panel and the request page
1132+
// show them in the reader's locale.
1133+
showcase_budget_approval: {
1134+
label: '项目预算审批',
1135+
approvals: { manager_review: { label: '经理审核' }, exec_review: { label: '高管审核' } },
1136+
},
1137+
showcase_closure_signoff: {
1138+
label: '结项签批(审批子流程)',
1139+
approvals: { ask_signoff: { label: '经理签批' } },
1140+
},
1141+
showcase_invoice_signoff: {
1142+
label: '发票双重签批(并行审批)',
1143+
approvals: { dual_signoff: { label: '财务 + 法务签批' } },
1144+
},
1145+
showcase_one_task_signoff: {
1146+
label: '单个任务签批(逐项子流程)',
1147+
approvals: { review: { label: '任务签批' } },
1148+
},
1149+
showcase_expense_signoff: {
1150+
label: '费用报告签批',
1151+
approvals: { dual_signoff: { label: '经理 + 财务签批' } },
1152+
},
1153+
showcase_committee_quorum: {
1154+
label: '大额费用 — 委员会多数签批',
1155+
approvals: { committee: { label: '委员会签批(三分之二)' } },
1156+
},
1157+
showcase_dynamic_approval: {
1158+
label: '公告动态审批',
1159+
approvals: { lead_review: { label: '负责人审核' }, co_sign: { label: '会签' } },
1160+
},
1161+
showcase_approver_bindings: {
1162+
label: '审批人绑定示例(#3508)',
1163+
approvals: {
1164+
by_user: { label: '指定用户' },
1165+
by_team: { label: '团队成员' },
1166+
by_department: { label: '部门成员' },
1167+
by_position: { label: '岗位任职者' },
1168+
by_manager: { label: '提交人的经理' },
1169+
by_field: { label: '触发字段中的用户' },
1170+
},
1171+
},
11301172
},
11311173
},
11321174
};

‎packages/cli/src/utils/i18n-coverage.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,13 +359,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
359359
// `.description` (#22507), and the per-field `label` / `placeholder` /
360360
// option labels), the terminal
361361
// toasts (`flows.<f>.successMessage` / `.errorMessage`, #22507) and a refused
362-
// `end` node's message (`flows.<f>.refusals.<n>.message`, #22450) — the
363-
// author's own text, so
362+
// `end` node's message (`flows.<f>.refusals.<n>.message`, #22450), and an
363+
// approval flow's label with each `approval` node's step label
364+
// (`flows.<f>.approvals.<n>.label`, #24916) — the author's own text, so
364365
// it keeps its own bucket and reports as `i18n/missing-flow` rather than
365366
// folding away with `--include-platform`. Until this bucket existed the
366367
// family could not report a screen-flow gap at all: HotCRM measured
367368
// `0 i18n/missing-*` while six screen dialogs rendered English in all four
368-
// locales (#11485).
369+
// locales (#11485). Its six approval flows stayed that invisible until
370+
// #24916.
369371
flow: 'flow',
370372
metadataType: 'metadataForm',
371373
metadataFormSection: 'metadataForm',
@@ -429,7 +431,7 @@ const SOURCE_SURFACE: Record<CoverageIssue['source'], string> = {
429431
dataset: 'datasets',
430432
picklist: 'picklists',
431433
page: 'pages',
432-
flow: 'flow screens, toasts and refusals',
434+
flow: 'flow screens, approval steps, toasts and refusals',
433435
metadataForm: 'metadata forms',
434436
};
435437

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
* flows.<flow>.screens.<node_id>.fields.<field>.options.<value> (#22507)
114114
* flows.<flow>.successMessage / .errorMessage (#22507)
115115
* flows.<flow>.refusals.<node_id>.message (#22450)
116+
* flows.<flow>.approvals.<node_id>.label (#24916)
116117
* ^ walked because the ledger's `flows` row is `live` and warns no
117118
* author; a group the ledger does warn on is left out of the walk
118119
* (see `authorWarnedTranslationGroups`)
@@ -146,6 +147,7 @@ import {
146147
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
147148
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
148149
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
150+
import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation';
149151
import { expandViewContainer, InlineLocaleMapSchema } from '@objectstack/spec/ui';
150152
import { authorWarnedProperties, walkPageComponents } from '@objectstack/lint';
151153
import { collectFilledFromHashes } from '@objectstack/platform-objects/apps';
@@ -1910,18 +1912,28 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void {
19101912
// by the schema, so this is authored text in practice; `pushOptional`
19111913
// keeps a label-less flow from seeding an empty string anyway.
19121914
//
1913-
// Demanded only for a flow with a `screen` node, at any depth of the same
1914-
// node universe the screens walk below reads. The predicate mirrors the one
1915-
// reader of the key: the console's `FlowRunner` names the flow by it, in
1916-
// its header and its completion toast, and the runner opens only on a run
1917-
// paused at a screen. A flow that can never pause there (record-triggered,
1918-
// scheduled, an API flow with no screen) has no surface that shows its
1919-
// translated label, so demanding one would ask an author for a string that
1920-
// is stored and never read. When a reader of a non-screen flow's label
1921-
// lands (a run-result toast, say), this predicate widens in the same change
1922-
// as that reader.
1915+
// Demanded for a flow with a `screen` node or an `approval` node, at any
1916+
// depth of the same node universe the walks below read. The predicate
1917+
// mirrors the two readers of the key, and nothing wider:
1918+
//
1919+
// - the console's `FlowRunner` names the flow by it, in its header and its
1920+
// completion toast, and the runner opens only on a run paused at a screen;
1921+
// - [#24916] the approvals door names a request's flow by it in the
1922+
// reader's locale (`plugin-approvals` `reader-locale-labels.ts`, the
1923+
// inbox, the record's approval panel and the request page), and a request
1924+
// exists only where an `approval` node opened one.
1925+
//
1926+
// A flow with neither (record-triggered, scheduled or an API flow that
1927+
// only does work) has no surface that shows its translated label: the
1928+
// console's launch hosts toast the action's copy or the flow's own
1929+
// `successMessage` / `errorMessage`, never its label. Demanding one would
1930+
// ask an author for a string that is stored and never read, so `Flow.label`
1931+
// being required by the schema is not the test. When a reader of such a
1932+
// flow's label lands, this predicate widens in the same change as that
1933+
// reader.
19231934
const hasScreen = nodes.some((node) => node && typeof node === 'object' && node.type === SCREEN_NODE_TYPE);
1924-
if (hasScreen) {
1935+
const approvalNodes = nodes.filter((node) => node && typeof node === 'object' && node.type === APPROVAL_NODE_TYPE);
1936+
if (hasScreen || approvalNodes.length > 0) {
19251937
pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope);
19261938
}
19271939

@@ -2008,6 +2020,23 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void {
20082020
if (!nodeId) continue;
20092021
pushOptional(out, ['flows', flowName, 'refusals', nodeId, 'message'], inlineText(cfg.message), 'flow', scope);
20102022
}
2023+
2024+
// [#24916] `flows.<flow>.approvals.<node_id>.label` — an `approval` node's
2025+
// step label, the name its request shows the submitter and the approvers.
2026+
// The approvals door reads that key in the reader's locale for the
2027+
// request's `current_step`, which is the node's `FlowNode.id`, and its step
2028+
// progress names every approval step the same way; the stored
2029+
// `current_step_label` is the authored `FlowNode.label` the seed carries.
2030+
// The key's one leaf is `label` because the node config declares no
2031+
// person-facing heading of its own (`translation.zod.ts`, `approvals`).
2032+
// Same node universe as the walks above, so the walk reaches every id
2033+
// `os validate` accepts the key for.
2034+
for (const node of approvalNodes) {
2035+
const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined;
2036+
// No id, no key: the request's `current_step` could not name the node.
2037+
if (!nodeId) continue;
2038+
pushOptional(out, ['flows', flowName, 'approvals', nodeId, 'label'], node.label, 'flow', scope);
2039+
}
20112040
}
20122041
}
20132042

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// objectstack#24916 — an approval flow's label and each approval step's label
4+
// join the flow bucket.
5+
//
6+
// The approvals door names a request's flow and its current step in the
7+
// READER's locale (`plugin-approvals` `reader-locale-labels.ts`), from
8+
// `flows.<flow>.label` and `flows.<flow>.approvals.<node_id>.label`. The walker
9+
// asked for the flow label only on a flow with a `screen` node, and for no
10+
// approval step at all, so `os lint` and `os i18n check` never listed an
11+
// approval flow as untranslated: HotCRM's six approval flows rendered English
12+
// in zh-CN / ja-JP / es-ES under a green coverage run. These pins are the CLI
13+
// half: the walker scaffolds both keys, the coverage gate demands them, and a
14+
// flow no reader names (no screen, no approval) still owes no label.
15+
//
16+
// The ledger mock is `i18n-flow-screen-coverage.test.ts`'s, for its reason:
17+
// these pins measure the bucket itself, whatever the shipped ledger says next.
18+
19+
import { describe, it, expect, vi } from 'vitest';
20+
21+
vi.mock('@objectstack/lint', async (importOriginal) => ({
22+
...(await importOriginal<typeof import('@objectstack/lint')>()),
23+
authorWarnedProperties: () => new Set<string>(),
24+
}));
25+
26+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
27+
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
28+
import { TranslationDataSchema } from '@objectstack/spec/system';
29+
30+
/** An approval-only flow: record-triggered, two approval steps, no screen. */
31+
const expenseApproval = {
32+
name: 'expense_approval',
33+
label: 'Expense Approval',
34+
type: 'autolaunched',
35+
nodes: [
36+
{ id: 'start', type: 'start', label: 'On Expense Created', config: { objectName: 'expense', triggerType: 'record-after-insert' } },
37+
{ id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'org_membership_level', value: 'owner' }] } },
38+
{ id: 'finance_review', type: 'approval', label: 'Finance Review', config: { approvers: [{ type: 'org_membership_level', value: 'owner' }] } },
39+
{ id: 'approved', type: 'end', label: 'Approved' },
40+
],
41+
edges: [],
42+
};
43+
44+
/** A screen flow, the shape the bucket already covered. */
45+
const quickNote = {
46+
name: 'quick_note',
47+
label: 'Quick Note',
48+
type: 'screen',
49+
nodes: [
50+
{ id: 'start', type: 'start', label: 'Start' },
51+
{ id: 'ask', type: 'screen', label: 'Ask', config: { title: 'Write a note', fields: [{ name: 'note', label: 'Note' }] } },
52+
{ id: 'done', type: 'end', label: 'Done' },
53+
],
54+
edges: [],
55+
};
56+
57+
/** A scheduled flow with no screen and no approval: no reader names it. */
58+
const nightlyDigest = {
59+
name: 'nightly_digest',
60+
label: 'Nightly Digest',
61+
type: 'schedule',
62+
nodes: [
63+
{ id: 'start', type: 'start', label: 'Start' },
64+
{ id: 'mail', type: 'send_email', label: 'Mail' },
65+
],
66+
edges: [],
67+
};
68+
69+
const flowKeys = (config: any) =>
70+
collectExpectedEntries(config).filter((e) => e.path[0] === 'flows').map((e) => e.path.join('.'));
71+
72+
describe('what the walker harvests from an approval flow (#24916)', () => {
73+
it('emits the flow label and each approval step label, seeded with the authored text', () => {
74+
const entries = collectExpectedEntries({ flows: [expenseApproval] }).filter((e) => e.path[0] === 'flows');
75+
76+
expect(entries.map((e) => e.path.join('.'))).toEqual([
77+
'flows.expense_approval.label',
78+
'flows.expense_approval.approvals.manager_review.label',
79+
'flows.expense_approval.approvals.finance_review.label',
80+
]);
81+
expect(entries[0]).toMatchObject({ sourceValue: 'Expense Approval', inline: 'Expense Approval', source: 'flow', flowName: 'expense_approval' });
82+
expect(entries[1]).toMatchObject({ sourceValue: 'Manager Review', inline: 'Manager Review', source: 'flow', flowName: 'expense_approval' });
83+
});
84+
85+
it('skips an approval node with no id: the request could not name it', () => {
86+
const flow = { ...expenseApproval, nodes: [{ type: 'approval', label: 'Anonymous Review' }] };
87+
expect(flowKeys({ flows: [flow] })).toEqual(['flows.expense_approval.label']);
88+
});
89+
90+
it('still emits no label for a flow with neither a screen nor an approval node', () => {
91+
expect(flowKeys({ flows: [nightlyDigest] })).toEqual([]);
92+
});
93+
});
94+
95+
describe('the coverage gate demands the approval flow and its steps (#24916)', () => {
96+
const tree = (flows?: Record<string, unknown>) => ({
97+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
98+
flows: [expenseApproval, quickNote, nightlyDigest],
99+
translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }],
100+
});
101+
const zhFlowIssues = (config: any) =>
102+
computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.source === 'flow');
103+
104+
it('lists the untranslated approval flow label and step labels in the flow bucket', () => {
105+
const keys = zhFlowIssues(tree()).map((i) => i.key).filter((k) => k.startsWith('flows.expense_approval.'));
106+
expect(keys).toEqual([
107+
'flows.expense_approval.label',
108+
'flows.expense_approval.approvals.manager_review.label',
109+
'flows.expense_approval.approvals.finance_review.label',
110+
]);
111+
});
112+
113+
it('keeps the step labels listed while only the flow label is translated', () => {
114+
const keys = zhFlowIssues(tree({ expense_approval: { label: '报销审批' } }))
115+
.map((i) => i.key)
116+
.filter((k) => k.startsWith('flows.expense_approval.'));
117+
expect(keys).toEqual([
118+
'flows.expense_approval.approvals.manager_review.label',
119+
'flows.expense_approval.approvals.finance_review.label',
120+
]);
121+
});
122+
123+
it('goes quiet for the approval flow once its label and every step are translated', () => {
124+
const keys = zhFlowIssues(tree({
125+
expense_approval: {
126+
label: '报销审批',
127+
approvals: { manager_review: { label: '经理审核' }, finance_review: { label: '财务审核' } },
128+
},
129+
})).map((i) => i.key);
130+
expect(keys.filter((k) => k.startsWith('flows.expense_approval.'))).toEqual([]);
131+
});
132+
133+
it('keeps the screen flow\'s demand exactly as it was, and asks nothing of the scheduled flow', () => {
134+
const keys = zhFlowIssues(tree()).map((i) => i.key);
135+
expect(keys.filter((k) => k.startsWith('flows.quick_note.'))).toEqual([
136+
'flows.quick_note.label',
137+
'flows.quick_note.screens.ask.title',
138+
'flows.quick_note.screens.ask.fields.note.label',
139+
]);
140+
expect(keys.filter((k) => k.startsWith('flows.nightly_digest.'))).toEqual([]);
141+
});
142+
143+
it('accepts the translated pack the way the translation contract declares it', () => {
144+
// The key the gate asks for is one the face declares: the translation that
145+
// silences it parses with no unrecognized key.
146+
const parsed = TranslationDataSchema.safeParse({
147+
flows: { expense_approval: { label: '报销审批', approvals: { manager_review: { label: '经理审核' } } } },
148+
});
149+
expect(parsed.success ? [] : parsed.error.issues).toEqual([]);
150+
});
151+
});
152+
153+
describe('the skeleton `os i18n extract` writes for an approval flow (#24916)', () => {
154+
const result = extractTranslations({ flows: [expenseApproval] }, { locales: ['en', 'zh-CN'] });
155+
156+
it('writes the authored labels into the default locale and empty slots into the others', () => {
157+
expect((result.bundles.en as any).flows.expense_approval).toEqual({
158+
label: 'Expense Approval',
159+
approvals: { manager_review: { label: 'Manager Review' }, finance_review: { label: 'Finance Review' } },
160+
});
161+
expect((result.bundles['zh-CN'] as any).flows.expense_approval).toEqual({
162+
label: '',
163+
approvals: { manager_review: { label: '' }, finance_review: { label: '' } },
164+
});
165+
});
166+
167+
it('parses through the schema it is written for, in every locale', () => {
168+
for (const [locale, bundle] of Object.entries(result.bundles)) {
169+
const parsed = TranslationDataSchema.safeParse(bundle);
170+
expect(parsed.success ? [] : parsed.error.issues, `skeleton for ${locale}`).toEqual([]);
171+
}
172+
});
173+
});

‎packages/cli/test/i18n-flow-screen-coverage.test.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -608,12 +608,15 @@ describe('a screen inside an ADR-0031 region (#17511)', () => {
608608

609609
// ── The flow's own label is demanded only for a flow the runner can open ────
610610
//
611-
// The console's `FlowRunner` is the one reader of `flows.<flow>.label`: it
612-
// names the flow in its header and its completion toast, and it opens only on
613-
// a run paused at a screen node. So the walker emits the label entry only for
614-
// a flow with a screen node somewhere in the node universe the screens walk
615-
// reads, at any depth. Both sides are pinned: a flow with no screen node owes
616-
// no label, and a flow whose only screen sits inside a region still owes it.
611+
// The console's `FlowRunner` reads `flows.<flow>.label`: it names the flow in
612+
// its header and its completion toast, and it opens only on a run paused at a
613+
// screen node. So the walker emits the label entry for a flow with a screen
614+
// node somewhere in the node universe the screens walk reads, at any depth.
615+
// Both sides are pinned: a flow with no screen node owes no label, and a flow
616+
// whose only screen sits inside a region still owes it. The approvals door is
617+
// the key's other reader, so a flow with an `approval` node owes it too
618+
// (#24916, pinned in `i18n-flow-approval-coverage.test.ts`); the scheduled
619+
// flow below has neither.
617620

618621
/** A scheduled flow whose nodes include a region, and no screen at any depth. */
619622
const nightlyDigest = {

0 commit comments

Comments
 (0)