creevey 0.10.0-beta.31 → 0.10.0-beta.33

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 (59) hide show
  1. package/README.md +19 -41
  2. package/dist/client/addon/withCreevey.js +1 -0
  3. package/dist/client/addon/withCreevey.js.map +1 -1
  4. package/dist/client/shared/components/ResultsPage.d.ts +1 -1
  5. package/dist/client/web/CreeveyApp.js +1 -0
  6. package/dist/client/web/CreeveyApp.js.map +1 -1
  7. package/dist/server/docker.d.ts +1 -1
  8. package/dist/server/docker.js +21 -14
  9. package/dist/server/docker.js.map +1 -1
  10. package/dist/server/index.js +9 -10
  11. package/dist/server/index.js.map +1 -1
  12. package/dist/server/playwright/docker-file.d.ts +1 -2
  13. package/dist/server/playwright/docker-file.js +10 -4
  14. package/dist/server/playwright/docker-file.js.map +1 -1
  15. package/dist/server/playwright/docker.d.ts +2 -1
  16. package/dist/server/playwright/docker.js +10 -2
  17. package/dist/server/playwright/docker.js.map +1 -1
  18. package/dist/server/playwright/internal.d.ts +3 -4
  19. package/dist/server/playwright/internal.js +48 -37
  20. package/dist/server/playwright/internal.js.map +1 -1
  21. package/dist/server/playwright/webdriver.js +4 -7
  22. package/dist/server/playwright/webdriver.js.map +1 -1
  23. package/dist/server/selenium/internal.js +5 -12
  24. package/dist/server/selenium/internal.js.map +1 -1
  25. package/dist/server/selenium/webdriver.js +4 -8
  26. package/dist/server/selenium/webdriver.js.map +1 -1
  27. package/dist/server/telemetry.js +2 -2
  28. package/dist/server/utils.d.ts +1 -2
  29. package/dist/server/utils.js +11 -8
  30. package/dist/server/utils.js.map +1 -1
  31. package/dist/server/webdriver.d.ts +2 -0
  32. package/dist/server/webdriver.js +13 -1
  33. package/dist/server/webdriver.js.map +1 -1
  34. package/dist/server/worker/context.d.ts +3 -0
  35. package/dist/server/worker/context.js +15 -0
  36. package/dist/server/worker/context.js.map +1 -0
  37. package/dist/types.d.ts +0 -2
  38. package/dist/types.js.map +1 -1
  39. package/docs/cli.md +12 -0
  40. package/docs/config.md +178 -167
  41. package/docs/storybook.md +60 -0
  42. package/docs/tests.md +50 -45
  43. package/package.json +1 -1
  44. package/src/client/addon/withCreevey.ts +1 -0
  45. package/src/client/web/CreeveyApp.tsx +1 -0
  46. package/src/server/docker.ts +24 -13
  47. package/src/server/index.ts +11 -14
  48. package/src/server/playwright/docker-file.ts +12 -5
  49. package/src/server/playwright/docker.ts +16 -3
  50. package/src/server/playwright/index-source.mjs +16 -0
  51. package/src/server/playwright/internal.ts +78 -52
  52. package/src/server/playwright/webdriver.ts +4 -7
  53. package/src/server/selenium/internal.ts +5 -12
  54. package/src/server/selenium/webdriver.ts +4 -8
  55. package/src/server/telemetry.ts +2 -2
  56. package/src/server/utils.ts +33 -25
  57. package/src/server/webdriver.ts +13 -0
  58. package/src/server/worker/context.ts +14 -0
  59. package/src/types.ts +0 -2
