creevey 0.10.0-beta.23 → 0.10.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/client/web/assets/{index-Bk1_hhr8.js → index-Bmw2S3ik.js} +83 -83
  2. package/dist/client/web/index.html +1 -1
  3. package/dist/creevey.js +1 -1
  4. package/dist/creevey.js.map +1 -1
  5. package/dist/server/config.d.ts +1 -1
  6. package/dist/server/config.js +8 -3
  7. package/dist/server/config.js.map +1 -1
  8. package/dist/server/connection.d.ts +4 -0
  9. package/dist/server/connection.js +35 -0
  10. package/dist/server/connection.js.map +1 -0
  11. package/dist/server/index.js +33 -1
  12. package/dist/server/index.js.map +1 -1
  13. package/dist/server/master/start.js +1 -1
  14. package/dist/server/master/start.js.map +1 -1
  15. package/dist/server/playwright/docker-file.js +2 -13
  16. package/dist/server/playwright/docker-file.js.map +1 -1
  17. package/dist/server/playwright/internal.d.ts +3 -2
  18. package/dist/server/playwright/internal.js +49 -32
  19. package/dist/server/playwright/internal.js.map +1 -1
  20. package/dist/server/testsFiles/parser.js +44 -2
  21. package/dist/server/testsFiles/parser.js.map +1 -1
  22. package/dist/server/utils.d.ts +17 -0
  23. package/dist/server/utils.js +53 -3
  24. package/dist/server/utils.js.map +1 -1
  25. package/dist/types.d.ts +17 -2
  26. package/dist/types.js.map +1 -1
  27. package/docs/config.md +3 -0
  28. package/package.json +4 -4
  29. package/src/creevey.ts +1 -1
  30. package/src/server/config.ts +7 -4
  31. package/src/server/connection.ts +32 -0
  32. package/src/server/index.ts +37 -2
  33. package/src/server/master/start.ts +1 -1
  34. package/src/server/playwright/docker-file.ts +2 -14
  35. package/src/server/playwright/internal.ts +48 -34
  36. package/src/server/testsFiles/parser.ts +51 -1
  37. package/src/server/utils.ts +59 -3
  38. package/src/types.ts +17 -2
@@ -14,9 +14,15 @@ import {
14
14
  } from '../../types';
15
15
  import { subscribeOn } from '../messages';
16
16
  import { appendIframePath, getAddresses, LOCALHOST_REGEXP, resolveStorybookUrl, storybookRootID } from '../webdriver';
17
- import { isShuttingDown, runSequence } from '../utils';
17
+ import { isShuttingDown, resolvePlaywrightBrowserType, runSequence } from '../utils';
18
18
  import { colors, logger } from '../logger';
19
- import { Args } from '@storybook/csf';
19
+ import type { Args } from '@storybook/csf';
20
+
21
+ const browsers = {
22
+ chromium,
23
+ firefox,
24
+ webkit,
25
+ };
20
26
 
21
27
  async function tryConnect(type: BrowserType, gridUrl: string): Promise<Browser | null> {
22
28
  let timeout: NodeJS.Timeout | null = null;
@@ -157,20 +163,12 @@ export class InternalBrowser {
157
163
  );
158
164
  }
159
165
 
