Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ jobs:
- name: Test the epoch guard
run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs

- name: Test Storybook audit contracts
run: node --test scripts/ax-tree-audit.test.mjs scripts/storybook-visual-smoke.test.mjs
- name: Test Computer Use script contracts
run: node --test scripts/ax-tree-audit.test.mjs scripts/computer-use/lab-root.test.mjs

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dev:hmr": "node scripts/dev.mjs",
"storybook": "storybook dev -p 6006 -c .storybook",
"build-storybook": "storybook build -c .storybook --output-dir storybook-static",
"presmoke:storybook": "npm --workspace @maka/core run build",
"smoke:storybook": "node ../../scripts/storybook-visual-smoke.mjs",
"check:architecture": "node --test scripts/check-renderer-architecture.test.mjs && node scripts/check-renderer-architecture.mjs",
"build": "npm run build:resources && npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer",
Expand Down
48 changes: 35 additions & 13 deletions scripts/storybook-visual-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { auditAxTree } from './ax-tree-audit.mjs';

const RENDER_VIEWPORT = Object.freeze({ width: 1280, height: 900 });
const NARROW_RENDER_VIEWPORT = Object.freeze({ width: 720, height: 900 });
const COLOR_SCHEMES = Object.freeze(['light', 'dark']);
const FULL_PALETTE_STORY_IDS = new Set(['product-shell-official-appshell--native-conversation']);
const REQUIRED_COMPUTER_USE_STORY_IDS = new Set([
'product-accessibility-dialogs--create-scheduled-task',
'product-accessibility-dialogs--mermaid-fullscreen',
Expand Down Expand Up @@ -131,28 +133,47 @@ function installStorybookRenderProbe({ storyId }) {
connect();
}

export function catalogJobs(storyIndex) {
export function catalogJobs(
storyIndex,
{ themePalettes = ['default'], fullPaletteStoryIds = FULL_PALETTE_STORY_IDS } = {},
) {
const entries = storyIndex?.entries;
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) {
throw new Error('Built Storybook index has no entries');
}
if (!Array.isArray(themePalettes) || themePalettes.length === 0) {
throw new Error('Storybook smoke requires at least one theme palette');
}
const palettes = [...new Set(themePalettes)];
if (!palettes.includes('default')) {
throw new Error('Storybook smoke theme palettes must include default');
}
const jobs = Object.values(entries)
.filter((entry) => entry?.type === 'story' && typeof entry.id === 'string')
.flatMap((entry) => [
{ storyId: entry.id, colorScheme: 'light' },
...(DARK_THEME_SENTINEL_STORY_IDS.has(entry.id)
? [{ storyId: entry.id, colorScheme: 'dark' }]
: []),
]);
.flatMap((entry) => {
const hasFullPaletteCoverage = fullPaletteStoryIds.has(entry.id);
const entryPalettes = hasFullPaletteCoverage ? palettes : ['default'];
const colorSchemes =
hasFullPaletteCoverage || DARK_THEME_SENTINEL_STORY_IDS.has(entry.id)
? COLOR_SCHEMES
: ['light'];
return entryPalettes.flatMap((palette) =>
colorSchemes.map((colorScheme) => ({
storyId: entry.id,
colorScheme,
palette,
})),
);
});
if (jobs.length === 0) throw new Error('Built Storybook index has no stories');
return jobs;
}

export function storyUrl(baseUrl, storyId, colorScheme = 'light') {
export function storyUrl(baseUrl, job) {
const url = new URL('/iframe.html', baseUrl);
url.searchParams.set('id', storyId);
url.searchParams.set('id', job.storyId);
url.searchParams.set('viewMode', 'story');
url.searchParams.set('globals', `colorScheme:${colorScheme}`);
url.searchParams.set('globals', `colorScheme:${job.colorScheme};palette:${job.palette}`);
return url.href;
}

Expand All @@ -161,7 +182,7 @@ export function storyViewport(storyId) {
}

export function jobLabel(job) {
return `${job.storyId} (${job.colorScheme})`;
return `${job.storyId} (${job.colorScheme}/${job.palette})`;
}

async function smokeStory(page, baseUrl, job, options = {}) {
Expand All @@ -180,7 +201,7 @@ async function smokeStory(page, baseUrl, job, options = {}) {
await page.addInitScript(installStorybookRenderProbe, { storyId: job.storyId });
await page.setViewportSize(storyViewport(job.storyId));
await page.emulateMedia({ colorScheme: job.colorScheme });
await page.goto(storyUrl(baseUrl, job.storyId, job.colorScheme), { waitUntil: 'load' });
await page.goto(storyUrl(baseUrl, job), { waitUntil: 'load' });

try {
await page.waitForFunction(
Expand Down Expand Up @@ -334,7 +355,8 @@ async function runCli() {
const repoRoot = resolve(scriptDir, '..');
const staticDir = resolve(process.argv[2] ?? join(repoRoot, 'apps/desktop/storybook-static'));
const storyIndex = await readFile(join(staticDir, 'index.json'), 'utf8').then(JSON.parse);
const jobs = catalogJobs(storyIndex);
const { THEME_PALETTES } = await import('@maka/core/settings');
Comment thread
Yx01-me marked this conversation as resolved.
const jobs = catalogJobs(storyIndex, { themePalettes: THEME_PALETTES });
const storyIds = new Set(jobs.map((job) => job.storyId));
const requiredStoryIds = new Set([
...REQUIRED_COMPUTER_USE_STORY_IDS,
Expand Down
126 changes: 126 additions & 0 deletions scripts/storybook-visual-smoke.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import { catalogJobs, storyUrl } from './storybook-visual-smoke.mjs';

const REFERENCE_STORY_ID = 'product-shell-official-appshell--native-conversation';
const THEME_PALETTES = [
'default',
...Array.from({ length: 10 }, (_, index) => `test-palette-${index + 1}`),
];

function storyIndex(...storyIds) {
return {
entries: Object.fromEntries(
storyIds.map((storyId) => [storyId, { id: storyId, type: 'story' }]),
),
};
}

test('ordinary catalog stories render the default palette in light mode', () => {
assert.deepEqual(
catalogJobs(storyIndex('product-settings--memory'), { themePalettes: THEME_PALETTES }),
[
{
storyId: 'product-settings--memory',
colorScheme: 'light',
palette: 'default',
},
],
);
});

test('dark theme sentinel stories render the default palette in both colour schemes', () => {
const storyId = 'product-settings-pages--appearance';

assert.deepEqual(catalogJobs(storyIndex(storyId), { themePalettes: THEME_PALETTES }), [
{ storyId, colorScheme: 'light', palette: 'default' },
{ storyId, colorScheme: 'dark', palette: 'default' },
]);
});

test('the reference story renders every palette in both colour schemes', () => {
const jobs = catalogJobs(storyIndex(REFERENCE_STORY_ID), {
themePalettes: THEME_PALETTES,
});

assert.equal(jobs.length, 22);
assert.equal(new Set(jobs.map((job) => `${job.colorScheme}/${job.palette}`)).size, 22);
assert.deepEqual(jobs.slice(0, 4), [
{ storyId: REFERENCE_STORY_ID, colorScheme: 'light', palette: 'default' },
{ storyId: REFERENCE_STORY_ID, colorScheme: 'dark', palette: 'default' },
{
storyId: REFERENCE_STORY_ID,
colorScheme: 'light',
palette: 'test-palette-1',
},
{
storyId: REFERENCE_STORY_ID,
colorScheme: 'dark',
palette: 'test-palette-1',
},
]);
});

test('a mixed catalog adds only the full palette story theme matrix', () => {
const storyIds = ['product-settings--memory', REFERENCE_STORY_ID, 'design-system--button'];
const jobs = catalogJobs(storyIndex(...storyIds), { themePalettes: THEME_PALETTES });

assert.equal(jobs.length, storyIds.length + THEME_PALETTES.length * 2 - 1);
assert.deepEqual(new Set(jobs.map((job) => job.storyId)), new Set(storyIds));
});

test('duplicate palette ids do not duplicate render jobs', () => {
const jobs = catalogJobs(storyIndex(REFERENCE_STORY_ID), {
themePalettes: ['default', 'onedark', 'default', 'onedark'],
});

assert.equal(jobs.length, 4);
});

test('catalog jobs require a non-empty palette inventory containing default', () => {
assert.throws(
() => catalogJobs(storyIndex('product-settings--memory'), { themePalettes: [] }),
/at least one theme palette/,
);
assert.throws(
() =>
catalogJobs(storyIndex('product-settings--memory'), {
themePalettes: ['onedark'],
}),
/must include default/,
);
});

test('story URLs encode the selected colour scheme and palette', () => {
const url = new URL(
storyUrl('http://127.0.0.1:6006', {
storyId: REFERENCE_STORY_ID,
colorScheme: 'dark',
palette: 'tokyo-night',
}),
);

assert.equal(url.pathname, '/iframe.html');
assert.equal(url.searchParams.get('id'), REFERENCE_STORY_ID);
assert.equal(url.searchParams.get('viewMode'), 'story');
assert.equal(url.searchParams.get('globals'), 'colorScheme:dark;palette:tokyo-night');
});