@@ -0,0 +1,60 @@
1
+ ## Creevey Storybook Parameters
2
+
3
+ Creevey allows you to customize how stories will be captured. You could define parameters on `global`, `kind` or `story` levels. All these parameters are deeply merged by Storybook for each story.
4
+
5
+ ```ts
6
+ // .storybook/preview.tsx
7
+ export const parameters = {
8
+ creevey: {
9
+ // Global parameters are applied to all stories
10
+ captureElement: '#storybook-root',
11
+ },
12
+ };
13
+ ```
14
+
15
+ ```ts
16
+ // stories/MyModal.stories.tsx
17
+ import React from 'react';
18
+ import { Meta, StoryObj } from '@storybook/react';
19
+ import { CreeveyMeta, CreeveyStory } from 'creevey';
20
+ import MyModal from './src/components/MyModal';
21
+
22
+ const Kind: Meta<typeof MyModal> = {
23
+ title: 'MyModal',
24
+ component: MyModal,
25
+ parameters: {
26
+ creevey: {
27
+ // It's possible to add additional delay before capturing screenshot
28
+ delay: 1000,
29
+
30
+ // For capturing the whole browser viewport, you can define `null` instead of css selector.
31
+ captureElement: null,
32
+
33
+ // You can skip some stories from capturing, by defining `skip` option:
34
+ // skip: { "The reason why story is skipped": { in: 'chrome', stories: 'Loading' } }
35
+ // - `in` - browser name, regex or array of browser names, which are defined in the Creevey config
36
+ // - `stories` - story name, regex or array of story names
37
+ // - `tests` - test name, regex or array of test names
38
+ // NOTE: If you try to skip stories by story name, the storybook name format will be used
39
+ // For more info see [storybook-export-vs-name-handling](https://storybook.js.org/docs/formats/component-story-format/#storybook-export-vs-name-handling))
40
+ skip: {
41
+ "`MyModal` doesn't support ie11": { in: 'ie11' },
42
+ 'Loading stories are flaky in firefox': { in: 'firefox', stories: 'Loading' },
43
+ "`MyModal` hovering doesn't work correctly": {
44
+ in: ['firefox', 'chrome'],
45
+ tests: /.*hover$/,
46
+ },
47
+ },
48
+ },
49
+ },
50
+ };
51
+
52
+ export default Kind;
53
+
54
+ export const Basic: StoryObj<typeof MyModal> = {
55
+ creevey: {
56
+ // Lastly some elements can be ignored in capturing screenshot
57
+ ignoreElements: ['button', '.local-time'],
58
+ },
59
+ };
60
+ ```
package/docs/tests.md CHANGED
@@ -1,63 +1,68 @@
1
- ## Write tests
1
+ ## Write interactive screenshot tests
2
2
 
3
- By default Creevey generate for each story very simple screenshot test. In most cases it would be enough to test your UI. But you may want to do some interactions and capture one or multiple screenshots with different states of your story. For this case you could write custom tests, like this
3
+ In most cases following Storybook's ideology of [writing stories](https://storybook.js.org/docs/get-started/whats-a-story) is enough to test your UI components. Where each component has a separate stories file with its different states. But sometimes you might have pretty complicated components with a lot of interactions and internal states. In this case, you can write tests for your stories.
4
4
 
5
- ```tsx
6
- import React from 'react';
7
- import { Story } from '@storybook/react';
8
- import { CreeveyStory } from 'creevey';
9
- import MyComponent from './src/components/MyComponent';
5
+ There are two different ways how to write interactive tests with Creevey:
10
6
 
11
- export default { title: 'MyComponent' };
7
+ ### Write tests in `*.creevey.ts` files
12
8
 
13
- export const Basic: Story & CreeveyStory = () => <MyComponent />;
14
- Basic.parameters = {
15
- creevey: {
16
- captureElement: '#storybook-root',
17
- tests: {
18
- async click() {
19
- await this.browser.actions().click(this.captureElement).perform();
9
+ It's the recommended way to write tests. It allows you to run these tests by Creevey itself and utilize webdriver benefits. The crucial part of it is webdriver action calls are more close to real user interactions and mitigate flakiness and false-negative results. Here is a simple example of how to write tests in `*.creevey.ts` files
20
10
 
21
- await this.expect(await this.takeScreenshot()).to.matchImage('clicked component');
22
- },
23
- },
24
- },
25
- };
11
+ ```ts
12
+ // stories/MyComponent.creevey.ts
13
+ import { kind, story, test } from 'creevey';
14
+
15
+ kind('MyComponent', () => {
16
+ story('Story', ({ setStoryParameters }) => {
17
+ // It's possible to pass Creevey parameters to story
18
+ setStoryParameters({
19
+ captureElement: 'span[data-test-id~="x"]',
20
+ ignoreElements: [],
21
+ });
22
+
23
+ test('idle', async (context) => {
24
+ await context.matchImage(await context.takeScreenshot());
25
+ });
26
+
27
+ test('input', async (context) => {
28
+ await context.webdriver.keyboard.press('Tab');
29
+ const focus = await context.takeScreenshot();
30
+ await context.webdriver.keyboard.type('Hello Creevey');
31
+ const input = await context.takeScreenshot();
32
+ await context.matchImages({ focus, input });
33
+ });
34
+ });
35
+ });
26
36
  ```
27
37
 
28
- NOTE: Here you define story parameters with simple test `click`. Where you setup capturing element `#storybook-root` then click on that element and taking screenshot to assert it. `this.browser` allow you to access to native selenium webdriver instance you could check [API here](https://www.selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html).
38
+ In the example above, we used Playwright API to interact with the story. But Creevey also supports Selenium webdriver. And in that case `context.webdriver` will be an instance of Selenium webdriver. Obviously Selenium API is different from Playwright.
29
39
 