160
- async loadStoriesFromBrowser(retry = false): Promise<StoriesRaw> {
161
- try {
162
- const stories = await this.#page.evaluate<StoriesRaw | undefined>(() => window.__CREEVEY_GET_STORIES__());
166
+ async loadStoriesFromBrowser(): Promise<StoriesRaw> {
167
+ const stories = await this.#page.evaluate<StoriesRaw | undefined>(() => window.__CREEVEY_GET_STORIES__());
163
168
 
164
- if (!stories) throw new Error("Can't get stories, it seems creevey or storybook API isn't available");
169
+ if (!stories) throw new Error("Can't get stories, it seems creevey or storybook API isn't available");
165
170
 
166
- return stories;
167
- } catch (error) {
168
- // TODO Check how other solutions with playwright get stories from storybook
169
- if (retry) throw error;
170
- await new Promise((resolve) => setTimeout(resolve, 1000));
171
- // NOTE: Try one more time because of dynamic nature of vite and storybook
172
- return this.loadStoriesFromBrowser(true);
173
- }
171
+ return stories;
174
172
  }
175
173
 
176
174
  static async getBrowser(
@@ -190,25 +188,13 @@ export class InternalBrowser {
190
188
 
191
189
  let browser: Browser | null = null;
192
190
 
193
- if (new URL(gridUrl).protocol === 'ws:') {
194
- switch (browserConfig.browserName) {
195
- case 'chromium':
196
- browser = await tryConnect(chromium, gridUrl);
197
- break;
198
- case 'firefox':
199
- browser = await tryConnect(firefox, gridUrl);
200
- break;
201
- case 'webkit':
202
- browser = await tryConnect(webkit, gridUrl);
203
- break;
204
-
205
- default:
206
- logger().error(
207
- `Unknown browser ${browserConfig.browserName}. Playwright supports browsers: chromium, firefox, webkit`,
208
- );
209
- }
191
+ const parsedUrl = new URL(gridUrl);
192
+ if (parsedUrl.protocol === 'ws:') {
193
+ browser = await tryConnect(browsers[resolvePlaywrightBrowserType(browserConfig.browserName)], gridUrl);
194
+ } else if (parsedUrl.protocol === 'creevey:') {
195
+ browser = await browsers[resolvePlaywrightBrowserType(browserConfig.browserName)].launch(playwrightOptions);
210
196
  } else {
211
- if (browserConfig.browserName != 'chrome') {
197
+ if (browserConfig.browserName !== 'chrome') {
212
198
  logger().error("Playwright's Selenium Grid feature supports only chrome browser");
213
199
  return null;
214
200
  }
@@ -223,7 +209,13 @@ export class InternalBrowser {
223
209
  return null;
224
210
  }
225
211
 
212
+ // TODO Record video
226
213
  const page = await browser.newPage();
214
+ // TODO Support tracing
215
+ // if (playwrightOptions?.trace) {
216
+ // const context = page.context();
217
+ // await context.tracing.start(playwrightOptions.trace);
218
+ // }
227
219
 
228
220
  // TODO Add debug output
229
221
 
@@ -278,6 +270,7 @@ export class InternalBrowser {
278
270
  [
279
271
  () => this.openStorybookPage(storybookUrl, resolveStorybookUrl),
280
272
  () => this.waitForStorybook(),
273
+ () => this.triggerViteReload(),
281
274
  () => this.updateStorybookGlobals(),
282
275
  () => this.resolveCreeveyHost(),
283
276
  () => this.updateBrowserGlobalVariables(),
@@ -304,7 +297,8 @@ export class InternalBrowser {
304
297
  await this.#page.goto(appendIframePath(resolvedUrl));
305
298
  } else {
306
299
  // TODO this.#page.setDefaultNavigationTimeout(10000);
307
- await resolveStorybookUrl(appendIframePath(storybookUrl), (url) => this.checkUrl(url));
300
+ const resolvedUrl = await resolveStorybookUrl(appendIframePath(storybookUrl), (url) => this.checkUrl(url));
301
+ await this.#page.goto(resolvedUrl);
308
302
  }
309
303
  } catch (error) {
310
304
  logger().error('Failed to resolve storybook URL', error instanceof Error ? error.message : '');
@@ -313,15 +307,18 @@ export class InternalBrowser {
313
307
  }
314
308
 
315
309
  private async checkUrl(url: string): Promise<boolean> {
310
+ const page = await this.#browser.newPage();
316
311
  try {
317
312
  logger().debug(`Opening ${chalk.magenta(url)} and checking the page source`);
318
- const response = await this.#page.goto(url, { waitUntil: 'commit' });
313
+ const response = await page.goto(url, { waitUntil: 'commit' });
319
314
  const source = await response?.text();
320
315
 
321
316
  logger().debug(`Checking ${chalk.cyan(`#${storybookRootID}`)} existence on ${chalk.magenta(url)}`);
322
317
  return source?.includes(`id="${storybookRootID}"`) ?? false;
323
318
  } catch {
324
319
  return false;
320
+ } finally {
321
+ await page.close();
325
322
  }
326
323
  }
327
324
 
@@ -340,6 +337,7 @@ export class InternalBrowser {
340
337
  do {
341
338
  try {
342
339
  // TODO Research a different way to ensure storybook is initiated
340
+ // TODO Maybe use `__STORYBOOK_PREVIEW__.extract()`
343
341
  wait = await this.#page.evaluate((SET_GLOBALS: string) => {
344
342
  if (typeof window.__STORYBOOK_ADDONS_CHANNEL__ == 'undefined') return true;
345
343
  if (window.__STORYBOOK_ADDONS_CHANNEL__.last(SET_GLOBALS) == undefined) return true;
@@ -348,6 +346,7 @@ export class InternalBrowser {
348
346
  } catch (e: unknown) {
349
347
  logger().debug('An error has been caught during the script:', e);
350
348
  }
349
+ if (wait) await new Promise((resolve) => setTimeout(resolve, 1000));
351
350
  } while (wait);
352
351
  return false;
353
352
  })(),
@@ -357,6 +356,18 @@ export class InternalBrowser {
357
356
  if (isTimeout) throw new Error('Failed to wait `setStories` event');
358
357
  }
359
358
 
359
+ private async triggerViteReload(): Promise<void> {
360
+ // NOTE: On the first load, Vite might try to optimize some dependencies and reload the page
361
+ // We need to trigger reload earlier to avoid unnecessary reloads further
362
+ try {
363
+ await this.#page.evaluate(async () => {
364
+ await window.__STORYBOOK_PREVIEW__.extract();
365
+ });
366
+ } catch {
367
+ await this.waitForStorybook();
368
+ }
369
+ }
370
+
360
371
  private async updateStorybookGlobals(): Promise<void> {
361
372
  if (!this.#storybookGlobals) return;
362
373
 
@@ -392,6 +403,7 @@ export class InternalBrowser {
392
403
  }
393
404
 
394
405
  private async updateBrowserGlobalVariables() {
406
+ logger().debug('Updating browser global variables');
395
407
  await this.#page.evaluate(
396
408
  ([workerId, creeveyHost, creeveyPort]) => {
397
409
  window.__CREEVEY_ENV__ = true;
@@ -406,10 +418,12 @@ export class InternalBrowser {
406
418
  private async resizeViewport(viewport?: { width: number; height: number }): Promise<void> {
407
419
  if (!viewport) return;
408
420
 
421
+ logger().debug('Resizing viewport to', viewport);
409
422
  await this.#page.setViewportSize(viewport);
410
423
  }
411
424
 
412
425
  private async resetMousePosition(): Promise<void> {
426
+ logger().debug('Resetting mouse position to (0, 0)');
413
427
  await this.#page.mouse.move(0, 0);
414
428
  }
415
429
  }
@@ -1,8 +1,58 @@
1
1
  import { pathToFileURL } from 'url';
2
- import { toId, storyNameFromExport } from '@storybook/csf';
3
2
  import { CreeveyStoryParams, CreeveyTestFunction } from '../../types.js';
4
3
  import { loadThroughTSX } from '../utils.js';
5
4
 
5
+ // NOTE: Copy-pasted from @storybook/csf
6
+ function toStartCaseStr(str: string) {
7
+ return str
8
+ .replace(/_/g, ' ')
9
+ .replace(/-/g, ' ')
10
+ .replace(/\./g, ' ')
11
+ .replace(/([^\n])([A-Z])([a-z])/g, (_, $1, $2, $3) => `${$1} ${$2}${$3}`)
12
+ .replace(/([a-z])([A-Z])/g, (_, $1, $2) => `${$1} ${$2}`)
13
+ .replace(/([a-z])([0-9])/gi, (_, $1, $2) => `${$1} ${$2}`)
14
+ .replace(/([0-9])([a-z])/gi, (_, $1, $2) => `${$1} ${$2}`)
15
+ .replace(/(\s|^)(\w)/g, (_, $1, $2: string) => `${$1}${$2.toUpperCase()}`)
16
+ .replace(/ +/g, ' ')
17
+ .trim();
18
+ }
19
+
20
+ /**
21
+ * Remove punctuation and illegal characters from a story ID.
22
+ *
23
+ * See https://gist.github.com/davidjrice/9d2af51100e41c6c4b4a
24
+ */
25
+ const sanitize = (string: string) => {
26
+ return (
27
+ string
28
+ .toLowerCase()
29
+ // eslint-disable-next-line no-useless-escape
30
+ .replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/gi, '-')
31
+ .replace(/-+/g, '-')
32
+ .replace(/^-+/, '')
33
+ .replace(/-+$/, '')
34
+ );
35
+ };
36
+
37
+ const sanitizeSafe = (string: string, part: string) => {
38
+ const sanitized = sanitize(string);
39
+ if (sanitized === '') {
40
+ throw new Error(`Invalid ${part} '${string}', must include alphanumeric characters`);
41
+ }
42
+ return sanitized;
43
+ };
44
+
45
+ /**
46
+ * Generate a storybook ID from a component/kind and story name.
47
+ */
48
+ const toId = (kind: string, name?: string) =>
49
+ `${sanitizeSafe(kind, 'kind')}${name ? `--${sanitizeSafe(name, 'name')}` : ''}`;
50
+
51
+ /**
52
+ * Transform a CSF named export into a readable story name
53
+ */
54
+ const storyNameFromExport = (key: string) => toStartCaseStr(key);
55
+
6
56
  export type CreeveyParamsByStoryId = Record<string, CreeveyStoryParams>;
7
57
 
8
58
  export default async function parse(files: string[]): Promise<CreeveyParamsByStoryId> {
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
- import { get } from 'https';
2
+ import https from 'https';
3
+ import http from 'http';
3
4
  import cluster from 'cluster';
4
5
  import { dirname } from 'path';
5
6
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -7,6 +8,7 @@ import { register as esmRegister } from 'tsx/esm/api';
7
8
  import { register as cjsRegister } from 'tsx/cjs/api';
8
9
  import { SkipOptions, SkipOption, isDefined, TestData, noop, ServerTest, Worker } from '../types.js';
9
10
  import { emitShutdownMessage, sendShutdownMessage } from './messages.js';
11
+ import assert from 'assert';
10
12
 
11
13
  const importMetaUrl = pathToFileURL(__filename).href;
12
14
 
@@ -14,6 +16,19 @@ export const isShuttingDown = { current: false };
14
16
 
15
17
  export const configExt = ['.js', '.mjs', '.ts', '.cjs', '.mts', '.cts'];
16
18
 
19
+ const browserTypes = {
20
+ chromium: 'chromium',
21
+ 'chromium-headless-shell': 'chromium',
22
+ chrome: 'chromium',
23
+ 'chrome-beta': 'chromium',
24
+ msedge: 'chromium',
25
+ 'msedge-beta': 'chromium',
26
+ 'msedge-dev': 'chromium',
27
+ 'bidi-chromium': 'chromium',
28
+ firefox: 'firefox',
29
+ webkit: 'webkit',
30
+ } as const;
31
+
17
32
  export const skipOptionKeys = ['in', 'kinds', 'stories', 'tests', 'reason'];
18
33
 
19
34
  function matchBy(pattern: string | string[] | RegExp | undefined, value: string): boolean {
@@ -106,6 +121,23 @@ export function gracefullyKill(worker: Worker): void {
106
121
  sendShutdownMessage(worker);
107
122
  }
108
123
 
124
+ export function shutdown(): void {
125
+ process.exit();
126
+ }
127
+
128
+ export function shutdownWithError(): void {
129
+ process.exit(1);
130
+ }
131
+
132
+ export function resolvePlaywrightBrowserType(browserName: string): (typeof browserTypes)[keyof typeof browserTypes] {
133
+ assert(
134
+ browserName in browserTypes,
135
+ new Error(`Failed to match browser name "${browserName}" to playwright browserType`),
136
+ );
137
+
138
+ return browserTypes[browserName as keyof typeof browserTypes];
139
+ }
140
+
109
141
  export async function getCreeveyCache(): Promise<string | undefined> {
110
142
  const { default: findCacheDir } = await import('find-cache-dir');
111
143
  return findCacheDir({ name: 'creevey', cwd: dirname(fileURLToPath(importMetaUrl)) });
@@ -141,11 +173,12 @@ export function testsToImages(tests: (TestData | undefined)[]): Set<string> {
141
173
 
142
174
  // https://tuhrig.de/how-to-know-you-are-inside-a-docker-container/
143
175
  export const isInsideDocker =
144
- fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf-8').includes('docker');
176
+ (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf-8').includes('docker')) ||
177
+ process.env.DOCKER === 'true';
145
178
 
146
179
  export const downloadBinary = (downloadUrl: string, destination: string): Promise<void> =>
147
180
  new Promise((resolve, reject) =>
148
- get(downloadUrl, (response) => {
181
+ https.get(downloadUrl, (response) => {
149
182
  if (response.statusCode == 302) {
150
183
  const { location } = response.headers;
151
184
  if (!location) {
@@ -214,3 +247,26 @@ export async function loadThroughTSX<T>(
214
247
 
215
248
  return result;
216
249
  }
250
+
251
+ export function waitOnUrl(url: string, timeout: number, delay: number) {
252
+ const startTime = Date.now();
253
+ return new Promise<void>((resolve, reject) => {
254
+ const interval = setInterval(() => {
255
+ http
256
+ .get(url, (response) => {
257
+ if (response.statusCode === 200) {
258
+ clearInterval(interval);
259
+ resolve();
260
+ }
261
+ })
262
+ .on('error', () => {
263
+ // Ignore HTTP errors
264
+ });
265
+
266
+ if (Date.now() - startTime > timeout) {
267
+ clearInterval(interval);
268
+ reject(new Error(`${url} didn't respond within ${timeout / 1000} seconds`));
269
+ }
270
+ }, delay);
271
+ });
272
+ }
package/src/types.ts CHANGED
@@ -4,7 +4,7 @@ import type Pixelmatch from 'pixelmatch';
4
4
  import type { ODiffOptions } from 'odiff-bin';
5
5
  import type { expect } from 'chai';
6
6
  import type EventEmitter from 'events';
7
- import { LaunchOptions } from 'playwright-core';
7
+ import type { LaunchOptions } from 'playwright-core';
8
8
  // import type { Browser } from 'playwright-core';
9
9
 
10
10
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -145,7 +145,14 @@ export interface BrowserConfigObject {
145
145
  [name: string]: unknown;
146
146
  };
147
147
 
148
- playwrightOptions?: Omit<LaunchOptions, 'logger'>;
148
+ playwrightOptions?: Omit<LaunchOptions, 'logger'> & {
149
+ trace?: {
150
+ screenshots?: boolean;
151
+ snapshots?: boolean;
152
+ sources?: boolean;
153
+ path: string;
154
+ };
155
+ };
149
156
  }
150
157
 
151
158
  export type StorybookGlobals = Record<string, unknown>;
@@ -200,6 +207,11 @@ export interface Config {
200
207
  * Url where storybook hosted on
201
208
  */
202
209
  resolveStorybookUrl?: () => Promise<string>;
210
+ /**
211
+ * Command to automatically start Storybook if it is not running.
212
+ * For example, `npm run storybook`, `yarn run storybook` etc.
213
+ */
214
+ storybookAutorunCmd?: string;
203
215
  /**
204
216
  * Absolute path to directory with reference images
205
217
  * @default path.join(process.cwd(), './images')
@@ -354,8 +366,11 @@ export interface Options {
354
366
  reportDir?: string;
355
367
  gridUrl?: string;
356
368
  storybookUrl?: string;
369
+ storybookAutorunCmd?: string;
370
+ saveReport: boolean;
357
371
  failFast?: boolean;
358
372
  odiff?: boolean;
373
+ noDocker?: boolean;
359
374
  }
360
375
 
361
376
  export type WorkerError = 'browser' | 'test' | 'unknown';