From 86e6db2265cff4fe8e43659190d1acda48fd1969 Mon Sep 17 00:00:00 2001 From: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:03:12 +0100 Subject: [PATCH 1/4] =?UTF-8?q?docs(scheduling):=20requirements=20=C2=A710?= =?UTF-8?q?.17=20+=20feature-flow=20note=20for=20client-side=20cron=20vali?= =?UTF-8?q?dation=20(#925)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule #1 delta before code: mirror contract (client validates with the backend's exact grammar), shared probe-generated fixture as the drift alarm, fail-open posture, submit-gating semantics (empty ⇒ native required), per-row warning icon with exact tooltip, ASCII-digit divergence note. Co-Authored-By: Claude Fable 5 --- docs/memory/feature-flows/scheduling.md | 26 ++++++++++++++ docs/memory/requirements/scheduling.md | 48 +++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/docs/memory/feature-flows/scheduling.md b/docs/memory/feature-flows/scheduling.md index efcc2455e..641eba585 100644 --- a/docs/memory/feature-flows/scheduling.md +++ b/docs/memory/feature-flows/scheduling.md @@ -693,6 +693,32 @@ Broadcast via dedicated scheduler -> Redis pub/sub -> backend WebSocket relay fo - `0 */6 * * *` - Every 6 hours - `*/30 * * * *` - Every 30 minutes +### Client-side cron validation (#925) + +The schedule form pre-validates the cron expression as the user types, and the list marks +stored-invalid rows — the backend 400 is no longer the first feedback (design-system p17). + +- **Validator**: `src/frontend/src/utils/cronValidation.js` — a pure, zero-dependency mirror of + the backend's exact grammar (`services/schedule_validation.py::validate_cron_expression`: + 5-field split + verbatim `_dow_to_apscheduler` port + APScheduler 3.11 field rules, including + the prefix-matched name expressions, the step-span rule, and the Python-truthiness + `last or MAX` fallback). Total + fail-open: an internal error returns `{valid: true}` so a + port bug can never brick the panel; the server 400 stays the authority. +- **Form gating** (`SchedulesPanel.vue`): `shouldShowCronError` (pure, exported) — while + creating, the inline error appears only after first blur and only for non-empty input; while + editing, an invalid stored cron (incl. empty) shows unconditionally. The format-hint line is + the reserved error slot (no modal jump). Submit is disabled only when the cron is + **non-empty AND invalid** — empty keeps the native `required` bubble. Presets come from the + exported `CRON_PRESETS` and are fixture-proven valid. +- **Row icon**: `computeCronValidityMap(schedules)` → warning triangle inside the cron chip for + invalid rows, tooltip/aria-label exactly `Invalid cron expression`. +- **Drift alarm**: both suites assert the shared probe-generated fixture + `tests/fixtures/cron-grammar-cases.json` — `tests/unit/test_925_cron_grammar_fixture.py` + against the live backend validator (fires on an APScheduler grammar change), + `src/frontend/tests/unit/cronValidation.spec.js` against the client mirror. Quirk rows + (`MON/999`, `jan/0`, `lastx`, `0-6,1` vs `0-6`, falsy-zero `0-0/2`) are pinned verbatim and + must not be "fixed" — parity outranks tidiness. + --- ## Dependencies diff --git a/docs/memory/requirements/scheduling.md b/docs/memory/requirements/scheduling.md index 84765dfc7..0dfc2e7c5 100644 --- a/docs/memory/requirements/scheduling.md +++ b/docs/memory/requirements/scheduling.md @@ -739,6 +739,54 @@ schedules: `docs/memory/feature-flows/scheduling.md`, `docs/memory/feature-flows/agent-compatibility-validation.md` +### 10.17 Client-Side Cron Validation (#925) +- **Status**: ✅ Implemented (2026-08-13) +- **GitHub Issue**: #925 +- **Description**: The schedule form validated cron expressions only at save time — the backend's + 400 was the first feedback — and a stored-invalid row (legacy croniter-era data, or rows created + before a validator tightening) sat in the list indistinguishable from a healthy one. The frontend + now pre-validates as the user types (design-system p17) and marks invalid stored rows. +- **Requirement — mirror contract, not a second grammar**: the client validator + (`src/frontend/src/utils/cronValidation.js`, pure leaf, **zero npm deps**) is a hand-rolled + mirror of the backend's exact acceptance grammar — + `services/schedule_validation.py::validate_cron_expression` = strict 5-field split + + `_dow_to_apscheduler` translation (ported verbatim, branch order included) + APScheduler 3.11 + field rules (`AllExpression`/`RangeExpression`/name-range prefix expressions, step-span rule, + Python-truthiness `last or MAX` fallback — deliberately `||` not `??` in JS). Cron libs + (`cron-parser`, `cronstrue`) were rejected: they implement a foreign grammar with ≥8 proven + verdict disagreements (`@daily`, 6-field, dow `0-6`, `last`, `7/2`, `L`/`#`, step-span, + `5/2`). This is the learnings.md #1472 lesson ("validate with the parser that registers") + applied one level up: the client validates with a pinned mirror of that parser. +- **Drift alarm — shared fixture asserted by both suites**: the grammar contract is + `tests/fixtures/cron-grammar-cases.json` (~110 rows, **generated from a probe against the live + backend validator, never hand-typed**). `tests/unit/test_925_cron_grammar_fixture.py` re-proves + every row against `validate_cron_expression` in the backend CI env (an APScheduler bump that + changes the grammar fails there loudly); `src/frontend/tests/unit/cronValidation.spec.js` + asserts the client mirror agrees row-for-row. Quirk rows (prefix-matched names `MON/999` / + `jan/0` / `lastx`, comma-translation asymmetry `0-6,1` vs `0-6`, falsy-zero `0-0/2`) are + pinned and must NOT be "fixed" — parity outranks tidiness. +- **Fail-open posture**: the validator is total (`String(expr ?? '')`) and a top-level catch + returns `{valid: true}` + `console.error` — an internal port bug must not brick the panel or + block saves. The backend 400 remains the enforcement authority; client validation is UX only. +- **Form gating**: inline error in a reserved-footprint slot (the format-hint line doubles as the + error slot — no modal jump, p4/p6); submit disabled **only when non-empty AND invalid** — an + empty cron keeps the native `required` bubble path; while **editing**, an invalid (incl. + empty/whitespace) stored cron shows its error unconditionally so the disabled Update button is + never unexplained; while creating, errors appear only after first blur (no mid-typing flash). +- **List surface**: each stored row with an invalid `cron_expression` renders a warning triangle + inside its cron chip, tooltip + aria-label exactly `Invalid cron expression` (shape + hue, + p24). Presets are sourced from the exported `CRON_PRESETS` so "presets never warn" is tested + against the shipped list. +- **Documented divergence (client-stricter only)**: the client accepts ASCII digits only where + Python's `int()`/`\d` also accept Unicode digits / `+` signs / underscores — rejecting e.g. + `٥ * * * *` that the server accepts. Direction is client-stricter on absurd input; the + damaging direction (client-invalid/server-valid false warnings on real input) is guarded by + the quirk rows. The fixture marks such rows `divergence: "client-stricter"`. +- **Out of scope**: no backend runtime change (400 contract byte-preserved); no server-side + migration/repair of stored invalid rows; no client timezone validation (the form select is a + fixed known-good list; cron field validity is timezone-independent). +- **Flow**: `docs/memory/feature-flows/scheduling.md` (§ Client-side cron validation) + --- ## 34. Agent-Defined Pipelines (#919) From ebcb5b34fc255fbbbed1301b6d3cde694e5b8eb5 Mon Sep 17 00:00:00 2001 From: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:07:20 +0100 Subject: [PATCH 2/4] feat(frontend): client-side cron validator mirroring the backend grammar, pinned by a probe-generated fixture (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/cronValidation.js is a zero-dependency hand-rolled mirror of services/schedule_validation.py (5-field split + verbatim _dow_to_apscheduler port + APScheduler 3.11 field rules incl. the prefix-match name expressions, the step-span rule, and the Python-truthiness `last or MAX` fallback — JS `||` on purpose, not `??`). Total + fail-open: an internal port bug returns {valid:true}; the server 400 stays the authority. tests/fixtures/cron-grammar-cases.json: 111 rows GENERATED by probing the live validate_cron_expression (apscheduler 3.11.2) — never hand-typed. Asserted row-for-row by BOTH suites so grammar drift fails CI loudly: - tests/unit/test_925_cron_grammar_fixture.py (113 passed) — the drift alarm, runs in the backend CI env with the pinned APScheduler; regeneration procedure in the module docstring. Backend RUNTIME untouched. - src/frontend/tests/unit/cronValidation.spec.js (136 passed) — mirror parity, presets, message-shape substrings, shouldShowCronError truth table, computeCronValidityMap, fail-open guard. The one divergence row (unicode digit ٥, Python \d) is marked divergence:"client-stricter" — server verdict recorded, client deliberately rejects; both suites assert their own side of it. Co-Authored-By: Claude Fable 5 --- src/frontend/src/utils/cronValidation.js | 261 ++++++++ .../tests/unit/cronValidation.spec.js | 192 ++++++ tests/fixtures/cron-grammar-cases.json | 558 ++++++++++++++++++ tests/unit/test_925_cron_grammar_fixture.py | 123 ++++ 4 files changed, 1134 insertions(+) create mode 100644 src/frontend/src/utils/cronValidation.js create mode 100644 src/frontend/tests/unit/cronValidation.spec.js create mode 100644 tests/fixtures/cron-grammar-cases.json create mode 100644 tests/unit/test_925_cron_grammar_fixture.py diff --git a/src/frontend/src/utils/cronValidation.js b/src/frontend/src/utils/cronValidation.js new file mode 100644 index 000000000..9f0d4eca1 --- /dev/null +++ b/src/frontend/src/utils/cronValidation.js @@ -0,0 +1,261 @@ +/** + * cronValidation.js — client-side mirror of the backend's cron acceptance grammar (#925). + * + * SOURCE OF TRUTH: `src/backend/services/schedule_validation.py::validate_cron_expression` + * (strict 5-field split + `_dow_to_apscheduler` translation) + APScheduler 3.11's + * CronTrigger field expressions (`apscheduler/triggers/cron/expressions.py`). + * This file is a hand-rolled, zero-dependency port of that EXACT grammar — not of + * "standard cron". Cron libs (cron-parser, cronstrue) implement a foreign grammar + * (@daily, 6-field, quartz L/#, dow 0-6 wrap …) with proven verdict disagreements. + * + * CONTRACT: `tests/fixtures/cron-grammar-cases.json` — probe-generated against the live + * backend validator, asserted row-for-row by BOTH `tests/unit/test_925_cron_grammar_fixture.py` + * (backend CI env — fires on an APScheduler grammar change) and + * `src/frontend/tests/unit/cronValidation.spec.js` (this mirror). Grammar drift fails CI loudly. + * + * PARITY OUTRANKS TIDINESS. Several upstream behaviours look like bugs and must NOT be + * "fixed" here — each is probe-verified server-ACCEPT and pinned by a fixture row: + * - Name expressions are START-ANCHORED PREFIX matches with no step group + * (APScheduler's Month/Weekday/LastDay regexes carry no `$` anchor), so + * `MON/999`, `jan/0`, `jan-feb/5`, `mon-`, `lastx`, `last/0` all ACCEPT + * (trailing garbage / steps silently dropped). + * - `_dow_to_apscheduler` comma-branch asymmetry: a range INSIDE a comma list stays raw + * numeric (`0-6,1` → `0-6,mon` ACCEPT) while a bare range maps both endpoints + * (`0-6` → `sun-sat` — inverted in APScheduler's mon=0..sun=6 order — REJECT). + * - Falsy-zero range end: APScheduler computes the step span as `(self.last or MAX)` + * (Python truthiness), so an explicit last of 0 falls back to the field max — + * `0-0/2` (minute) ACCEPTs while `5-5/1` REJECTs. Mirrored with JS `||`, NOT `??`, + * intentionally-because-upstream-buggy: `??` would reject every `N-0/step` row on a + * min-0 field that the server accepts (a false warning — the damaging direction). + * + * DOCUMENTED DIVERGENCE (client-stricter, absurd input only): digits are ASCII `[0-9]`. + * Python's `\d` / `int()` also accept Unicode digits (`٥`), `+` signs and underscores; + * the server therefore accepts e.g. `٥ * * * *` that this mirror rejects. Marked + * `divergence: "client-stricter"` in the fixture; never mirrored on purpose. + * + * FAIL-OPEN: `validateCronExpression` is total and never throws. An internal error + * (port bug) returns `{valid: true, error: null}` + console.error — client validation + * is advisory UX; the backend 400 remains the enforcement authority. + */ + +// Unix cron day-of-week (0/7=Sun) → APScheduler named days. +// Mirrors schedule_validation._DOW_NAMES verbatim. +const DOW_NAMES = { 0: 'sun', 1: 'mon', 2: 'tue', 3: 'wed', 4: 'thu', 5: 'fri', 6: 'sat', 7: 'sun' } + +const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] +// APScheduler order: mon=0 .. sun=6 (NOT unix 0=Sun). +const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +// Field order + bounds mirror APScheduler's MIN_VALUES/MAX_VALUES for the five +// fields the backend passes to CronTrigger. +const FIELDS = [ + { name: 'minute', min: 0, max: 59 }, + { name: 'hour', min: 0, max: 23 }, + { name: 'day', min: 1, max: 31, extra: 'day' }, + { name: 'month', min: 1, max: 12, extra: 'month' }, + { name: 'day_of_week', min: 0, max: 6, extra: 'dow' }, +] + +/** The schedule-form quick presets. Exported so "presets never warn" is tested + * against the SHIPPED list, not a mirrored literal (labels/expressions are + * byte-identical to the former hardcoded buttons). */ +export const CRON_PRESETS = [ + { label: 'Daily 9 AM', expression: '0 9 * * *' }, + { label: 'Weekly Mon', expression: '0 9 * * 1' }, + { label: 'Every 6h', expression: '0 */6 * * *' }, + { label: 'Every 30m', expression: '*/30 * * * *' }, +] + +// Verbatim port of schedule_validation._dow_to_apscheduler — BRANCH ORDER MATTERS +// (`/` passthrough before `,` before `-` before single). Do not reorder. +function dowToApscheduler(dow) { + const token = (t) => { + // Python `_DOW_NAMES[int(t)]` with ValueError/KeyError → token unchanged. + // ASCII-digits-only is the documented client-stricter divergence (int() also + // takes unicode digits / '+' / underscores — absurd inputs, not mirrored). + if (!/^[0-9]+$/.test(t)) return t + const name = DOW_NAMES[Number(t)] + return name !== undefined ? name : t + } + if (dow === '*' || dow.includes('/')) return dow + if (dow.includes(',')) return dow.split(',').map(token).join(',') + if (dow.includes('-')) { + // Python split('-', 1): split at the FIRST '-' only. + const i = dow.indexOf('-') + return `${token(dow.slice(0, i))}-${token(dow.slice(i + 1))}` + } + return token(dow) +} + +// Echoed user tokens are bounded so a pasted blob can't balloon the error slot. +function echo(item) { + return item.length > 24 ? `${item.slice(0, 24)}…` : item +} + +function itemError(rawItem, field, reason) { + return `Invalid cron expression: "${echo(rawItem)}" is not valid for ${field.name}${reason ? ` — ${reason}` : ''}` +} + +/** + * Validate one comma-separated item of one field. Mirrors APScheduler's + * compile_expression: compilers tried in order, first REGEX match wins (a match + * whose constructor/validate_range fails is an error, never a fall-through). + * `item` is the post-translation token (dow), `rawItem` the user's original. + * Returns an error string or null. + */ +function checkItem(item, rawItem, field) { + if (item === '') { + return `Invalid cron expression: ${field.name} has an empty list item` + } + + // 1. AllExpression: r'\*(?:/(?P\d+))?$' + let m = /^\*(?:\/([0-9]+))?$/.exec(item) + if (m) { + if (m[1] !== undefined) { + const step = Number(m[1]) + if (step === 0) return itemError(rawItem, field, 'step must be at least 1') + if (step > field.max - field.min) { + return itemError(rawItem, field, `step ${step} is larger than the ${field.min}–${field.max} range`) + } + } + return null + } + + // 2. RangeExpression: r'(?P\d+)(?:-(?P\d+))?(?:/(?P\d+))?$' + m = /^([0-9]+)(?:-([0-9]+))?(?:\/([0-9]+))?$/.exec(item) + if (m) { + const first = Number(m[1]) + let last = m[2] !== undefined ? Number(m[2]) : undefined + const step = m[3] !== undefined ? Number(m[3]) : undefined + // AllExpression.__init__: zero step raises before anything else. + if (step === 0) return itemError(rawItem, field, 'step must be at least 1') + // RangeExpression.__init__: bare value ⇒ last = first (so `8` in dow hits the ≤max check). + if (last === undefined && step === undefined) last = first + if (last !== undefined && first > last) { + return itemError(rawItem, field, 'range first value is greater than last') + } + // validate_range, in APScheduler's order: super() full-span step check first… + if (step !== undefined && step > field.max - field.min) { + return itemError(rawItem, field, `step ${step} is larger than the ${field.min}–${field.max} range`) + } + if (first < field.min) { + return itemError(rawItem, field, `values must be ${field.min}–${field.max}`) + } + if (last !== undefined && last > field.max) { + return itemError(rawItem, field, `values must be ${field.min}–${field.max}`) + } + // …then the actual-span step check. `||` (not `??`) is INTENTIONAL: mirrors + // APScheduler's Python-truthiness `(self.last or MAX) - self.first`, where an + // explicit last of 0 falls back to the field max (probed: `0-0/2` ACCEPT, + // `5-5/1` REJECT). `??` would reject server-valid crons — see header comment. + const span = (last || field.max) - first + if (step !== undefined && step > span) { + return itemError(rawItem, field, `step ${step} is larger than the range of "${echo(item)}"`) + } + return null + } + + // 3. Field-specific compilers (all PREFIX matches — no `$` anchor upstream). + if (field.extra === 'day') { + // LastDayOfMonthExpression: re.compile(r'last', re.IGNORECASE), re.match ⇒ + // start-anchored prefix. `lastx`/`last-`/`last/0` ACCEPT; `xlast` falls through. + // (WeekdayPositionExpression needs an embedded space — unreachable after the + // whitespace field split, so deliberately not ported.) + if (/^last/i.test(item)) return null + } else if (field.extra === 'month' || field.extra === 'dow') { + // Month/WeekdayRangeExpression: r'(?P[a-z]+)(?:-(?P[a-z]+))?' + // (IGNORECASE, no end anchor) — trailing garbage/steps silently dropped. + const names = field.extra === 'month' ? MONTHS : WEEKDAYS + const nm = /^([a-zA-Z]+)(?:-([a-zA-Z]+))?/.exec(item) + if (nm) { + // Regex matched ⇒ this compiler OWNS the item; a bad name is an error, + // never a fall-through (mirrors the propagating ValueError). + const firstIdx = names.indexOf(nm[1].toLowerCase()) + if (firstIdx === -1) { + return itemError(rawItem, field, `"${echo(nm[1])}" is not a recognized name (use ${names[0]}..${names[names.length - 1]})`) + } + if (nm[2] !== undefined) { + const lastIdx = names.indexOf(nm[2].toLowerCase()) + if (lastIdx === -1) { + return itemError(rawItem, field, `"${echo(nm[2])}" is not a recognized name (use ${names[0]}..${names[names.length - 1]})`) + } + if (firstIdx > lastIdx) { + return itemError(rawItem, field, 'range first value is greater than last') + } + } + return null + } + } + + return itemError(rawItem, field, null) +} + +/** + * Validate a cron expression exactly as the backend will. + * Total: coerces null/undefined/non-strings; never throws (fail-open on internal error). + * @returns {{valid: boolean, error: string|null}} + */ +export function validateCronExpression(expr) { + try { + const text = String(expr ?? '').trim() + // ''.split(/\s+/) is [''] — an empty expression is 0 fields, like Python's ''.split(). + const fields = text === '' ? [] : text.split(/\s+/) + if (fields.length !== 5) { + return { + valid: false, + error: + `Invalid cron expression: expected 5 fields (minute hour day month day_of_week), ` + + `got ${fields.length} — e.g. "0 9 * * *"`, + } + } + + for (let f = 0; f < FIELDS.length; f++) { + const field = FIELDS[f] + const raw = fields[f] + // The backend translates the WHOLE dow field before CronTrigger sees it. + const translated = field.extra === 'dow' ? dowToApscheduler(raw) : raw + // The translation never adds/removes commas, so raw/translated items align + // positionally — errors echo what the user actually typed. + const items = translated.split(',') + const rawItems = raw.split(',') + for (let i = 0; i < items.length; i++) { + const err = checkItem(items[i], rawItems[i] !== undefined ? rawItems[i] : items[i], field) + if (err) return { valid: false, error: err } + } + } + return { valid: true, error: null } + } catch (e) { + // Advisory layer: a port bug must not brick the panel or block saves. + // The server 400 remains the authority. + console.error('cronValidation: internal error (failing open)', e) + return { valid: true, error: null } + } +} + +/** + * Pure display gate for the form's inline error (node-testable, not buried in a computed). + * - valid ⇒ never show. + * - editing ⇒ show whenever invalid (incl. empty/whitespace stored cron — a disabled + * Update button must never be unexplained). + * - creating ⇒ show only after first blur (touched) AND for non-empty input — empty + * stays with the native `required` bubble; no mid-typing flash before first blur. + */ +export function shouldShowCronError({ valid, expr, touched, editing }) { + if (valid) return false + if (editing) return true + return Boolean(touched) && String(expr ?? '').trim() !== '' +} + +/** + * id → validity for the schedules list (row warning icons). + * Plain object (NOT a Map) — template bracket access. A null/absent cron on a row + * coerces to invalid without throwing. + */ +export function computeCronValidityMap(schedules) { + const map = {} + for (const s of schedules || []) { + if (!s || s.id === undefined || s.id === null) continue + map[s.id] = validateCronExpression(s.cron_expression).valid + } + return map +} diff --git a/src/frontend/tests/unit/cronValidation.spec.js b/src/frontend/tests/unit/cronValidation.spec.js new file mode 100644 index 000000000..7ee795533 --- /dev/null +++ b/src/frontend/tests/unit/cronValidation.spec.js @@ -0,0 +1,192 @@ +// #925 — the client cron validator must agree with the backend grammar row-for-row. +// +// Contract: tests/fixtures/cron-grammar-cases.json (repo root) — verdicts PROBED +// against the live `services/schedule_validation.py::validate_cron_expression` +// at generation time. The backend twin `tests/unit/test_925_cron_grammar_fixture.py` +// re-proves every row against the real validator in the backend CI env (the drift +// alarm for APScheduler upgrades); THIS spec proves the mirror agrees. Rows marked +// `divergence: "client-stricter"` are server-valid inputs the client deliberately +// rejects (Python \d/int() accept Unicode digits etc.; the client is ASCII-only). +import { describe, it, expect, vi, afterEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { + validateCronExpression, + shouldShowCronError, + computeCronValidityMap, + CRON_PRESETS, +} from '@/utils/cronValidation' + +const FIXTURE_URL = new URL('../../../../tests/fixtures/cron-grammar-cases.json', import.meta.url) +const rows = JSON.parse(readFileSync(fileURLToPath(FIXTURE_URL), 'utf-8')) + +describe('cron grammar fixture parity (client mirror)', () => { + it('loads a non-trivial fixture', () => { + expect(Array.isArray(rows)).toBe(true) + expect(rows.length).toBeGreaterThanOrEqual(100) + expect(rows.filter((r) => r.valid).length).toBeGreaterThanOrEqual(40) + expect(rows.filter((r) => !r.valid).length).toBeGreaterThanOrEqual(40) + }) + + for (const row of rows) { + const label = row.expr === null ? '' : JSON.stringify(row.expr) + if (row.divergence === 'client-stricter') { + it(`${label} — documented divergence: server accepts, client rejects (${row.note})`, () => { + // The fixture records the SERVER verdict (true); the client is + // deliberately stricter on this absurd-input class. + expect(row.valid).toBe(true) + expect(validateCronExpression(row.expr).valid).toBe(false) + }) + } else { + it(`${label} — ${row.valid ? 'ACCEPT' : 'REJECT'} (${row.note})`, () => { + const verdict = validateCronExpression(row.expr) + expect(verdict.valid).toBe(row.valid) + if (row.valid) { + expect(verdict.error).toBeNull() + } else { + expect(typeof verdict.error).toBe('string') + expect(verdict.error.length).toBeGreaterThan(0) + } + }) + } + } + + it('quirk rows are present and pinned (guard against fixture pruning)', () => { + const byExpr = Object.fromEntries(rows.map((r) => [r.expr, r.valid])) + expect(byExpr['0-0/2 * * * *']).toBe(true) // falsy-zero last → span = max + expect(byExpr['5-5/1 * * * *']).toBe(false) // truthy last → span 0 + expect(byExpr['0 9 * * MON/999']).toBe(true) // name prefix drops the step + expect(byExpr['0 9 * * 0-6,1']).toBe(true) // comma branch keeps ranges raw… + expect(byExpr['0 9 * * 0-6']).toBe(false) // …bare range → sun-sat inverted + expect(byExpr['0 9 lastx * *']).toBe(true) // 'last' is a prefix match + }) +}) + +describe('CRON_PRESETS', () => { + it('ships exactly the four historical presets, byte-identical', () => { + expect(CRON_PRESETS).toEqual([ + { label: 'Daily 9 AM', expression: '0 9 * * *' }, + { label: 'Weekly Mon', expression: '0 9 * * 1' }, + { label: 'Every 6h', expression: '0 */6 * * *' }, + { label: 'Every 30m', expression: '*/30 * * * *' }, + ]) + }) + + it('every preset is valid (presets never warn)', () => { + for (const p of CRON_PRESETS) { + expect(validateCronExpression(p.expression).valid).toBe(true) + } + }) + + it('every preset is fixture-proven, not just mirror-proven', () => { + const byExpr = Object.fromEntries(rows.map((r) => [r.expr, r.valid])) + for (const p of CRON_PRESETS) { + expect(byExpr[p.expression]).toBe(true) + } + }) +}) + +describe('error message shape (pinned loosely — substrings, not bytes)', () => { + it('field-count error names "5 fields" and shows an example', () => { + const { error } = validateCronExpression('@daily') + expect(error).toContain('5 fields') + expect(error).toContain('0 9 * * *') + expect(error).toContain('got 1') + }) + + it('empty input reports 0 fields', () => { + expect(validateCronExpression('').error).toContain('got 0') + expect(validateCronExpression(' ').error).toContain('got 0') + }) + + it('item errors name the failing field', () => { + expect(validateCronExpression('99 0 * * *').error).toContain('minute') + expect(validateCronExpression('0 24 * * *').error).toContain('hour') + expect(validateCronExpression('0 9 0 * *').error).toContain('day') + expect(validateCronExpression('0 9 * 13 *').error).toContain('month') + expect(validateCronExpression('0 9 * * 8').error).toContain('day_of_week') + }) + + it('echoed user tokens are truncated (no ballooning error slot)', () => { + const blob = 'x'.repeat(300) + const { valid, error } = validateCronExpression(`${blob} * * * *`) + expect(valid).toBe(false) + expect(error.length).toBeLessThan(160) + }) +}) + +describe('shouldShowCronError (display gate truth table)', () => { + const cases = [ + // [valid, expr, touched, editing] -> expected + [{ valid: true, expr: '0 9 * * *', touched: true, editing: false }, false, 'valid never shows'], + [{ valid: true, expr: '', touched: true, editing: true }, false, 'valid never shows even editing'], + // Create flow: no flash before first blur… + [{ valid: false, expr: 'garbage', touched: false, editing: false }, false, 'creating, untouched → no flash'], + // …live after first blur, non-empty only. + [{ valid: false, expr: 'garbage', touched: true, editing: false }, true, 'creating, touched, non-empty invalid → show'], + [{ valid: false, expr: '', touched: true, editing: false }, false, 'creating, empty → native required owns it'], + [{ valid: false, expr: ' ', touched: true, editing: false }, false, 'creating, whitespace-only → treated as empty'], + // Edit flow: unconditional when invalid (E1 — a dead Update button must be explained). + [{ valid: false, expr: 'garbage', touched: false, editing: true }, true, 'editing, invalid, untouched → show'], + [{ valid: false, expr: '', touched: false, editing: true }, true, 'editing, EMPTY stored cron, untouched → show'], + [{ valid: false, expr: null, touched: false, editing: true }, true, 'editing, null stored cron → show'], + [{ valid: false, expr: null, touched: false, editing: false }, false, 'creating, null expr, untouched → no flash'], + ] + for (const [input, expected, name] of cases) { + it(name, () => { + expect(shouldShowCronError(input)).toBe(expected) + }) + } +}) + +describe('computeCronValidityMap', () => { + it('returns a plain object keyed by id (template bracket access)', () => { + const map = computeCronValidityMap([ + { id: 'a', cron_expression: '0 9 * * *' }, + { id: 'b', cron_expression: 'not a cron' }, + ]) + expect(map.constructor).toBe(Object) + expect(map['a']).toBe(true) + expect(map['b']).toBe(false) + }) + + it('a null cron on a row is invalid, never a throw', () => { + const map = computeCronValidityMap([{ id: 'x', cron_expression: null }]) + expect(map['x']).toBe(false) + }) + + it('tolerates empty/absent lists and rows without ids', () => { + expect(computeCronValidityMap([])).toEqual({}) + expect(computeCronValidityMap(null)).toEqual({}) + expect(computeCronValidityMap(undefined)).toEqual({}) + expect(computeCronValidityMap([null, { cron_expression: '* * * * *' }])).toEqual({}) + }) +}) + +describe('total / fail-open contract', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('null and undefined are REJECTED (0 fields), not a crash', () => { + expect(validateCronExpression(null).valid).toBe(false) + expect(validateCronExpression(undefined).valid).toBe(false) + }) + + it('non-string input is coerced, not thrown on', () => { + expect(validateCronExpression(5).valid).toBe(false) // "5" → 1 field + expect(validateCronExpression(['0 9 * * *']).valid).toBe(true) // String() → "0 9 * * *" + }) + + it('an internal crash fails OPEN {valid:true} with a console.error (server 400 is the backstop)', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const evil = { + toString() { + throw new Error('boom — simulated port bug') + }, + } + const verdict = validateCronExpression(evil) + expect(verdict).toEqual({ valid: true, error: null }) + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/tests/fixtures/cron-grammar-cases.json b/tests/fixtures/cron-grammar-cases.json new file mode 100644 index 000000000..a052c0d55 --- /dev/null +++ b/tests/fixtures/cron-grammar-cases.json @@ -0,0 +1,558 @@ +[ + { + "expr": "0 9 * * *", + "valid": true, + "note": "preset: Daily 9 AM" + }, + { + "expr": "0 9 * * 1", + "valid": true, + "note": "preset: Weekly Mon" + }, + { + "expr": "0 */6 * * *", + "valid": true, + "note": "preset: Every 6h" + }, + { + "expr": "*/30 * * * *", + "valid": true, + "note": "preset: Every 30m" + }, + { + "expr": "* * * * *", + "valid": true, + "note": "all-wildcards" + }, + { + "expr": "00 09 * * *", + "valid": true, + "note": "leading zeros" + }, + { + "expr": "0 9 * * *", + "valid": true, + "note": "multi-space separators" + }, + { + "expr": "0\t9\t*\t*\t*", + "valid": true, + "note": "tab separators" + }, + { + "expr": " 0 9 * * * ", + "valid": true, + "note": "leading/trailing whitespace stripped" + }, + { + "expr": "0 9 * * MON", + "valid": true, + "note": "dow uppercase name" + }, + { + "expr": "0 9 * * mon", + "valid": true, + "note": "dow lowercase name" + }, + { + "expr": "0 9 * * SUN", + "valid": true, + "note": "dow SUN" + }, + { + "expr": "0 9 * * MON-FRI", + "valid": true, + "note": "dow name range" + }, + { + "expr": "0 9 * * TUE,THU", + "valid": true, + "note": "dow name comma list" + }, + { + "expr": "0 9 * * mon,wed,5", + "valid": true, + "note": "dow mixed names + numeric (5→fri)" + }, + { + "expr": "0 9 * * tue-thu,sat", + "valid": true, + "note": "dow range inside comma list (names)" + }, + { + "expr": "0 9 * * SAT-SUN", + "valid": true, + "note": "sat(5) ≤ sun(6) in APScheduler order" + }, + { + "expr": "0 9 * * 1-5", + "valid": true, + "note": "dow 1-5 → mon-fri" + }, + { + "expr": "0 9 * * 1-7", + "valid": true, + "note": "dow 1-7 → mon-sun" + }, + { + "expr": "0 9 * * 6-7", + "valid": true, + "note": "dow 6-7 → sat-sun" + }, + { + "expr": "0 9 * * 7", + "valid": true, + "note": "dow 7 → sun (unix Sunday alias)" + }, + { + "expr": "0 9 * * 0", + "valid": true, + "note": "dow 0 → sun" + }, + { + "expr": "0 9 * * */2", + "valid": true, + "note": "dow */2 — '/' passthrough, step ≤ span" + }, + { + "expr": "0 9 * * 0/2", + "valid": true, + "note": "dow 0/2 — '/' passthrough raw numeric" + }, + { + "expr": "0 9 * * 1,3,5", + "valid": true, + "note": "dow numeric comma list → mon,wed,fri" + }, + { + "expr": "0 9 * * mon-mon", + "valid": true, + "note": "dow degenerate name range" + }, + { + "expr": "0 9 * * 3-3", + "valid": true, + "note": "dow 3-3 → wed-wed" + }, + { + "expr": "0 9 * * 7-7", + "valid": true, + "note": "dow 7-7 → sun-sun" + }, + { + "expr": "5/2 * * * *", + "valid": true, + "note": "start/step with implicit max" + }, + { + "expr": "1-10/2 * * * *", + "valid": true, + "note": "range with step" + }, + { + "expr": "*/59 * * * *", + "valid": true, + "note": "wildcard step at field span" + }, + { + "expr": "0-59/59 * * * *", + "valid": true, + "note": "explicit full range, step = span" + }, + { + "expr": "1-1 * * * *", + "valid": true, + "note": "degenerate numeric range" + }, + { + "expr": "1-5,10 * * * *", + "valid": true, + "note": "range + value in comma list" + }, + { + "expr": "0-5/2 * * * *", + "valid": true, + "note": "small range with step" + }, + { + "expr": "* * * jan *", + "valid": true, + "note": "month name" + }, + { + "expr": "* * * JAN-MAR *", + "valid": true, + "note": "month name range uppercase" + }, + { + "expr": "0 9 last * *", + "valid": true, + "note": "day 'last'" + }, + { + "expr": "0 9 LAST * *", + "valid": true, + "note": "day 'LAST' case-insensitive" + }, + { + "expr": "0 9 31 2 *", + "valid": true, + "note": "Feb 31: field-valid (semantics not checked)" + }, + { + "expr": "0 9 lastx * *", + "valid": true, + "note": "QUIRK: 'last' is a start-anchored prefix match" + }, + { + "expr": "0 9 last- * *", + "valid": true, + "note": "QUIRK: trailing garbage after 'last' ignored" + }, + { + "expr": "0 9 last/0 * *", + "valid": true, + "note": "QUIRK: step after 'last' never parsed" + }, + { + "expr": "0 9 * * mon-", + "valid": true, + "note": "QUIRK: name-range regex prefix — dangling '-' ignored" + }, + { + "expr": "0 9 * * mon-fri/2", + "valid": true, + "note": "QUIRK: step after name range silently dropped" + }, + { + "expr": "0 9 * * mon-fri/0", + "valid": true, + "note": "QUIRK: even a zero step is dropped after names" + }, + { + "expr": "0 9 * * MON/2", + "valid": true, + "note": "QUIRK: step after single name dropped" + }, + { + "expr": "0 9 * * MON/999", + "valid": true, + "note": "QUIRK: absurd step after name dropped" + }, + { + "expr": "* * * jan/0 *", + "valid": true, + "note": "QUIRK: month name prefix — step dropped" + }, + { + "expr": "* * * jan-feb/5 *", + "valid": true, + "note": "QUIRK: month name range prefix — step dropped" + }, + { + "expr": "0 9 * * 0-6,1", + "valid": true, + "note": "QUIRK: range inside comma list stays raw numeric (vs bare 0-6)" + }, + { + "expr": "0 9 * * 0-6/1", + "valid": true, + "note": "QUIRK: '/' passthrough keeps 0-6 raw numeric" + }, + { + "expr": "0-0/2 * * * *", + "valid": true, + "note": "QUIRK: falsy-zero last → span falls back to max (minute)" + }, + { + "expr": "0-0/59 * * * *", + "valid": true, + "note": "QUIRK: falsy-zero last, step = full span" + }, + { + "expr": "* 0-0/23 * * *", + "valid": true, + "note": "QUIRK: falsy-zero last (hour)" + }, + { + "expr": "0 9 * * 0-0/2", + "valid": true, + "note": "QUIRK: falsy-zero last (dow, raw passthrough)" + }, + { + "expr": "", + "valid": false, + "note": "empty" + }, + { + "expr": " ", + "valid": false, + "note": "whitespace only" + }, + { + "expr": "*/30", + "valid": false, + "note": "1 field" + }, + { + "expr": "@daily", + "valid": false, + "note": "macro rejected by 5-field split" + }, + { + "expr": "@hourly", + "valid": false, + "note": "macro rejected by 5-field split" + }, + { + "expr": "0 9 * * 1 2026", + "valid": false, + "note": "6 fields" + }, + { + "expr": "* * 1st mon * *", + "valid": false, + "note": "6 fields (quartz-ish day spec splits)" + }, + { + "expr": "99 99 * * *", + "valid": false, + "note": "minute+hour out of range" + }, + { + "expr": "60 * * * *", + "valid": false, + "note": "minute 60 > 59" + }, + { + "expr": "* 24 * * *", + "valid": false, + "note": "hour 24 > 23" + }, + { + "expr": "* * 0 * *", + "valid": false, + "note": "day 0 < 1" + }, + { + "expr": "* * 32 * *", + "valid": false, + "note": "day 32 > 31" + }, + { + "expr": "* * * 0 *", + "valid": false, + "note": "month 0 < 1" + }, + { + "expr": "* * * 13 *", + "valid": false, + "note": "month 13 > 12" + }, + { + "expr": "0 9 * * 8", + "valid": false, + "note": "dow 8 unmappable → 8 > 6" + }, + { + "expr": "0 9 * * 29", + "valid": false, + "note": "dow 29 unmappable → out of range" + }, + { + "expr": "0 9 * * 0-6", + "valid": false, + "note": "QUIRK: bare 0-6 → sun-sat inverted (vs 0-6,1 accept)" + }, + { + "expr": "0 9 * * sun-sat", + "valid": false, + "note": "sun(6) > sat(5) inverted" + }, + { + "expr": "0 9 * * SUN-SAT,1", + "valid": false, + "note": "inverted name range inside comma list still checked" + }, + { + "expr": "0 9 * * fri-mon", + "valid": false, + "note": "fri(4) > mon(0) inverted" + }, + { + "expr": "0 9 * * 5-1", + "valid": false, + "note": "5-1 → fri-mon inverted" + }, + { + "expr": "* * * mar-jan *", + "valid": false, + "note": "month range inverted" + }, + { + "expr": "* * * dec-feb *", + "valid": false, + "note": "month range inverted (no wrap)" + }, + { + "expr": "* * * 11-2 *", + "valid": false, + "note": "numeric month range inverted (no wrap)" + }, + { + "expr": "0 9 * * 7/2", + "valid": false, + "note": "dow 7/2 raw passthrough — span 6-7 < step" + }, + { + "expr": "0 9 * * 6/2", + "valid": false, + "note": "dow 6/2 — span 0 < 2" + }, + { + "expr": "0 9 * * 5/2", + "valid": false, + "note": "dow 5/2 — span 1 < 2" + }, + { + "expr": "*/60 * * * *", + "valid": false, + "note": "wildcard step > field span" + }, + { + "expr": "*/70 * * * *", + "valid": false, + "note": "wildcard step > field span" + }, + { + "expr": "59/2 * * * *", + "valid": false, + "note": "start/step span 0 < 2" + }, + { + "expr": "58/2 * * * *", + "valid": false, + "note": "start/step span 1 < 2" + }, + { + "expr": "1-1/1 * * * *", + "valid": false, + "note": "truthy last ⇒ span 0 < 1" + }, + { + "expr": "0-59/60 * * * *", + "valid": false, + "note": "step > full range" + }, + { + "expr": "5-5/1 * * * *", + "valid": false, + "note": "truthy last ⇒ span 0 (contrast 0-0/2)" + }, + { + "expr": "0 9 31/2 * *", + "valid": false, + "note": "day 31/2 — span 0 < 2" + }, + { + "expr": "0 9 1-1/5 * *", + "valid": false, + "note": "day truthy last ⇒ span 0 < 5" + }, + { + "expr": "* * * 1-1/5 *", + "valid": false, + "note": "month truthy last ⇒ span 0 < 5" + }, + { + "expr": "*/0 * * * *", + "valid": false, + "note": "zero step on wildcard" + }, + { + "expr": "5/0 * * * *", + "valid": false, + "note": "zero step on value" + }, + { + "expr": "1-10/0 * * * *", + "valid": false, + "note": "zero step on range" + }, + { + "expr": "0 9 * * L", + "valid": false, + "note": "quartz L" + }, + { + "expr": "0 9 * * 5#3", + "valid": false, + "note": "quartz nth-weekday" + }, + { + "expr": "0 9 * * 5L", + "valid": false, + "note": "quartz last-weekday" + }, + { + "expr": "0 9 * * ?", + "valid": false, + "note": "quartz ?" + }, + { + "expr": "0 9 * * monday", + "valid": false, + "note": "full day names not in 3-letter list" + }, + { + "expr": "0 9 xlast * *", + "valid": false, + "note": "'last' must be a prefix, not a suffix" + }, + { + "expr": "-5 * * * *", + "valid": false, + "note": "leading dash" + }, + { + "expr": "5- * * * *", + "valid": false, + "note": "trailing dash (numeric, end-anchored regex)" + }, + { + "expr": "1--5 * * * *", + "valid": false, + "note": "double dash" + }, + { + "expr": "0 9 * * -mon", + "valid": false, + "note": "leading dash before name" + }, + { + "expr": "* * * -jan *", + "valid": false, + "note": "leading dash before month name" + }, + { + "expr": "1,2,,3 * * * *", + "valid": false, + "note": "empty comma-list item" + }, + { + "expr": "a b c d e", + "valid": false, + "note": "5 unrecognized fields" + }, + { + "expr": null, + "valid": false, + "note": "null/None input → 0 fields" + }, + { + "expr": "٥ * * * *", + "valid": true, + "note": "unicode digit — Python \\d/int() accept; client is ASCII-only", + "divergence": "client-stricter" + } +] diff --git a/tests/unit/test_925_cron_grammar_fixture.py b/tests/unit/test_925_cron_grammar_fixture.py new file mode 100644 index 000000000..5984d8d7c --- /dev/null +++ b/tests/unit/test_925_cron_grammar_fixture.py @@ -0,0 +1,123 @@ +"""#925 — the client-side cron validator must mirror the backend grammar exactly. + +The schedule form pre-validates cron expressions in the browser +(``src/frontend/src/utils/cronValidation.js``), a hand-rolled mirror of THIS +backend's acceptance grammar: ``services/schedule_validation.py``'s strict +5-field split + ``_dow_to_apscheduler`` translation + APScheduler 3.11's +``CronTrigger`` field expressions. The contract between the two is the shared +fixture ``tests/fixtures/cron-grammar-cases.json`` — verdicts PROBED against the +live validator at generation time, never hand-typed — asserted row-for-row by +both this pytest (against the real ``validate_cron_expression``) and the vitest +spec ``src/frontend/tests/unit/cronValidation.spec.js`` (against the mirror). + +This test is the DRIFT ALARM: it runs in the backend CI environment with the +pinned APScheduler, so an upgrade that changes the cron grammar fails HERE +loudly instead of silently desynchronizing the client mirror (the damaging +direction being client-invalid/server-valid false warnings in the schedules UI). + +If this test goes red after an APScheduler (or validator) change: + 1. Re-probe every fixture row against the new grammar and rewrite the verdicts + (never hand-edit a verdict). From the repo root:: + + python3 - <<'EOF' + import importlib.util, json, pathlib + spec = importlib.util.spec_from_file_location( + "sv", "src/backend/services/schedule_validation.py") + sv = importlib.util.module_from_spec(spec); spec.loader.exec_module(sv) + p = pathlib.Path("tests/fixtures/cron-grammar-cases.json") + rows = json.loads(p.read_text()) + for r in rows: + try: + sv.validate_cron_expression(r["expr"], "UTC"); r["valid"] = True + except sv.ScheduleValidationError: + r["valid"] = False + p.write_text(json.dumps(rows, indent=2, ensure_ascii=False) + "\n") + EOF + + 2. Port the grammar change into ``cronValidation.js`` and re-run the vitest + spec until it agrees with the regenerated fixture. + +Quirk rows (prefix-matched names ``MON/999``/``jan/0``/``lastx``, the +comma-translation asymmetry ``0-6,1`` vs bare ``0-6``, the falsy-zero +``0-0/2``) are pinned deliberately — parity outranks tidiness; do not "fix" +them on either side. Rows carrying ``divergence: "client-stricter"`` record the +SERVER verdict here; the vitest spec asserts the client's deliberate stricter +verdict for those (documented: Python ``\\d``/``int()`` accept Unicode digits, +the client is ASCII-only). + +Backend RUNTIME is untouched by #925 — this file is test-tree only. +""" + +import json +from pathlib import Path + +import pytest + +from services.schedule_validation import ( + ScheduleValidationError, + validate_cron_expression, +) + +_FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "cron-grammar-cases.json" + + +def _load_rows(): + rows = json.loads(_FIXTURE.read_text(encoding="utf-8")) + assert isinstance(rows, list) and rows, "fixture must be a non-empty array" + return rows + + +_ROWS = _load_rows() + + +def _row_id(row): + expr = row["expr"] + return "" if expr is None else repr(expr) + + +@pytest.mark.parametrize("row", _ROWS, ids=[_row_id(r) for r in _ROWS]) +def test_fixture_row_matches_live_validator(row): + """Every fixture verdict re-proven against the REAL backend validator.""" + expr = row["expr"] + try: + validate_cron_expression(expr, "UTC") + actual = True + except ScheduleValidationError: + actual = False + assert actual == row["valid"], ( + f"Grammar drift: {row['expr']!r} ({row['note']}) — fixture says " + f"valid={row['valid']}, live validate_cron_expression says {actual}. " + f"Re-probe the fixture and update the client mirror (see module docstring)." + ) + + +def test_fixture_covers_both_verdicts_and_the_quirks(): + """Structural floor: the fixture must keep exercising both verdicts and the + named quirk rows whose absence would let the client mirror drift silently.""" + by_expr = {r["expr"]: r["valid"] for r in _ROWS} + # Both verdict classes present in force. + assert sum(1 for r in _ROWS if r["valid"]) >= 40 + assert sum(1 for r in _ROWS if not r["valid"]) >= 40 + # The load-bearing quirk rows (probed upstream behaviours, not typos). + assert by_expr["0-0/2 * * * *"] is True # falsy-zero last → span = max + assert by_expr["5-5/1 * * * *"] is False # truthy last → span 0 + assert by_expr["0 9 * * MON/999"] is True # name prefix match drops the step + assert by_expr["0 9 * * 0-6,1"] is True # comma branch keeps ranges raw… + assert by_expr["0 9 * * 0-6"] is False # …bare range maps to sun-sat (inverted) + assert by_expr["0 9 lastx * *"] is True # 'last' is a prefix match + # The four shipped presets stay valid. + for preset in ("0 9 * * *", "0 9 * * 1", "0 */6 * * *", "*/30 * * * *"): + assert by_expr[preset] is True, f"preset {preset!r} must stay fixture-valid" + + +def test_divergence_rows_are_server_valid_client_stricter_only(): + """A divergence row records the SERVER verdict; the only sanctioned direction + is client-stricter (server-valid input the client rejects). A server-INVALID + divergence row would mean the client fails open on something the server + rejects — that is just a mirror bug, not a documented divergence.""" + for row in _ROWS: + if row.get("divergence"): + assert row["divergence"] == "client-stricter" + assert row["valid"] is True, ( + f"divergence row {row['expr']!r} must be server-valid" + ) From 5160bd80251336012345bf591b0c77c2dfd95cbd Mon Sep 17 00:00:00 2001 From: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:10:37 +0100 Subject: [PATCH 3/4] feat(frontend): inline cron error + submit gating + stored-row warning icon in SchedulesPanel (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Form: the format-hint line becomes the reserved error slot (min-h-8 — hint and every error shape measure ≤2 lines at the modal's real 400px width, verified in a browser, so the modal never jumps; error messages refined to a single echoed token for exactly this reason). Errors gate through the pure shouldShowCronError: creating shows only after first blur and for non-empty input (native `required` keeps the empty case); editing shows unconditionally when invalid so a disabled Update button is never unexplained. Submit disabled ONLY when non-empty AND invalid. cronTouched resets in closeForm() AND the initialMessage watcher (the one open-create path that bypasses closeForm). Presets: v-for over the exported CRON_PRESETS — labels/expressions byte-identical to the four hardcoded buttons they replace. List: warning triangle (status-warning tokens, #1472/#1796 idiom) INSIDE the cron chip span so flex-wrap can't detach it; tooltip + aria-label exactly "Invalid cron expression"; v-if on === false so a map miss can never false-warn. Raw-color ratchet: SchedulesPanel raw_nongray 21→21, raw_gray 315→300 (preset v-for collapse), hardcoded 0→0 — counts only shrank. vite build green; check:tokens green; full vitest suite 422 passed. Co-Authored-By: Claude Fable 5 --- .../src/components/SchedulesPanel.vue | 78 +++++++++++++++++-- src/frontend/src/utils/cronValidation.js | 9 ++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/components/SchedulesPanel.vue b/src/frontend/src/components/SchedulesPanel.vue index 0287248dc..9bad359fd 100644 --- a/src/frontend/src/components/SchedulesPanel.vue +++ b/src/frontend/src/components/SchedulesPanel.vue @@ -74,16 +74,29 @@ type="text" required placeholder="0 9 * * *" + @blur="cronTouched = true" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-action-primary-500" /> -

- Format: minute hour day month day_of_week (e.g., "0 9 * * *" for 9 AM daily) + +

+ +

- - - - + +
@@ -248,9 +261,11 @@ > Cancel +