30
- You also could write more powerful tests with asserting multiple screenshots
40
+ ### Using Storybook's `play` function
31
41
 
32
- ```tsx
33
- import React from 'react';
34
- import { CSFStory } from 'creevey';
35
- import MyForm from './src/components/MyForm';
42
+ Storybook allows you to write tests in the story file itself by using [`play` function](https://storybook.js.org/docs/writing-tests/component-testing). It's a good way to write simple tests. But there are couple drawbacks of this approach:
36
43
 
37
- export default { title: 'MyForm' };
44
+ - You can have only one test per story. Which is not a big deal, but sometimes you might not want to have multiple stories with the same markup.
45
+ - Tests are running in browser environment and use https://testing-library.com API under the hood. It's good for unit tests, but might not be suitable for visual regression tests, because testing-library relies on DOM API and not even close to real user interactions. For example, you might have a button that could be visible for user, but it's covered by some other transparent element. With testing-library the button easily accessible and clickable, but the user can't interact with it.
38
46
 
39
- export const Basic: CSFStory<JSX.Element> = () => <MyForm />;
40
- Basic.story = {
41
- parameters: {
42
- creevey: {
43
- captureElement: '#storybook-root',
44
- delay: 1000,
45
- tests: {
46
- async submit() {
47
- const input = await this.browser.findElement({ css: '.my-input' });
47
+ Here is an example of how to write tests using Storybook's `play` function:
48
48
 
49
- const empty = await this.takeScreenshot();
49
+ ```tsx
50
+ // stories/MyComponent.stories.tsx
51
+ import React from 'react';
52
+ import { Meta, StoryObj } from '@storybook/react';
53
+ import { fireEvent, within } from '@storybook/test';
54
+ import MyComponent from './src/components/MyComponent';
50
55
 
51
- await this.browser.actions().click(input).sendKeys('Hello Creevey').sendKeys(this.keys.ENTER).perform();
56
+ export default {
57
+ title: 'MyComponent',
58
+ component: MyComponent,
59
+ };
52
60
 
53
- const submitted = await this.takeScreenshot();
61
+ export const Basic: StoryObj<typeof MyComponent> = {
62
+ play: async ({ canvasElement }) => {
63
+ const slider = await within(canvasElement).findByTestId('slider');
54
64
 
55
- await this.expect({ empty, submitted }).to.matchImages();
56
- },
57
- },
58
- },
65
+ await fireEvent.change(slider, { target: { value: 50 } });
59
66
  },
60
67
  };
