Skip to content

Commit

Permalink
Validate reasonability of startupGracePeriod (#827)
Browse files Browse the repository at this point in the history
* Add validations for the reasonability of startupGracePeriod

* Add special handler for default

* Fix some schema tests

* Refactor assertions
  • Loading branch information
kachick authored Jun 3, 2024
1 parent 983b4bd commit 77ca20d
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 73 deletions.
16 changes: 14 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31042,6 +31042,7 @@ var MyDurationLike = z2.object({
nanoseconds: z2.number().optional()
}).strict().readonly();
var Durationable = z2.union([z2.string().duration(), MyDurationLike]).transform((item) => getDuration(item));
var defaultGrace = mr.Duration.from({ seconds: 10 });
function isDurationLike(my) {
for (const [_2, value] of Object.entries(my)) {
if (value === void 0) {
Expand All @@ -31067,9 +31068,9 @@ var WaitFilterCondition = FilterCondition.extend(
// - Intentionally avoided to use enum for now. Only GitHub knows whole eventNames and the adding plans
// - Intentionally omitted in skip-list, let me know if you have the use-case
eventName: z2.string().min(1).optional(),
// Do not raise validation errors for the reasonability of value range.
// Do not raise validation errors for the reasonability of max value.
// Even in equal_intervals mode, we can't enforce the possibility of the whole running time
startupGracePeriod: Durationable.default(mr.Duration.from({ seconds: 10 }))
startupGracePeriod: Durationable.default(defaultGrace)
}
).readonly();
var WaitList = z2.array(WaitFilterCondition).readonly();
Expand All @@ -31088,6 +31089,17 @@ var Options = z2.object({
}).readonly().refine(
({ waitList, skipList }) => !(waitList.length > 0 && skipList.length > 0),
{ message: "Do not specify both wait-list and skip-list", path: ["waitList", "skipList"] }
).refine(
({ waitSecondsBeforeFirstPolling, waitList }) => waitList.every(
(item) => !(mr.Duration.compare(
{ seconds: waitSecondsBeforeFirstPolling },
item.startupGracePeriod
) > 0 && mr.Duration.compare(item.startupGracePeriod, defaultGrace) !== 0)
),
{
message: "A shorter startupGracePeriod waiting for the first poll does not make sense",
path: ["waitSecondsBeforeFirstPolling", "waitList"]
}
);

// src/input.ts
Expand Down
32 changes: 32 additions & 0 deletions src/assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { strictEqual, deepStrictEqual } from 'node:assert';
import { Temporal } from 'temporal-polyfill';
import { Options } from './schema.ts';

export function jsonEqual(actual: unknown, expected: unknown) {
deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected);
}

export function durationEqual(a: Temporal.Duration, b: Temporal.Duration) {
strictEqual(
Temporal.Duration.compare(a, b),
0,
);
}

function makeComparableOptions(options: Options): Options {
return {
...options,
waitList: options.waitList.map((w) => ({
...w,
// Do not use .toJSON(), it does not normalize `seconds: 102` to `PT1M42S`, returns `PT102S`
startupGracePeriodNano: w.startupGracePeriod.total('nanoseconds'),
})),
};
}

// Providing to get better result and diff in cases which have Temporal.Duration
// - Object.is() returns `false` even for same total, because they are not idencial
// - deepStrictEqual returns `true` even for different total because of no properties :<
export function optionsEqual(actual: Options, expected: Options) {
deepStrictEqual(makeComparableOptions(actual), makeComparableOptions(expected));
}
3 changes: 2 additions & 1 deletion src/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import test from 'node:test';
import assert from 'node:assert';
import { checks8679817057, checks92810686811WaitSuccessPolling1 } from './snapshot.ts';
import { Report, Summary, generateReport, getSummaries } from './report.ts';
import { jsonEqual, omit } from './util.ts';
import { omit } from './util.ts';
import { Temporal } from 'temporal-polyfill';
import { jsonEqual } from './assert.ts';

const exampleSummary = Object.freeze(
{
Expand Down
160 changes: 98 additions & 62 deletions src/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,8 @@
import test from 'node:test';
import { strictEqual, deepStrictEqual, throws } from 'node:assert';
import { deepStrictEqual, throws } from 'node:assert';
import { Durationable, Options } from './schema.ts';
import { Temporal } from 'temporal-polyfill';

function equalDuration(a: Temporal.Duration, b: Temporal.Duration) {
strictEqual(
Temporal.Duration.compare(a, b),
0,
);
}

function makeComparableOptions(options: Options): Options {
return {
...options,
waitList: options.waitList.map((w) => ({
...w,
// Do not use .toJSON(), it does not normalize `seconds: 102` to `PT1M42S`, returns `PT102S`
startupGracePeriodNano: w.startupGracePeriod.total('nanoseconds'),
})),
};
}

// Providing to get better result and diff in cases which have Temporal.Duration
// - Object.is() returns `false` even for same total, because they are not idencial
// - deepStrictEqual returns `true` even for different total because of no properties :<
function assertEqualOptions(actual: Options, expected: Options) {
deepStrictEqual(makeComparableOptions(actual), makeComparableOptions(expected));
}
import { durationEqual, optionsEqual } from './assert.ts';

const defaultOptions = Object.freeze({
isEarlyExit: true,
Expand Down Expand Up @@ -55,14 +31,17 @@ test('Options keep given values', () => {
});

test('Options set some default values it cannot be defined in action.yml', () => {
deepStrictEqual({
...defaultOptions,
waitList: [{
workflowFile: 'ci.yml',
optional: false,
startupGracePeriod: Temporal.Duration.from({ seconds: 101 }),
}],
}, Options.parse({ ...defaultOptions, waitList: [{ workflowFile: 'ci.yml' }] }));
optionsEqual(
Options.parse({ ...defaultOptions, waitList: [{ workflowFile: 'ci.yml' }] }),
{
...defaultOptions,
waitList: [{
workflowFile: 'ci.yml',
optional: false,
startupGracePeriod: Temporal.Duration.from({ seconds: 10 }),
}],
},
);
});

test('Options reject invalid values', () => {
Expand Down Expand Up @@ -107,45 +86,38 @@ test('Options reject invalid values', () => {

test('Durationable', async (t) => {
await t.test('transformed to Temporal.Duration', (_t) => {
equalDuration(Durationable.parse('PT1M42S'), Temporal.Duration.from({ seconds: 102 }));
equalDuration(Durationable.parse({ minutes: 1, seconds: 42 }), Temporal.Duration.from({ seconds: 102 }));
durationEqual(Durationable.parse('PT1M42S'), Temporal.Duration.from({ seconds: 102 }));
durationEqual(Durationable.parse({ minutes: 1, seconds: 42 }), Temporal.Duration.from({ seconds: 102 }));
});

await t.test('it raises an error if given an unexpected keys', (_t) => {
await t.test('it raises an error if given an invalid formats', (_t) => {
throws(
() =>
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: { min: 5 } }],
}),
() => Durationable.parse('42 minutes'),
{
name: 'ZodError',
message: /unrecognized_key/,
message: /invalid_string/,
},
);
});

await t.test('it parses ISO 8601 duration format', (_t) => {
deepStrictEqual(
await t.test('it raises an error if given an unexpected keys', (_t) => {
throws(
() => Durationable.parse({ min: 5 }),
{
...defaultOptions,
waitList: [{
workflowFile: 'ci.yml',
optional: false,
startupGracePeriod: Temporal.Duration.from('PT1M42S'),
}],
name: 'ZodError',
message: /unrecognized_key/,
},
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: 'PT1M42S' }],
}),
);
});
});

test('wait-list have startupGracePeriod', async (t) => {
await t.test('it accepts DurationLike objects', (_t) => {
deepStrictEqual(
optionsEqual(
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: Temporal.Duration.from({ minutes: 5 }) }],
}),
{
...defaultOptions,
waitList: [{
Expand All @@ -154,14 +126,27 @@ test('wait-list have startupGracePeriod', async (t) => {
startupGracePeriod: Temporal.Duration.from({ minutes: 5 }),
}],
},
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: Temporal.Duration.from({ minutes: 5 }) }],
}),
);
});

await t.test('it raises an error if given an unexpected keys', (_t) => {
await t.test('it raises a TypeError if given an unexpected keys', { todo: 'TODO: Replace with ZodError' }, (_t) => {
throws(
() =>
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: { min: 5 } }],
}),
{
name: 'TypeError',
message: 'No valid fields: days,hours,microseconds,milliseconds,minutes,months,nanoseconds,seconds,weeks,years',
},
);
});

await t.test('it raises a ZodError if given an unexpected keys', {
todo: "TODO: I don't know why using refine appears the native error",
skip: 'SKIP: To suppress noise',
}, (_t) => {
throws(
() =>
Options.parse({
Expand All @@ -176,7 +161,7 @@ test('wait-list have startupGracePeriod', async (t) => {
});

await t.test('it parses ISO 8601 duration format', (_t) => {
assertEqualOptions(
optionsEqual(
Options.parse({
...defaultOptions,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: 'PT1M42S' }],
Expand All @@ -191,4 +176,55 @@ test('wait-list have startupGracePeriod', async (t) => {
},
);
});

await t.test('it raises a ZodError if given value is larger than initial polling time', (_t) => {
throws(
() =>
Options.parse({
...defaultOptions,
waitSecondsBeforeFirstPolling: 41,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: { seconds: 40 } }],
}),
{
name: 'ZodError',
message: /A shorter startupGracePeriod waiting for the first poll does not make sense/,
},
);
});

await t.test('but does not raises errors if given value is as same as default to keep backward compatibility', (_t) => {
optionsEqual(
Options.parse({
...defaultOptions,
waitSecondsBeforeFirstPolling: 42,
waitList: [{ workflowFile: 'ci.yml', startupGracePeriod: { seconds: 10 } }],
}),
{
...defaultOptions,
waitSecondsBeforeFirstPolling: 42,
waitList: [{
workflowFile: 'ci.yml',
optional: false,
startupGracePeriod: Temporal.Duration.from({ seconds: 10 }),
}],
},
);

optionsEqual(
Options.parse({
...defaultOptions,
waitSecondsBeforeFirstPolling: 42,
waitList: [{ workflowFile: 'ci.yml' }],
}),
{
...defaultOptions,
waitSecondsBeforeFirstPolling: 42,
waitList: [{
workflowFile: 'ci.yml',
optional: false,
startupGracePeriod: Temporal.Duration.from({ seconds: 10 }),
}],
},
);
});
});
19 changes: 17 additions & 2 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type MyDurationLike = z.infer<typeof MyDurationLike>;
// IETF does not define duration formats in their RFCs, but in RFC 3399 refers ISO 8601 duration formats.
// https://www.ietf.org/rfc/rfc3339.txt
export const Durationable = z.union([z.string().duration(), MyDurationLike]).transform((item) => getDuration(item));
const defaultGrace = Temporal.Duration.from({ seconds: 10 });

// workaround for https://github.com/colinhacks/zod/issues/635
function isDurationLike(my: MyDurationLike): my is DurationLike {
Expand Down Expand Up @@ -71,9 +72,9 @@ const WaitFilterCondition = FilterCondition.extend(
// - Intentionally omitted in skip-list, let me know if you have the use-case
eventName: z.string().min(1).optional(),

// Do not raise validation errors for the reasonability of value range.
// Do not raise validation errors for the reasonability of max value.
// Even in equal_intervals mode, we can't enforce the possibility of the whole running time
startupGracePeriod: Durationable.default(Temporal.Duration.from({ seconds: 10 })),
startupGracePeriod: Durationable.default(defaultGrace),
},
).readonly();
const WaitList = z.array(WaitFilterCondition).readonly();
Expand All @@ -98,6 +99,20 @@ export const Options = z.object({
}).readonly().refine(
({ waitList, skipList }) => !(waitList.length > 0 && skipList.length > 0),
{ message: 'Do not specify both wait-list and skip-list', path: ['waitList', 'skipList'] },
).refine(
({ waitSecondsBeforeFirstPolling, waitList }) =>
waitList.every(
(item) =>
!(Temporal.Duration.compare(
{ seconds: waitSecondsBeforeFirstPolling },
item.startupGracePeriod,
) > 0
&& Temporal.Duration.compare(item.startupGracePeriod, defaultGrace) !== 0),
),
{
message: 'A shorter startupGracePeriod waiting for the first poll does not make sense',
path: ['waitSecondsBeforeFirstPolling', 'waitList'],
},
);

export type Options = z.infer<typeof Options>;
Expand Down
6 changes: 0 additions & 6 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { deepStrictEqual } from 'node:assert';

export function pick<T extends object, U extends keyof T>(
base: Readonly<T>,
keys: Readonly<U[]>,
Expand Down Expand Up @@ -39,7 +37,3 @@ export function groupBy<T, K>(items: ReadonlyArray<T>, callback: (item: T) => K)
}
return map;
}

export function jsonEqual(actual: unknown, expected: unknown) {
deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected);
}

0 comments on commit 77ca20d

Please sign in to comment.