From 1a5b7e480ddccfbbcf07bf2deecf9fe0f6a51916 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:12:37 +0000 Subject: [PATCH] Add wizard docs entry, malformed-model regression test, and second Playwright flow Closes #53519 - Link the AW wizard page from the docs sidebar (Setup > AW Wizard) - Extract wizard data model validation into validation.ts (no JSON import) so it can be unit tested directly with plain node - Add docs/src/lib/wizard/model.test.js covering valid data plus malformed cases: non-object input, missing/non-semver version, empty top-level arrays, and goal/trigger/destination options missing required fields - Wire the new test into make test-docs-wizard-model - Add a second Playwright flow in wizard.spec.ts covering the issue-automation goal branch to exercise a distinct trigger/destination inference path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Makefile | 6 ++ docs/astro.config.mjs | 1 + docs/src/lib/wizard/model.test.js | 140 ++++++++++++++++++++++++++++++ docs/src/lib/wizard/model.ts | 104 +++------------------- docs/src/lib/wizard/validation.ts | 97 +++++++++++++++++++++ docs/tests/wizard.spec.ts | 20 +++++ 6 files changed, 276 insertions(+), 92 deletions(-) create mode 100644 docs/src/lib/wizard/model.test.js create mode 100644 docs/src/lib/wizard/validation.ts diff --git a/Makefile b/Makefile index 5189aa41fcb..8030cfca19e 100644 --- a/Makefile +++ b/Makefile @@ -1198,6 +1198,12 @@ test-docs-remark: @node docs/src/lib/remark/inlineMarkdownInHtml.test.js @echo "✓ Docs remark plugin unit tests passed" +.PHONY: test-docs-wizard-model +test-docs-wizard-model: + @echo "Running AW wizard data model unit tests..." + @node docs/src/lib/wizard/model.test.js + @echo "✓ AW wizard data model unit tests passed" + # Sync templates from .github to pkg/cli/templates # Sync action pins from .github/aw to pkg/actionpins/data and pkg/workflow/data .PHONY: sync-action-pins diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 29fcf1a8717..4a54973eb83 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -315,6 +315,7 @@ export default defineConfig({ label: 'Setup', items: [ { label: 'Quick Start', link: '/setup/quick-start/' }, + { label: 'AW Wizard', link: '/wizard/' }, { label: 'Creating Workflows', link: '/setup/creating-workflows/' }, { label: 'CLI Commands', link: '/setup/cli/' }, ], diff --git a/docs/src/lib/wizard/model.test.js b/docs/src/lib/wizard/model.test.js new file mode 100644 index 00000000000..4add7fa92e9 --- /dev/null +++ b/docs/src/lib/wizard/model.test.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// @ts-check + +/** + * Unit tests for the AW wizard shared JSON data model validation + * (docs/src/lib/wizard/validation.ts). + * + * These tests exercise `validateModel` directly so malformed wizard data + * model JSON is caught automatically instead of only failing at Astro + * build/import time. + * + * Run with: node docs/src/lib/wizard/model.test.js + */ + +import { validateModel } from './validation.ts'; +import validModel from '../../data/wizard-data-model.json' with { type: 'json' }; + +let passed = 0; +let failed = 0; + +function assertThrows(fn, label) { + try { + fn(); + console.error(` ✗ ${label}`); + console.error(' expected: function to throw'); + console.error(' actual: no error was thrown'); + failed++; + } catch (error) { + console.log(` ✓ ${label} (${/** @type {Error} */ (error).message})`); + passed++; + } +} + +function assertDoesNotThrow(fn, label) { + try { + fn(); + console.log(` ✓ ${label}`); + passed++; + } catch (error) { + console.error(` ✗ ${label}`); + console.error(` expected: function not to throw`); + console.error(` actual: threw ${/** @type {Error} */ (error).message}`); + failed++; + } +} + +function clone(data) { + return JSON.parse(JSON.stringify(data)); +} + +// ------------------------------------------------------------------- +// Test: the real, checked-in wizard data model is valid +// ------------------------------------------------------------------- +console.log('\nreal wizard-data-model.json:'); +{ + assertDoesNotThrow(() => validateModel(clone(validModel)), 'checked-in wizard-data-model.json passes validation'); +} + +// ------------------------------------------------------------------- +// Test: non-object input is rejected +// ------------------------------------------------------------------- +console.log('\nnon-object input:'); +{ + assertThrows(() => validateModel(null), 'null is rejected'); + assertThrows(() => validateModel('not an object'), 'string is rejected'); + assertThrows(() => validateModel([]), 'array is rejected'); +} + +// ------------------------------------------------------------------- +// Test: missing/invalid version field +// ------------------------------------------------------------------- +console.log('\nversion field:'); +{ + const missingVersion = clone(validModel); + delete missingVersion.version; + assertThrows(() => validateModel(missingVersion), 'missing version is rejected'); + + const nonSemverVersion = clone(validModel); + nonSemverVersion.version = '1.0'; + assertThrows(() => validateModel(nonSemverVersion), 'non-semver version ("1.0") is rejected'); +} + +// ------------------------------------------------------------------- +// Test: empty top-level arrays are rejected +// ------------------------------------------------------------------- +console.log('\nempty top-level arrays:'); +{ + const emptyGoals = clone(validModel); + emptyGoals.goalCategories = []; + assertThrows(() => validateModel(emptyGoals), 'empty goalCategories is rejected'); + + const emptyTriggers = clone(validModel); + emptyTriggers.triggerOptions = []; + assertThrows(() => validateModel(emptyTriggers), 'empty triggerOptions is rejected'); + + const emptyDestinations = clone(validModel); + emptyDestinations.destinationOptions = []; + assertThrows(() => validateModel(emptyDestinations), 'empty destinationOptions is rejected'); +} + +// ------------------------------------------------------------------- +// Test: goal category missing required fields +// ------------------------------------------------------------------- +console.log('\ngoal category shape:'); +{ + const missingTriggerIds = clone(validModel); + delete missingTriggerIds.goalCategories[0].triggerOptionIds; + assertThrows(() => validateModel(missingTriggerIds), 'goal category missing triggerOptionIds is rejected'); + + const missingDestinationIds = clone(validModel); + delete missingDestinationIds.goalCategories[0].destinationOptionIds; + assertThrows(() => validateModel(missingDestinationIds), 'goal category missing destinationOptionIds is rejected'); + + const missingGoalText = clone(validModel); + delete missingGoalText.goalCategories[0].text; + assertThrows(() => validateModel(missingGoalText), 'goal category missing text.label is rejected'); +} + +// ------------------------------------------------------------------- +// Test: trigger/destination option missing required fields +// ------------------------------------------------------------------- +console.log('\ntrigger/destination option shape:'); +{ + const missingTriggerType = clone(validModel); + delete missingTriggerType.triggerOptions[0].type; + assertThrows(() => validateModel(missingTriggerType), 'trigger option missing type is rejected'); + + const missingDestinationSafeOutputType = clone(validModel); + delete missingDestinationSafeOutputType.destinationOptions[0].safeOutputType; + assertThrows( + () => validateModel(missingDestinationSafeOutputType), + 'destination option missing safeOutputType is rejected', + ); +} + +// ------------------------------------------------------------------- +// Summary +// ------------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/docs/src/lib/wizard/model.ts b/docs/src/lib/wizard/model.ts index 5cb93fd5bc0..a48b7235342 100644 --- a/docs/src/lib/wizard/model.ts +++ b/docs/src/lib/wizard/model.ts @@ -1,96 +1,16 @@ import modelData from '../../data/wizard-data-model.json'; - -export interface WizardText { - label: string; - help?: string; -} - -export interface WizardGoalCategory { - id: string; - text: WizardText; - triggerOptionIds: string[]; - destinationOptionIds: string[]; - defaultTriggerOptionId?: string; - defaultDestinationOptionId?: string; -} - -export interface WizardTriggerOption { - id: string; - type: string; - text: WizardText; - frontmatter?: Record; -} - -export interface WizardDestinationOption { - id: string; - safeOutputType: string; - text: WizardText; - inferFromTriggerTypes?: string[]; - frontmatter?: Record; -} - -export interface WizardPromptSection { - id: string; - heading: string; -} - -export interface WizardDataModel { - version: string; - goalCategories: WizardGoalCategory[]; - triggerOptions: WizardTriggerOption[]; - destinationOptions: WizardDestinationOption[]; - promptTemplate?: { - introText?: string; - sections?: WizardPromptSection[]; - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasText(value: unknown): value is WizardText { - return isRecord(value) && typeof value.label === 'string' && (value.help === undefined || typeof value.help === 'string'); -} - -function validateModel(data: unknown): asserts data is WizardDataModel { - if (!isRecord(data)) throw new Error('Wizard data model must be an object.'); - if (typeof data.version !== 'string') throw new Error('Wizard data model version must be a string.'); - if (!/^\d+\.\d+\.\d+$/.test(data.version)) throw new Error('Wizard data model version must use semver.'); - if (!Array.isArray(data.goalCategories) || data.goalCategories.length === 0) { - throw new Error('Wizard data model requires at least one goal category.'); - } - if (!Array.isArray(data.triggerOptions) || data.triggerOptions.length === 0) { - throw new Error('Wizard data model requires at least one trigger option.'); - } - if (!Array.isArray(data.destinationOptions) || data.destinationOptions.length === 0) { - throw new Error('Wizard data model requires at least one destination option.'); - } - - for (const goal of data.goalCategories) { - if (!isRecord(goal) || typeof goal.id !== 'string' || !hasText(goal.text)) { - throw new Error('Each wizard goal category must include id and text.label.'); - } - if (!Array.isArray(goal.triggerOptionIds) || goal.triggerOptionIds.length === 0) { - throw new Error(`Goal category "${goal.id}" must define triggerOptionIds.`); - } - if (!Array.isArray(goal.destinationOptionIds) || goal.destinationOptionIds.length === 0) { - throw new Error(`Goal category "${goal.id}" must define destinationOptionIds.`); - } - } - - for (const trigger of data.triggerOptions) { - if (!isRecord(trigger) || typeof trigger.id !== 'string' || typeof trigger.type !== 'string' || !hasText(trigger.text)) { - throw new Error('Each wizard trigger option must include id, type, and text.label.'); - } - } - - for (const destination of data.destinationOptions) { - if (!isRecord(destination) || typeof destination.id !== 'string' || typeof destination.safeOutputType !== 'string' || !hasText(destination.text)) { - throw new Error('Each wizard destination option must include id, safeOutputType, and text.label.'); - } - } -} +import { validateModel, type WizardDataModel } from './validation'; + +export type { + WizardText, + WizardGoalCategory, + WizardTriggerOption, + WizardDestinationOption, + WizardPromptSection, + WizardDataModel, +} from './validation'; + +export { validateModel } from './validation'; validateModel(modelData); diff --git a/docs/src/lib/wizard/validation.ts b/docs/src/lib/wizard/validation.ts new file mode 100644 index 00000000000..01700efc160 --- /dev/null +++ b/docs/src/lib/wizard/validation.ts @@ -0,0 +1,97 @@ +export interface WizardText { + label: string; + help?: string; +} + +export interface WizardGoalCategory { + id: string; + text: WizardText; + triggerOptionIds: string[]; + destinationOptionIds: string[]; + defaultTriggerOptionId?: string; + defaultDestinationOptionId?: string; +} + +export interface WizardTriggerOption { + id: string; + type: string; + text: WizardText; + frontmatter?: Record; +} + +export interface WizardDestinationOption { + id: string; + safeOutputType: string; + text: WizardText; + inferFromTriggerTypes?: string[]; + frontmatter?: Record; +} + +export interface WizardPromptSection { + id: string; + heading: string; +} + +export interface WizardDataModel { + version: string; + goalCategories: WizardGoalCategory[]; + triggerOptions: WizardTriggerOption[]; + destinationOptions: WizardDestinationOption[]; + promptTemplate?: { + introText?: string; + sections?: WizardPromptSection[]; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasText(value: unknown): value is WizardText { + return isRecord(value) && typeof value.label === 'string' && (value.help === undefined || typeof value.help === 'string'); +} + +/** + * Validates that `data` conforms to the shared wizard JSON data model shape, + * throwing a descriptive Error on the first violation found. Used both at + * module load time (via model.ts) and by automated tests to catch malformed + * model data before it reaches the docs build. + */ +export function validateModel(data: unknown): asserts data is WizardDataModel { + if (!isRecord(data)) throw new Error('Wizard data model must be an object.'); + if (typeof data.version !== 'string') throw new Error('Wizard data model version must be a string.'); + if (!/^\d+\.\d+\.\d+$/.test(data.version)) throw new Error('Wizard data model version must use semver.'); + if (!Array.isArray(data.goalCategories) || data.goalCategories.length === 0) { + throw new Error('Wizard data model requires at least one goal category.'); + } + if (!Array.isArray(data.triggerOptions) || data.triggerOptions.length === 0) { + throw new Error('Wizard data model requires at least one trigger option.'); + } + if (!Array.isArray(data.destinationOptions) || data.destinationOptions.length === 0) { + throw new Error('Wizard data model requires at least one destination option.'); + } + + for (const goal of data.goalCategories) { + if (!isRecord(goal) || typeof goal.id !== 'string' || !hasText(goal.text)) { + throw new Error('Each wizard goal category must include id and text.label.'); + } + if (!Array.isArray(goal.triggerOptionIds) || goal.triggerOptionIds.length === 0) { + throw new Error(`Goal category "${goal.id}" must define triggerOptionIds.`); + } + if (!Array.isArray(goal.destinationOptionIds) || goal.destinationOptionIds.length === 0) { + throw new Error(`Goal category "${goal.id}" must define destinationOptionIds.`); + } + } + + for (const trigger of data.triggerOptions) { + if (!isRecord(trigger) || typeof trigger.id !== 'string' || typeof trigger.type !== 'string' || !hasText(trigger.text)) { + throw new Error('Each wizard trigger option must include id, type, and text.label.'); + } + } + + for (const destination of data.destinationOptions) { + if (!isRecord(destination) || typeof destination.id !== 'string' || typeof destination.safeOutputType !== 'string' || !hasText(destination.text)) { + throw new Error('Each wizard destination option must include id, safeOutputType, and text.label.'); + } + } +} diff --git a/docs/tests/wizard.spec.ts b/docs/tests/wizard.spec.ts index d14f6165a20..7f6f99993f5 100644 --- a/docs/tests/wizard.spec.ts +++ b/docs/tests/wizard.spec.ts @@ -23,4 +23,24 @@ test.describe('AW wizard page', () => { await expect(page.locator('[data-wizard-preview]')).toContainText('Destination: Post a discussion'); await expect(page.getByRole('button', { name: 'Review selections' })).toBeVisible(); }); + + test('infers a comment destination for the issue automation goal', async ({ page }) => { + await page.goto('/gh-aw/wizard/'); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('Step 1 of 3')).toBeVisible(); + await page.getByLabel('Automate issue triage or replies').check(); + await page.getByRole('button', { name: 'Next' }).click(); + + await expect(page.getByRole('heading', { name: 'Choose a trigger' })).toBeVisible(); + await expect(page.getByLabel('When an issue is opened')).toBeVisible(); + await expect(page.getByLabel('When someone comments on an issue')).toBeVisible(); + + await page.getByRole('button', { name: 'Next' }).click(); + + await expect(page.getByRole('heading', { name: 'Review the inferred output destination' })).toBeVisible(); + await expect(page.locator('[data-wizard-summary]')).toContainText('Automate issue triage or replies'); + await expect(page.locator('[data-wizard-preview]')).toContainText('Destination: Post a comment'); + await expect(page.getByRole('button', { name: 'Review selections' })).toBeVisible(); + }); });