61
68
  ```
62
-
63
- NOTE: In this example I fill some simple form and submit it. Also as you could see, I taking two different screenshots `empty` and `submitted` and assert these in the end.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "creevey",
3
3
  "description": "Cross-browser screenshot testing tool for Storybook with fancy UI Runner",
4
- "version": "0.10.0-beta.31",
4
+ "version": "0.10.0-beta.33",
5
5
  "type": "commonjs",
6
6
  "bin": "dist/cli.js",
7
7
  "main": "./dist/index.js",
@@ -322,6 +322,7 @@ export function withCreevey(): ReturnType<typeof makeDecorator> {
322
322
  });
323
323
  }
324
324
 
325
+ // TODO It's not accessible from the outside the package
325
326
  export async function capture(options?: CaptureOptions): Promise<void> {
326
327
  if (!isTestBrowser) return;
327
328
 
@@ -137,6 +137,7 @@ export function CreeveyApp({ api, initialState }: CreeveyAppProps): JSX.Element
137
137
  }, [handleImageApproveNew, handleGoToNextFailedTest]);
138
138
 
139
139
  const handleApproveAll = useCallback(() => {
140
+ // TODO Update handled incorrectly
140
141
  api?.approveAll();
141
142
  }, [api]);
142
143
 
@@ -3,8 +3,8 @@ import Logger from 'loglevel';
3
3
  import { Writable } from 'stream';
4
4
  import Dockerode, { Container } from 'dockerode';
5
5
  import { DockerAuth } from '../types.js';
6
- import { subscribeOn } from './messages.js';
7
6
  import { logger } from './logger.js';
7
+ import { setWorkerContainer } from './worker/context.js';
8
8
 
9
9
  const docker = new Dockerode();
10
10
 
@@ -58,12 +58,13 @@ export async function pullImages(
58
58
  }
59
59
  }
60
60
 
61
- export async function buildImage(imageName: string, dockerfile: string): Promise<void> {
61
+ export async function buildImage(imageName: string, version: string, dockerfile: string): Promise<void> {
62
62
  const images = await docker.listImages({ filters: { label: [`creevey=${imageName}`] } });
63
63
 
64
- if (images.at(0)) {
64
+ const containers = await docker.listContainers({ all: true, filters: { label: [`creevey=${imageName}`] } });
65
+ if (containers.length > 0) {
65
66
  await Promise.all(
66
- (await docker.listContainers({ all: true, filters: { label: [`creevey=${imageName}`] } })).map(async (info) => {
67
+ containers.map(async (info) => {
67
68
  const container = docker.getContainer(info.Id);
68
69
  try {
69
70
  await container.remove({ force: true });
@@ -72,6 +73,23 @@ export async function buildImage(imageName: string, dockerfile: string): Promise
72
73
  }
73
74
  }),
74
75
  );
76
+ }
77
+
78
+ const oldImages = images.filter((info) => info.Labels.version !== version);
79
+ if (oldImages.length > 0) {
80
+ await Promise.all(
81
+ oldImages.map(async (info) => {
82
+ const image = docker.getImage(info.Id);
83
+ try {
84
+ await image.remove({ force: true });
85
+ } catch {
86
+ /* noop */
87
+ }
88
+ }),
89
+ );
90
+ }
91
+
92
+ if (oldImages.length !== images.length) {
75
93
  logger().info(`Image ${imageName} already exists`);
76
94
  return;
77
95
  }
@@ -91,7 +109,7 @@ export async function buildImage(imageName: string, dockerfile: string): Promise
91
109
  // @ts-expect-error Type incompatibility AsyncIterator and AsyncIterableIterator
92
110
  pack,
93
111
  // TODO Support buildkit decode grpc (version: '2')
94
- { t: imageName, labels: { creevey: imageName }, version: '1' },
112
+ { t: imageName, labels: { creevey: imageName, version }, version: '1' },
95
113
  (buildError: Error | null, stream) => {
96
114
  if (buildError || !stream) {
97
115
  // spinner.error(buildError?.message);
@@ -148,14 +166,7 @@ export async function runImage(
148
166
 
149
167
  return new Promise((resolve) => {
150
168
  hub.once('container', (container: Container) => {
151
- // eslint-disable-next-line @typescript-eslint/no-misused-promises
152
- subscribeOn('shutdown', async () => {
153
- try {
154
- await container.remove({ force: true });
155
- } catch {
156
- /* noop */
157
- }
158
- });
169
+ setWorkerContainer(container);
159
170
  });
160
171
  hub.once(
161
172
  'start',
@@ -6,13 +6,13 @@ import { resolveCommand } from 'package-manager-detector/commands';
6
6
  import { readConfig, defaultBrowser } from './config.js';
7
7
  import { Options, Config, BrowserConfigObject, isWorkerMessage } from '../types.js';
8
8
  import { logger } from './logger.js';
9
+ import { getStorybookUrl, checkIsStorybookConnected } from './connection.js';
9
10
  import { SeleniumWebdriver } from './selenium/webdriver.js';
10
11
  import { LOCALHOST_REGEXP } from './webdriver.js';
11
12
  import { isInsideDocker, killTree, resolvePlaywrightBrowserType, shutdownWithError } from './utils.js';
12
13
  import { sendWorkerMessage, subscribeOn } from './messages.js';
13
14
  import { buildImage } from './docker.js';
14
15
  import { mkdir, writeFile } from 'fs/promises';
15
- import { getStorybookUrl, checkIsStorybookConnected } from './connection.js';
16
16
  import assert from 'assert';
17
17
 
18
18
  async function startWebdriverServer(browser: string, config: Config, options: Options): Promise<string | undefined> {
@@ -35,7 +35,7 @@ async function startWebdriverServer(browser: string, config: Config, options: Op
35
35
  if (cluster.isPrimary) return undefined;
36
36
 
37
37
  const { browserName } = config.browsers[browser] as BrowserConfigObject;
38
- return `creevey://${resolvePlaywrightBrowserType(browserName)}.playwright`;
38
+ return `creevey://${resolvePlaywrightBrowserType(browserName)}`;
39
39
  }
40
40
 
41
41
  const {
@@ -48,24 +48,21 @@ async function startWebdriverServer(browser: string, config: Config, options: Op
48
48
  const { browserName } = config.browsers[browser] as BrowserConfigObject;
49
49
 
50
50
  const imageName = `creevey/${browserName}:v${version}`;
51
- const host = await startPlaywrightContainer(imageName, options.debug);
51
+ const host = await startPlaywrightContainer(imageName, browser, config, options.debug);
52
52
 
53
53
  return host;
54
54
  } else {
55
55
  const { playwrightDockerFile } = await import('./playwright/docker-file.js');
56
- const browsers = [
57
- ...new Set(
58
- Object.values(config.browsers).map(
59
- (c) => [(c as BrowserConfigObject).browserName, (c as BrowserConfigObject).playwrightOptions] as const,
60
- ),
61
- ),
62
- ];
56
+ const {
57
+ default: { version: creeveyVersion },
58
+ } = await import('../../package.json', { with: { type: 'json' } });
59
+ const browsers = [...new Set(Object.values(config.browsers).map((c) => (c as BrowserConfigObject).browserName))];
63
60
  await Promise.all(
64
- browsers.map(async ([browserName, launchOptions]) => {
61
+ browsers.map(async (browserName) => {
65
62
  const imageName = `creevey/${browserName}:v${version}`;
66
- const dockerfile = playwrightDockerFile(browserName, version, launchOptions);
63
+ const dockerfile = await playwrightDockerFile(browserName, version);
67
64
 
68
- await buildImage(imageName, dockerfile);
65
+ await buildImage(imageName, creeveyVersion, dockerfile);
69
66
  }),
70
67
  );
71
68
 
@@ -107,7 +104,7 @@ export default async function (options: Options): Promise<void> {
107
104
  gridUrl = await startWebdriverServer(browser, config, options);
108
105
  }
109
106
 
110
- if (cluster.isPrimary) {
107
+ if (cluster.isPrimary && process.env.CI !== 'true') {
111
108
  const [localUrl, remoteUrl] = getStorybookUrl(config, options);
112
109
  const pm = getUserAgent();
113
110
  assert(pm, new Error('Failed to detect current package manager'));
@@ -1,10 +1,12 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { pathToFileURL } from 'url';
1
3
  import semver from 'semver';
2
4
  import { exec } from 'shelljs';
3
- import { LaunchOptions } from 'playwright-core';
4
- import { resolvePlaywrightBrowserType } from '../utils';
5
+
6
+ const importMetaUrl = pathToFileURL(__filename).href;
5
7
 
6
8
  // TODO Support custom docker images
7
- export function playwrightDockerFile(browser: string, version: string, serverOptions?: LaunchOptions): string {
9
+ export async function playwrightDockerFile(browser: string, version: string): Promise<string> {
8
10
  const sv = semver.coerce(version);
9
11
 
10
12
  let npmRegistry;
@@ -14,14 +16,19 @@ export function playwrightDockerFile(browser: string, version: string, serverOpt
14
16
  /* noop */
15
17
  }
16
18
 
19
+ const indexJs = await readFile(new URL('./index-source.mjs', importMetaUrl), 'utf-8');
20
+
17
21
  return `
18
22
  FROM node:lts
19
23
 
20
24
  WORKDIR /creevey
21
25
 
22
26
  RUN echo "{ \\"type\\": \\"module\\" }" > package.json && \\
23
- echo "import { ${resolvePlaywrightBrowserType(browser)} as browser } from 'playwright-core';" >> index.js && \\
24
- echo "const ws = await browser.launchServer({ ...${JSON.stringify(serverOptions)}, port: 4444, wsPath: 'creevey' })" >> index.js && \\${
27
+ ${indexJs
28
+ .split('\n')
29
+ .map((line) => `echo "${line.replace(/"/g, '\\"')}" >> index.js && \\`)
30
+ .join('\n')}
31
+ ${
25
32
  npmRegistry
26
33
  ? `
27
34
  echo "registry=${npmRegistry}" > .npmrc && \\`
@@ -1,9 +1,17 @@
1
+ import assert from 'assert';
1
2
  import { runImage } from '../docker';
2
3
  import { emitWorkerMessage, subscribeOn } from '../messages';
3
- import { isInsideDocker } from '../utils';
4
+ import { getCreeveyCache, isInsideDocker, resolvePlaywrightBrowserType } from '../utils';
4
5
  import { LOCALHOST_REGEXP } from '../webdriver';
6
+ import type { BrowserConfigObject, Config } from '../../types';
5
7
 
6
- export async function startPlaywrightContainer(imageName: string, debug: boolean): Promise<string> {
8
+ export async function startPlaywrightContainer(
9
+ imageName: string,
10
+ browser: string,
11
+ config: Config,
12
+ debug: boolean,
13
+ ): Promise<string> {
14
+ const { browserName, playwrightOptions } = config.browsers[browser] as BrowserConfigObject;
7
15
  const port = await new Promise<number>((resolve) => {
8
16
  subscribeOn('worker', (message) => {
9
17
  if (message.type == 'port') {
@@ -13,13 +21,18 @@ export async function startPlaywrightContainer(imageName: string, debug: boolean
13
21
  emitWorkerMessage({ type: 'port', payload: { port: -1 } });
14
22
  });
15
23
 
24
+ const cacheDir = await getCreeveyCache();
25
+
26
+ assert(cacheDir, "Couldn't get cache directory");
27
+
16
28
  const host = await runImage(
17
29
  imageName,
18
- [],
30
+ [JSON.stringify({ ...playwrightOptions, browser: resolvePlaywrightBrowserType(browserName) })],
19
31
  {
20
32
  ExposedPorts: { [`${port}/tcp`]: {} },
21
33
  HostConfig: {
22
34
  PortBindings: { ['4444/tcp']: [{ HostPort: `${port}` }] },
35
+ Binds: [`${cacheDir}/${process.pid}:/creevey/traces`],
23
36
  },
24
37
  },
25
38
  debug,
@@ -0,0 +1,16 @@
1
+ import { chromium, firefox, webkit } from 'playwright-core';
2
+
3
+ /** @type import("playwright-core").LaunchOptions & { browser: 'chromium' | 'firefox' | 'webkit' } */
4
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
5
+ const config = JSON.parse(process.argv.slice(2)[0]);
6
+
7
+ const browsers = { chromium, firefox, webkit };
8
+
9
+ const ws = await browsers[config.browser].launchServer({
10
+ ...config,
11
+ port: 4444,
12
+ wsPath: 'creevey',
13
+ tracesDir: 'traces',
14
+ });
15
+
16
+ console.log(config.browser, 'browser server launched on:', ws.wsEndpoint());