Display valid multi-day automation schedules (#26850)

Co-authored-by: ggbdpq <ggbdpq@gmail.com>
This commit is contained in:
Neil
2026-10-09 23:41:33 -07:00
committed by GitHub
co-authored by ggbdpq
parent 1a6d570412
commit c857fcc578
6 changed files with 90 additions and 17 deletions
@@ -129,6 +129,17 @@ describe('AutomationSchedulePicker', () => {
}
)
it.each(['en', 'zh', 'ja', 'ko', 'es'])(
'shows a valid multi-day weekly rule without an invalid label in %s (#24985)',
async (locale) => {
await i18n.changeLanguage(locale)
const rrule = 'FREQ=WEEKLY;BYDAY=TU,TH,SA;BYHOUR=8;BYMINUTE=0'
expect(formatUiAutomationSchedule(rrule)).toBe(rrule)
expect(formatUiAutomationSchedule('*/30 9-17 * * MON-FRI')).not.toBe('Invalid schedule')
}
)
it('localizes the valid-custom-cron status without matching English copy (#14404)', async () => {
await i18n.changeLanguage('zh')
@@ -21,6 +21,9 @@ export function formatUiAutomationScheduleDescriptor(
)
}
if (descriptor.kind === 'custom') {
if (descriptor.expression) {
return descriptor.expression
}
return translate(
'auto.components.automations.automation.schedule.label.ba20c92073',
'Custom schedule'
+44
View File
@@ -109,6 +109,50 @@ describe('automation schedules', () => {
expect(formatAutomationSchedule('FREQ=YEARLY')).toBe('Invalid schedule')
})
it.each([
['TU,TH,SA', new Date(2026, 9, 6, 8)],
['MO,WE,FR,SU', new Date(2026, 9, 7, 8)],
['MO,TU,WE,TH,FR,SA,SU', new Date(2026, 9, 6, 8)]
])(
'labels a runnable multi-day weekly rule %s without calling it invalid (#24985)',
(days, next) => {
const rrule = `FREQ=WEEKLY;BYDAY=${days};BYHOUR=8;BYMINUTE=0`
const start = new Date(2026, 9, 5, 9).getTime()
expect(isValidAutomationSchedule(rrule)).toBe(true)
expect(nextAutomationOccurrenceAfter(rrule, start, start)).toBe(next.getTime())
expect(formatAutomationSchedule(` ${rrule} `)).toBe(rrule)
expect(describeAutomationSchedule(` ${rrule} `)).toEqual({
kind: 'custom',
expression: rrule
})
}
)
it.each(['FREQ=WEEKLY;BYDAY=TU,NO;BYHOUR=8;BYMINUTE=0', 'FREQ=YEARLY', '0 0 31 2 *'])(
'keeps the invalid label for schedules the scheduler rejects: %s',
(schedule) => {
expect(isValidAutomationSchedule(schedule)).toBe(false)
expect(formatAutomationSchedule(schedule)).toBe('Invalid schedule')
expect(describeAutomationSchedule(schedule)).toEqual({ kind: 'invalid' })
}
)
it.each([
['\u001b[2J', '\\x1b[2J'],
['\u001b]52;c;ZXZpbA==\u0007', '\\x1b]52;c;ZXZpbA==\\x07'],
['\u009b2J', '\\x9b2J'],
['\t\r\n', '\\x09\\x0d\\x0a']
])('renders accepted control bytes as printable schedule text', (control, escaped) => {
const prefix = 'FREQ=WEEKLY;BYDAY=TU,TH;BYHOUR=8;BYMINUTE=0;X='
const expression = `${prefix}${control};Y=keep`
const expected = `${prefix}${escaped};Y=keep`
expect(isValidAutomationSchedule(expression)).toBe(true)
expect(formatAutomationSchedule(expression)).toBe(expected)
expect(describeAutomationSchedule(expression)).toEqual({ kind: 'custom', expression: expected })
})
it('formats hourly schedules using the stored minute', () => {
expect(formatAutomationSchedule('FREQ=HOURLY;BYMINUTE=5')).toBe('Hourly at :05')
})
+16 -5
View File
@@ -1,10 +1,11 @@
import {
parseAutomationRrule,
parseCronExpression,
parseSchedule,
tryParseAutomationRrule,
type ParsedCron
} from './automation-schedule-parsing'
import { cronHasPossibleOccurrence } from './automation-cron-occurrence'
import { escapeTerminalControlCharacters } from './terminal-control-character-escape'
export type AutomationCronScheduleClassification =
| { kind: 'hourly'; minute: number; label: string }
@@ -20,7 +21,8 @@ export type AutomationScheduleDescriptor =
| { kind: 'daily'; hour: number; minute: number }
| { kind: 'weekdays'; hour: number; minute: number }
| { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number }
| { kind: 'custom' }
// `expression` carries a scheduler-valid RRULE no preset sentence can name (#24985).
| { kind: 'custom'; expression?: string }
| { kind: 'invalid' }
// Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale.
@@ -74,7 +76,9 @@ function setContainsRange(values: Set<number>, min: number, max: number): boolea
return true
}
function formatParsedRruleSchedule(schedule: ReturnType<typeof parseAutomationRrule>): string {
function formatParsedRruleSchedule(
schedule: NonNullable<ReturnType<typeof tryParseAutomationRrule>>
): string {
if (schedule.preset === 'hourly') {
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
}
@@ -151,7 +155,11 @@ export function formatAutomationSchedule(scheduleExpression: string): string {
if (schedule.kind === 'cron') {
return classifyParsedCronSchedule(schedule).label
}
return formatParsedRruleSchedule(parseAutomationRrule(trimmed))
const rrule = tryParseAutomationRrule(trimmed)
if (rrule === null) {
return escapeTerminalControlCharacters(trimmed, true)
}
return formatParsedRruleSchedule(rrule)
} catch {
return 'Invalid schedule'
}
@@ -177,7 +185,10 @@ export function describeAutomationSchedule(
if (schedule.kind === 'cron') {
return toScheduleDescriptor(classifyParsedCronSchedule(schedule))
}
const rrule = parseAutomationRrule(trimmed)
const rrule = tryParseAutomationRrule(trimmed)
if (rrule === null) {
return { kind: 'custom', expression: escapeTerminalControlCharacters(trimmed, true) }
}
if (rrule.preset === 'hourly') {
return { kind: 'hourly', minute: rrule.minute }
}
+1 -12
View File
@@ -1,4 +1,5 @@
import { ORCHESTRATION_LEGACY_RUN_ID } from './orchestration-rpc-contract'
import { escapeTerminalControlCharacters } from './terminal-control-character-escape'
export type OrchestrationMessageSummary = {
id: string
@@ -132,18 +133,6 @@ function formatMessagePriorityTag(message: OrchestrationMessageSummary): string
return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : ''
}
function escapeTerminalControlCharacters(value: string): string {
return [...value]
.map((character) => {
const code = character.charCodeAt(0)
if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) {
return character
}
return `\\x${code.toString(16).padStart(2, '0')}`
})
.join('')
}
function formatQuotedMessageField(label: string, value?: string): string {
return `[${label}]\n${escapeTerminalControlCharacters(value ?? '')
.split('\n')
@@ -0,0 +1,15 @@
export function escapeTerminalControlCharacters(value: string, escapeLineBreaks = false): string {
return [...value]
.map((character) => {
const code = character.charCodeAt(0)
if (
(!escapeLineBreaks && character === '\n') ||
(code >= 0x20 && code < 0x7f) ||
code > 0x9f
) {
return character
}
return `\\x${code.toString(16).padStart(2, '0')}`
})
.join('')
}