Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions documentation.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@
},
{
"name": "ReportFrameComponent",
"id": "component-ReportFrameComponent-e323810cea7fa8a83dc4423d7e4d93fbb78ad0dfd2b31f4f6f0327cd9c1b5582e77db10297dcea5fdc7718f4b7ae0a9c5bdfed47003df780b535753863652249",
"id": "component-ReportFrameComponent-e96f8cf5e81556af6935e5f14db2c7eb7d81c6dcd8253cb410c9793fda620e197a247e0b277c993a75ed0a65a9aa5e2d5f9e67ca08eb2509235d32d48a012ba0",
"file": "src/stories/reports/report-frame.component.ts",
"changeDetection": "ChangeDetectionStrategy.OnPush",
"encapsulation": [],
Expand All @@ -607,7 +607,7 @@
"inputs": [],
"outputs": [],
"providers": [],
"selector": "storybook-report-frame",
"selector": "app-report-frame",
"styleUrls": [],
"styles": [],
"templateUrl": [
Expand Down Expand Up @@ -701,7 +701,7 @@
"description": "",
"rawdescription": "\n",
"type": "component",
"sourceCode": "import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';\nimport { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';\n\n@Component({\n selector: 'storybook-report-frame',\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n class: 'report-frame-host',\n },\n templateUrl: './report-frame.component.html',\n styleUrl: './report-frame.component.css',\n})\nexport class ReportFrameComponent {\n readonly title = input.required<string>();\n readonly description = input.required<string>();\n readonly src = input.required<string>();\n\n private readonly sanitizer = inject(DomSanitizer);\n\n readonly safeSrc = computed<SafeResourceUrl>(() =>\n this.sanitizer.bypassSecurityTrustResourceUrl(this.src()),\n );\n}\n",
"sourceCode": "import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';\nimport { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';\n\n@Component({\n selector: 'app-report-frame',\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n class: 'report-frame-host',\n },\n templateUrl: './report-frame.component.html',\n styleUrl: './report-frame.component.css',\n})\nexport class ReportFrameComponent {\n readonly title = input.required<string>();\n readonly description = input.required<string>();\n readonly src = input.required<string>();\n\n private readonly sanitizer = inject(DomSanitizer);\n\n readonly safeSrc = computed<SafeResourceUrl>(() =>\n this.sanitizer.bypassSecurityTrustResourceUrl(this.src()),\n );\n}\n",
"styleUrl": "./report-frame.component.css",
"assetsDirs": [],
"styleUrlsData": "",
Expand Down Expand Up @@ -2021,29 +2021,29 @@
{
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/features/todos/components/todo-filters-panel/todo-filters-panel.test-ids.ts",
"file": "src/app/features/todos/components/todo-item/todo-item.test-ids.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "unknown",
"defaultValue": "generateTestIds({ scopeName: 'filtersPanel' }).add(\n 'searchInput',\n 'filterAll',\n 'filterActive',\n 'filterCompleted',\n)"
"defaultValue": "generateTestIds({ scopeName: 'todoItem' }).add(\n 'checkbox',\n 'title',\n 'meta',\n 'stateButton',\n 'deleteButton',\n)"
},
{
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/features/todos/components/todo-summary/todo-summary.test-ids.ts",
"file": "src/app/features/todos/components/todo-filters-panel/todo-filters-panel.test-ids.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "unknown",
"defaultValue": "generateTestIds({\n scopeName: 'todoSummary',\n}).add('totalValue', 'activeValue', 'completedValue')"
"defaultValue": "generateTestIds({ scopeName: 'filtersPanel' }).add(\n 'searchInput',\n 'filterAll',\n 'filterActive',\n 'filterCompleted',\n)"
},
{
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/features/todos/components/todo-item/todo-item.test-ids.ts",
"file": "src/app/features/todos/components/todo-summary/todo-summary.test-ids.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "unknown",
"defaultValue": "generateTestIds({ scopeName: 'todoItem' }).add(\n 'checkbox',\n 'title',\n 'meta',\n 'stateButton',\n 'deleteButton',\n)"
"defaultValue": "generateTestIds({\n scopeName: 'todoSummary',\n}).add('totalValue', 'activeValue', 'completedValue')"
},
{
"ctype": "miscellaneous",
Expand All @@ -2070,7 +2070,7 @@
"type": "ScopeName",
"deprecated": false,
"deprecationMessage": "",
"destrucuredGroupId": "ae0490c6-480d-4136-958b-ff277c8b0711",
"destrucuredGroupId": "0f318d07-9ff8-44d7-96b3-4ce50d9b8c0c",
"destructuredParameter": true
}
],
Expand All @@ -2080,7 +2080,7 @@
"type": "ScopeName",
"deprecated": false,
"deprecationMessage": "",
"destrucuredGroupId": "ae0490c6-480d-4136-958b-ff277c8b0711",
"destrucuredGroupId": "0f318d07-9ff8-44d7-96b3-4ce50d9b8c0c",
"destructuredParameter": true,
"tagName": {
"text": "param"
Expand Down Expand Up @@ -2432,7 +2432,7 @@
"type": "ScopeName",
"deprecated": false,
"deprecationMessage": "",
"destrucuredGroupId": "ae0490c6-480d-4136-958b-ff277c8b0711",
"destrucuredGroupId": "0f318d07-9ff8-44d7-96b3-4ce50d9b8c0c",
"destructuredParameter": true
}
],
Expand All @@ -2442,7 +2442,7 @@
"type": "ScopeName",
"deprecated": false,
"deprecationMessage": "",
"destrucuredGroupId": "ae0490c6-480d-4136-958b-ff277c8b0711",
"destrucuredGroupId": "0f318d07-9ff8-44d7-96b3-4ce50d9b8c0c",
"destructuredParameter": true,
"tagName": {
"text": "param"
Expand Down
181 changes: 129 additions & 52 deletions e2e/vrt/stories.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="node" />

import { test, expect } from '@playwright/test';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { test, expect, type Page } from '@playwright/test';
import { fileURLToPath } from 'node:url';

/**
* Visual Regression Tests for Storybook Stories
Expand Down Expand Up @@ -30,68 +31,144 @@ const viewports = [
},
];

function getStoryPath(componentPath: string, storyName: string): string {
const kebabCaseStory = storyName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
const id = `${componentPath}--${kebabCaseStory}`;
return `/iframe.html?viewMode=story&id=${id}`;
interface StoryDefinition {
exportName: string;
id: string;
title?: string;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const storyTitlePrefix = 'Todos/Components';
const storiesSourceRoot = resolve(__dirname, '../../src');

function getStoryPath(storyId: string): string {
return `/iframe.html?viewMode=story&id=${storyId}`;
}

test.describe('TodoAddForm Component Stories', () => {
const stories = [
{ storyName: 'Empty', description: 'Default empty state' },
{ storyName: 'Filled', description: 'With content' },
{ storyName: 'RequiredError', description: 'Validation error for empty/whitespace' },
{ storyName: 'MaxLengthError', description: 'Validation error for length exceeded' },
];
function collectStoryFiles(directoryPath: string): string[] {
return readdirSync(directoryPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = resolve(directoryPath, entry.name);

if (entry.isDirectory()) {
return collectStoryFiles(entryPath);
}

return entry.name.endsWith('.stories.ts') ? [entryPath] : [];
});
}

function toStorySegment(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.toLowerCase();
}

function formatStoryName(exportName: string): string {
return exportName.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
}

function parseStoriesFromFile(filePath: string): StoryDefinition[] {
const source = readFileSync(filePath, 'utf8');
const titleMatch = source.match(/title:\s*['"`]([^'"`]+)['"`]/);
const title = titleMatch?.[1];
Comment on lines +74 to +77

if (!title?.startsWith(storyTitlePrefix)) {
return [];
}

const titleId = title
.split('/')
.map((segment) => toStorySegment(segment))
.join('-');

return Array.from(source.matchAll(/export const\s+(\w+)\s*:\s*Story\b/g), (match) => ({
exportName: match[1],
id: `${titleId}--${toStorySegment(match[1])}`,
title,
}));
}
Comment on lines +83 to +93

function discoverStoriesFromSource(): StoryDefinition[] {
const stories = collectStoryFiles(storiesSourceRoot)
.flatMap((filePath) => parseStoriesFromFile(filePath))
.sort((a, b) => {
const titleCompare = (a.title ?? '').localeCompare(b.title ?? '');
if (titleCompare !== 0) {
return titleCompare;
}

return a.exportName.localeCompare(b.exportName);
});

if (stories.length === 0) {
throw new Error(
`No stories found for title prefix "${storyTitlePrefix}". Check Storybook story titles or story export patterns.`
);
}

return stories;
}

const stories = discoverStoriesFromSource();

async function waitForStoryToRender(page: Page, path: string): Promise<void> {
const renderStateHandle = await page.waitForFunction(
() => {
const body = document.body;
if (!body) {
return null;
}

if (body.classList.contains('sb-show-main')) {
return 'ready';
}

if (body.classList.contains('sb-show-nopreview')) {
return 'no-preview';
}

if (body.classList.contains('sb-show-errordisplay')) {
const storybookError = document.querySelector('#error-message')?.textContent ?? '';
return storybookError.trim().length > 0
? `storybook-error:${storybookError.trim()}`
: 'storybook-error';
}

if (body.children.length === 0 && body.textContent?.trim() === 'Not Found') {
return 'not-found';
}

return null;
},
{ timeout: 12_000 }
);

const renderState = (await renderStateHandle.jsonValue()) as string;
if (renderState !== 'ready') {
throw new Error(`Story did not render for ${path}. Render state: ${renderState}`);
}
}

test.describe('Todo Component Stories', () => {
for (const viewport of viewports) {
test.describe(`@${viewport.name}`, () => {
test.use({
viewport: { width: viewport.width, height: viewport.height },
});

for (const story of stories) {
test(`${story.storyName} - ${story.description}`, async ({ page }) => {
const path = getStoryPath('todos-components-todo-add-form', story.storyName);
test(`${story.title ?? 'Unknown'} / ${formatStoryName(story.exportName)}`, async ({
page,
}) => {
const path = getStoryPath(story.id);
await page.goto(path);

await page.waitForLoadState('domcontentloaded');

const renderStateHandle = await page.waitForFunction(
() => {
const body = document.body;
if (!body) {
return null;
}

if (body.classList.contains('sb-show-main')) {
return 'ready';
}

if (body.classList.contains('sb-show-nopreview')) {
return 'no-preview';
}

if (body.classList.contains('sb-show-errordisplay')) {
const storybookError = document.querySelector('#error-message')?.textContent ?? '';
return storybookError.trim().length > 0
? `storybook-error:${storybookError.trim()}`
: 'storybook-error';
}

if (body.children.length === 0 && body.textContent?.trim() === 'Not Found') {
return 'not-found';
}

return null;
},
{ timeout: 12_000 },
);

const renderState = (await renderStateHandle.jsonValue()) as string;
if (renderState !== 'ready') {
throw new Error(`Story did not render for ${path}. Render state: ${renderState}`);
}
await waitForStoryToRender(page, path);

await expect(page).toHaveScreenshot({
fullPage: false,
Expand Down
6 changes: 3 additions & 3 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const isVRTMode = !!process.env['VRT'];
const isCI = !!process.env['CI'];
const vrtPort = 6106;
const vrtHost = '127.0.0.1';
const ensureStorybookStaticCommand =
'[ -f storybook-static/index.html ] && [ -f storybook-static/iframe.html ] || npx ng run angular-21:build-storybook';
Comment on lines +9 to +10

export default defineConfig({
testDir: './e2e',
Expand All @@ -23,9 +25,7 @@ export default defineConfig({
},
webServer: isVRTMode
? {
command: isCI
? `npx http-server storybook-static -a ${vrtHost} -p ${vrtPort} -c-1 -s`
: `test -d storybook-static || npx ng run angular-21:build-storybook; npx http-server storybook-static -a ${vrtHost} -p ${vrtPort} -c-1 -s`,
command: `${ensureStorybookStaticCommand}; npx http-server storybook-static -a ${vrtHost} -p ${vrtPort} -c-1 -s`,
url: `http://${vrtHost}:${vrtPort}`,
reuseExistingServer: false,
timeout: 120_000,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading