From e2707afbd4a52fee38dce65507d0dd567bd45b90 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 01:16:27 -0700 Subject: [PATCH] test(core): end-to-end workflow-settings journey + surface enumeration; docs and changeset --- .changeset/workflow-settings-mechanism.md | 10 + CONCEPTS.md | 6 + docs/settings-reference.md | 65 ++++ docs/workflow-steps.md | 28 ++ .../__tests__/workflow-settings-e2e.test.ts | 336 ++++++++++++++++++ 5 files changed, 445 insertions(+) create mode 100644 .changeset/workflow-settings-mechanism.md create mode 100644 packages/core/src/__tests__/workflow-settings-e2e.test.ts diff --git a/.changeset/workflow-settings-mechanism.md b/.changeset/workflow-settings-mechanism.md new file mode 100644 index 0000000000..cd39ecc1e0 --- /dev/null +++ b/.changeset/workflow-settings-mechanism.md @@ -0,0 +1,10 @@ +--- +"@runfusion/fusion": minor +--- + +Add a first-class workflow settings mechanism and hard-move execution policy onto it. + +- **Workflow settings.** Workflows now declare typed settings in their IR (id, type, default, options) — the same authoring pattern as custom task fields. Setting *values* persist per `(workflow, project)` behind a single validating store authority, and the engine resolves *effective settings* per task (`stored value ?? declaration default`, dropping values that no longer validate). Built-in `builtin:coding` declares every moved key with its former default, so an untuned project behaves identically. +- **Hard-move migration.** A one-time, idempotent, per-project migration relocates the step-execution, review/approval, and per-phase model-lane keys out of project/global settings into workflow setting values, removing them from the settings schema entirely. A `MOVED_SETTINGS_KEYS` tombstone list shields cross-node sync, v1 imports, and stale writers from resurrecting a moved key; a consistency test enforces one home per key. +- **Settings UI redesign.** The Settings modal is rebuilt from shared schema-driven field primitives and per-section components; moved settings show a redirect stub linking to the workflow editor (one release). The new **Workflow editor → Settings** panel (Definitions/Values tabs) and the `fn_workflow_settings` agent tool edit values with typed validation. +- **Export v2.** Settings export bumps to version 2 with a `workflowSettings` value section; importing a v1 export upgrades any moved key it carries into the appropriate workflow's values. Workflow settings are not synced across nodes yet (surfaced in the sync UI). diff --git a/CONCEPTS.md b/CONCEPTS.md index 930f075379..fd47dbecd2 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -13,6 +13,12 @@ User-level settings persisted server-side that apply across all Surfaces and all ### Workflow Setting A typed setting declared by a workflow in its IR (id, type, default, options), mirroring the custom-task-field shape. Declarations describe the schema; *values* persist per workflow + project through a single validating store authority, so built-in workflows can carry values without their IR being editable. The engine consumes **effective settings** — stored value falling back to declaration default, with values that no longer validate against the current declaration dropped (never fed to execution). +### Effective Settings +The per-task, flat `Partial`-shaped value map the engine reads at executor entry, composed from the task's resolved workflow: for each declared Workflow Setting, the stored `(workflowId, projectId)` value falls back to the declaration default, with stored values that no longer validate against the current declaration dropped. Resolution never throws — a missing or corrupt workflow degrades to the built-in coding declarations — so every read site receives a usable value. Because built-in declaration defaults are byte-equal to the legacy project-settings defaults, an untuned project resolves to identical behavior across the settings hard-move. + +### Moved Settings Keys +The tombstone allowlist (`MOVED_SETTINGS_KEYS`) of the step-execution, review/approval, and per-phase model-lane keys that the one-time hard-move migration relocated from project/global settings into Workflow Settings. It is the single record of the old names and shields every surface that can encounter a legacy payload — cross-node sync diffs, v1 settings imports, and stale writers — from resurrecting a moved key. A consistency test enforces that a key lives in exactly one regime (project settings *or* the tombstone list, never both). + ### Three-Tier Setting The named persistence pattern for a user preference on the dashboard: a device-local cache for instant reads, a write-through to Global Settings so other Surfaces see it, and a hydrate-on-mount from the server when no local value exists. A local or in-flight user choice always wins over server hydration, and changes propagate to other open tabs. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 66559c11b7..c13e065959 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -173,10 +173,75 @@ fn settings set updateCheckEnabled false --- +## Workflow Settings + +Some knobs that used to live in this Settings reference as project settings are now +**workflow settings**: they are declared by a workflow and their values are stored +**per `(workflow, project)`**, not as ambient project settings. A workflow models +*how* tasks execute, so the timeouts, review gates, and per-phase model lanes that +govern that execution belong to the workflow. + +**Where to set them.** Open the **workflow editor** (the workflow node editor in the +dashboard) and select the **Settings** panel. It has two tabs: + +- **Definitions** — the typed declarations and defaults (read-only for the built-in + `builtin:coding` workflow; editable for custom workflows). +- **Values** — the per-project values for the workflow that is open. Values are + editable for any workflow, including built-ins. Edits batch and commit through a + single **Save** in the Values tab. + +**How values resolve.** The engine resolves *effective settings* per task as +`stored value ?? declaration default`. A built-in workflow with no stored value +falls back to the declaration default, which is byte-equal to the legacy project +default — so an untuned project behaves exactly as before. Switching a project to a +**new** custom workflow starts that workflow from its own declaration defaults, not +the project's prior customized values. + +**Agents.** `fn_workflow_create`/`fn_workflow_update` accept `settings` declarations, +and the `fn_workflow_settings` tool reads and writes values with the same typed +validation as the editor (invalid values are rejected, never persisted). See +[engine tools reference](../packages/cli/skill/fusion/references/engine-tools.md). + +**Sync & export.** + +- Workflow settings are **not synced across nodes yet** (a node-sync channel for the + value table is planned). Cross-node settings sync filters these keys out of its + diff and surfaces a "Workflow settings are not synced across nodes yet" note. +- Workflow setting values **are** included in **settings export v2** under a + `workflowSettings` section keyed `workflowId → { settingKey: value }`. Importing a + v1 export upgrades any moved key it carries into the appropriate workflow's values + instead of writing it back into project settings. + +### Where did my setting go? + +These groups moved out of project settings and into workflow settings (built-in +`builtin:coding` declares all of them with their former defaults): + +| Group | Keys (examples) | +|---|---| +| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | +| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | +| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) | + +In the dashboard Settings modal, the former locations now show a short redirect stub +linking to the workflow editor (for one release). Set these in the workflow editor's +**Settings → Values** tab for the workflow you want to tune. + +> Note: the global baseline model lanes (`executionGlobalProvider` etc.) and +> integrity guarantees stay where they are — only the per-workflow process policy +> moved. + ## Project Settings Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`. +> **Moved keys retained for reference.** Some rows below — the step-execution, +> review/approval, and per-phase model-lane keys listed under +> [Where did my setting go?](#where-did-my-setting-go) — are no longer project +> settings. They are documented here for type/default reference only; configure them +> in the **workflow editor → Settings → Values** tab. They are not writable through +> `PUT /api/settings`. + | Setting | Type | Default | Description | |---|---|---:|---| | `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. | diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index b093b0723f..12c0ed263b 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -462,6 +462,34 @@ Parity coverage includes flag-OFF no-op behavior, lifecycle ordering parity vs l | `GET /api/workflow-step-templates` | List built-in templates | | `POST /api/workflow-step-templates/:id/create` | Materialize template as workflow step | +## Workflow Settings + +Workflows can declare **typed settings** in their IR — the same authoring pattern as +custom task fields, one level up. A setting declaration carries `{ id, name, type, +default?, options?, description? }` with the type whitelist `string | text | number | +boolean | enum | multi-enum`. Declarations are validated at save (unique ids, type +whitelist, options only for enum kinds, default validates against its own type). + +Setting **values** persist per `(workflowId, projectId)` in a dedicated value table, +separate from the declarations: built-in workflows declare settings but their +declarations are non-editable, while their *values* are writable per project. The +engine resolves *effective settings* per task as `stored value ?? declaration +default`, dropping any stored value that no longer validates against the current +declaration (drop-on-orphan) and falling back to the default. + +The **step-execution**, **review/approval**, and **per-phase model-lane** knobs that +used to be project settings are now workflow settings declared by `builtin:coding` +with their former defaults. See +[Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings) +for the full moved-key catalog, the editor walkthrough, and the export/sync posture. + +Authoring surfaces: + +- **Workflow editor → Settings panel** — Definitions (declarations/defaults) and + Values (per-project) tabs. +- **Agent tools** — `fn_workflow_create`/`fn_workflow_update` accept `settings` + declarations; `fn_workflow_settings` reads/writes values. + ## Screenshot ![Workflow step manager](./screenshots/workflow-steps.png) diff --git a/packages/core/src/__tests__/workflow-settings-e2e.test.ts b/packages/core/src/__tests__/workflow-settings-e2e.test.ts new file mode 100644 index 0000000000..614dd217c1 --- /dev/null +++ b/packages/core/src/__tests__/workflow-settings-e2e.test.ts @@ -0,0 +1,336 @@ +/** + * U10 — End-to-end characterization of the workflow-settings hard-move (R3, R6, R7). + * + * This is the parity-closure suite: it proves the whole move is behavior-preserving + * across one deterministic journey, with NO real polling and NO slow work (in-memory + * timers are unnecessary — every step is synchronous store/resolver work; the store + * is opened on a temp dir with a disk-backed DB so the raw `config.settings` row and + * the global settings file survive across the seeding/migration steps, exactly as the + * settings-migration suite does). + * + * The journey (single test): + * a. Build a PRE-migration store state: a project with customized MOVED keys + * (`workflowStepTimeoutMs`, `requirePrApproval`, `executionProvider`) written + * into the RAW `config.settings` row the way a v108-era store would hold them — + * BEFORE the migration runner fires (marker cleared, raw seeded). Pattern reused + * from settings-migration.test.ts (`seedRawProjectSettings` + `clearMarker`). + * b. Run the migration → assert effective values via `resolveEffectiveSettingsById` + * equal the customized values (engine-parity anchor). + * c. Edit a value via `store.updateWorkflowSettingValues` (the panel/tool write + * path) → assert `resolveEffectiveSettingsById` reflects it. + * d. Export via `exportSettings` (v2) → wipe (fresh store/project) → `importSettings` + * → assert identical effective values, including the `workflowSettings` section + * round-trip. + * e. Assert NO moved key exists in raw project settings at any point post-migration, + * and an unrelated settings save does not resurrect them. + * + * ── Surface-enumeration checklist (FN-5893 discipline) ──────────────────────────── + * Every surface that touches workflow settings carries at least one assertion in a + * dedicated suite. The `surface-enumeration` describe block below asserts each of + * these files exists (cheap meta-test) so the parity coverage cannot silently rot: + * + * - engine (effective-settings): + * packages/engine/src/__tests__/effective-settings-merge.test.ts + * packages/engine/src/__tests__/effective-settings-model-lane.test.ts + * packages/engine/src/__tests__/workflow-settings-fallback-alignment.test.ts + * - dashboard settings modal (moved-keys sweep): + * packages/dashboard/app/__tests__/settings-moved-keys.test.ts + * - workflow editor (WorkflowSettingsPanel): + * packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx + * - CLI (settings commands): + * packages/cli/src/commands/__tests__/settings.test.ts + * - agent tools: + * packages/engine/src/__tests__/agent-tools-workflow-settings.test.ts + * - export/import: + * packages/core/src/__tests__/settings-export.test.ts + * - cross-node sync: + * packages/dashboard/src/__tests__/routes-nodes-sync.test.ts + * - consistency drift guard: + * packages/core/src/__tests__/settings-consistency.test.ts + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TaskStore } from "../store.js"; +import { + MOVED_SETTINGS_KEYS, + SETTINGS_MIGRATION_VERSION, + SETTINGS_MIGRATION_MARKER_KEY, +} from "../moved-settings.js"; +import { + resolveEffectiveSettingsById, + type WorkflowSettingsResolverStore, +} from "../workflow-settings-resolver.js"; +import { PROJECT_SETTINGS_KEYS } from "../settings-schema.js"; +import { exportSettings, importSettings } from "../settings-export.js"; + +// ── Test harness (mirrors settings-migration.test.ts) ───────────────────────── + +interface Env { + tempDir: string; + fusionDir: string; + globalSettingsDir: string; +} + +function createEnv(prefix: string): Env { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + const fusionDir = join(tempDir, ".fusion"); + const tasksDir = join(fusionDir, "tasks"); + const globalSettingsDir = join(tempDir, "global-settings"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(globalSettingsDir, { recursive: true }); + writeFileSync(join(globalSettingsDir, "settings.json"), JSON.stringify({})); + return { tempDir, fusionDir, globalSettingsDir }; +} + +async function openStore(env: Env): Promise { + const { TaskStore } = await import("../store.js"); + // Disk-backed DB so the raw config row + global settings file survive the + // seed → migrate steps (an in-memory DB would not retain the seeded raw row). + const store = new TaskStore(env.tempDir, env.globalSettingsDir, { inMemoryDb: false }); + await store.init(); + return store; +} + +/** Low-level raw db handle. */ +function rawDb(store: TaskStore): { + prepare: (sql: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown; all: (...a: unknown[]) => unknown }; +} { + return (store as unknown as { db: ReturnType }).db; +} + +/** Overwrite the RAW persisted project `config.settings` JSON. */ +function seedRawProjectSettings(store: TaskStore, settings: Record): void { + const db = rawDb(store); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO config (id, nextWorkflowStepId, settings, workflowSteps, updatedAt) + VALUES (1, 1, ?, '[]', ?) + ON CONFLICT(id) DO UPDATE SET settings = excluded.settings, updatedAt = excluded.updatedAt`, + ).run(JSON.stringify(settings), now); +} + +/** Read the RAW persisted project settings JSON back. */ +function readRawProjectSettings(store: TaskStore): Record { + const row = rawDb(store).prepare("SELECT settings FROM config WHERE id = 1").get() as + | { settings: string } + | undefined; + if (!row) return {}; + return JSON.parse(row.settings) as Record; +} + +function clearMarker(store: TaskStore): void { + rawDb(store).prepare("DELETE FROM __meta WHERE key = ?").run(SETTINGS_MIGRATION_MARKER_KEY); +} + +function readMarker(store: TaskStore): number | undefined { + const row = rawDb(store).prepare("SELECT value FROM __meta WHERE key = ?").get(SETTINGS_MIGRATION_MARKER_KEY) as + | { value: string } + | undefined; + return row ? Number(row.value) : undefined; +} + +async function runMigration(store: TaskStore): Promise { + await (store as unknown as { migrateMovedSettingsToWorkflowValuesOnce(): Promise }).migrateMovedSettingsToWorkflowValuesOnce(); +} + +const resolverStore = (store: TaskStore) => store as unknown as WorkflowSettingsResolverStore; + +/** Assert no moved key is present in the raw project settings JSON. */ +function expectNoMovedKeysInRaw(store: TaskStore): void { + const raw = readRawProjectSettings(store); + for (const key of MOVED_SETTINGS_KEYS) { + expect(raw[key]).toBeUndefined(); + } +} + +// ── The canonical end-to-end journey ────────────────────────────────────────── + +describe("workflow-settings end-to-end journey (U10)", () => { + let env: Env; + let store: TaskStore; + + beforeEach(async () => { + env = createEnv("fn-wf-settings-e2e-"); + store = await openStore(env); + }); + + afterEach(async () => { + try { + await store.close(); + } catch { + /* ignore */ + } + try { + rmSync(env.tempDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("pre-migration customized project → migrate → edit → export v2 → wipe → import → identical effective values; moved keys never resurrect", async () => { + const projectId = store.getWorkflowSettingsProjectId(); + + // ── (a) PRE-migration state: a v108-era project with customized MOVED keys + // written into the RAW config.settings row, marker cleared so the runner fires. + const customized = { + // Unrelated, non-moved project key — must survive the whole journey untouched. + maxConcurrent: 3, + // Customized moved keys (step execution, review/approval, model lane). + workflowStepTimeoutMs: 120_000, + requirePrApproval: true, + executionProvider: "openai", + }; + seedRawProjectSettings(store, customized); + clearMarker(store); + + // Sanity: pre-migration, the raw row holds the moved keys (legacy shape). + expect(readRawProjectSettings(store).workflowStepTimeoutMs).toBe(120_000); + + // ── (b) Migration fires → effective values equal the customized values. + await runMigration(store); + + expect(readMarker(store)).toBe(SETTINGS_MIGRATION_VERSION); + // No moved key remains in the settings SCHEMA after the hard-move. + for (const key of MOVED_SETTINGS_KEYS) { + expect((PROJECT_SETTINGS_KEYS as readonly string[]).includes(key)).toBe(false); + } + // (e, part 1) Raw project settings lost the moved keys; unrelated key stayed. + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(3); + + // Engine-parity: resolved effective values equal the pre-migration customized + // values for the project's default-resolved workflow (builtin:coding). + const postMigration = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(postMigration.workflowStepTimeoutMs).toBe(120_000); + expect(postMigration.requirePrApproval).toBe(true); + expect(postMigration.executionProvider).toBe("openai"); + + // ── (c) Edit a value via the panel/tool write path → resolution reflects it. + await store.updateWorkflowSettingValues("builtin:coding", projectId, { + workflowStepTimeoutMs: 222_000, + }); + const afterEdit = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterEdit.workflowStepTimeoutMs).toBe(222_000); + // The other migrated values are unchanged by the single-key edit. + expect(afterEdit.requirePrApproval).toBe(true); + expect(afterEdit.executionProvider).toBe("openai"); + + // (e, part 2) An UNRELATED settings save must NOT resurrect any moved key + // (the default re-injection trap) and must not disturb effective values. + await store.updateSettings({ maxConcurrent: 9 }); + expectNoMovedKeysInRaw(store); + expect(readRawProjectSettings(store).maxConcurrent).toBe(9); + const afterUnrelatedSave = await resolveEffectiveSettingsById(resolverStore(store), "builtin:coding", projectId); + expect(afterUnrelatedSave.workflowStepTimeoutMs).toBe(222_000); + expect(afterUnrelatedSave.requirePrApproval).toBe(true); + + // ── (d) Export v2 → carries the workflowSettings value section, no moved keys + // under `project`. + const exported = await exportSettings(store, { scope: "both" }); + expect(exported.version).toBe(2); + expect(exported.workflowSettings).toBeDefined(); + const exportedBuiltin = exported.workflowSettings?.["builtin:coding"]; + expect(exportedBuiltin).toBeDefined(); + expect(exportedBuiltin?.workflowStepTimeoutMs).toBe(222_000); + expect(exportedBuiltin?.requirePrApproval).toBe(true); + expect(exportedBuiltin?.executionProvider).toBe("openai"); + // Moved keys never appear under `project` in a v2 export. + for (const key of MOVED_SETTINGS_KEYS) { + expect((exported.project as Record | undefined)?.[key]).toBeUndefined(); + } + // The unrelated project key is carried under `project`. + expect((exported.project as Record | undefined)?.maxConcurrent).toBe(9); + + // ── Wipe: a brand-new store/project (fresh temp dir, fresh DB). + const env2 = createEnv("fn-wf-settings-e2e-import-"); + const store2 = await openStore(env2); + try { + const projectId2 = store2.getWorkflowSettingsProjectId(); + + // The fresh project has declaration defaults (NOT the source project's values). + const freshBefore = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(freshBefore.workflowStepTimeoutMs).toBe(360_000); // legacy/declaration default + expect(freshBefore.requirePrApproval).toBe(false); + + // ── Import the v2 export → effective values match the exported project, + // INCLUDING the workflowSettings section round-trip. + const importResult = await importSettings(store2, exported, { scope: "both" }); + expect(importResult.success).toBe(true); + expect(importResult.workflowSettingsCount).toBeGreaterThan(0); + + const imported = await resolveEffectiveSettingsById(resolverStore(store2), "builtin:coding", projectId2); + expect(imported.workflowStepTimeoutMs).toBe(222_000); + expect(imported.requirePrApproval).toBe(true); + expect(imported.executionProvider).toBe("openai"); + + // The imported project carries the unrelated key but never a moved key in raw. + expect(readRawProjectSettings(store2).maxConcurrent).toBe(9); + expectNoMovedKeysInRaw(store2); + + // (e, part 3) A post-import unrelated save on the destination store also does + // not resurrect moved keys. + await store2.updateSettings({ maxConcurrent: 4 }); + expectNoMovedKeysInRaw(store2); + } finally { + try { + await store2.close(); + } catch { + /* ignore */ + } + rmSync(env2.tempDir, { recursive: true, force: true }); + } + }); +}); + +// ── Surface-enumeration meta-test (FN-5893 discipline) ──────────────────────── +// +// A cheap structural guard: every surface that consumes/manages workflow settings +// must keep at least one dedicated test suite. If any surface's suite is renamed or +// deleted without a replacement, this fails loudly so parity coverage can't rot. + +describe("workflow-settings surface enumeration (FN-5893)", () => { + // Resolve the monorepo `packages/` root from this file's location: + // .../packages/core/src/__tests__/ → up 4 → packages/ + const packagesRoot = resolve(fileURLToPath(import.meta.url), "../../../.."); + + const surfaceSuites: Record = { + "engine (effective-settings)": [ + "engine/src/__tests__/effective-settings-merge.test.ts", + "engine/src/__tests__/effective-settings-model-lane.test.ts", + "engine/src/__tests__/workflow-settings-fallback-alignment.test.ts", + ], + "dashboard settings modal (moved-keys sweep)": [ + "dashboard/app/__tests__/settings-moved-keys.test.ts", + ], + "workflow editor (WorkflowSettingsPanel)": [ + "dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx", + ], + "CLI (settings command)": [ + "cli/src/commands/__tests__/settings.test.ts", + ], + "agent tools": [ + "engine/src/__tests__/agent-tools-workflow-settings.test.ts", + ], + "export / import": [ + "core/src/__tests__/settings-export.test.ts", + ], + "cross-node sync": [ + "dashboard/src/__tests__/routes-nodes-sync.test.ts", + ], + "consistency drift guard": [ + "core/src/__tests__/settings-consistency.test.ts", + ], + }; + + for (const [surface, files] of Object.entries(surfaceSuites)) { + it(`${surface} has a dedicated workflow-settings suite`, () => { + for (const rel of files) { + const abs = join(packagesRoot, rel); + expect(existsSync(abs), `expected surface test to exist: ${rel}`).toBe(true); + } + }); + } +});