1 | import execa from 'execa';
|
2 | import fs from 'fs';
|
3 | import { confirm } from 'node-ask';
|
4 | import fetch from 'node-fetch';
|
5 | import kill from 'tree-kill';
|
6 |
|
7 | import getEnv from './lib/getEnv';
|
8 | import parseArgs from './lib/parseArgs';
|
9 | import startApp, { checkResponse } from './lib/startStorybook';
|
10 | import TestLogger from './lib/testLogger';
|
11 | import openTunnel from './lib/tunnel';
|
12 | import uploadFiles from './lib/uploadFiles';
|
13 | import { runAll, runBuild } from './main';
|
14 |
|
15 | let lastBuild;
|
16 | let mockBuildFeatures;
|
17 | beforeEach(() => {
|
18 | fetch.mockClear();
|
19 | mockBuildFeatures = {
|
20 | features: { uiTests: true, uiReview: true },
|
21 | wasLimited: false,
|
22 | };
|
23 | });
|
24 |
|
25 | jest.mock('execa');
|
26 |
|
27 | jest.mock('node-ask');
|
28 |
|
29 | jest.mock('node-fetch', () =>
|
30 | jest.fn(async (url, { body } = {}) => ({
|
31 | ok: true,
|
32 | json: async () => {
|
33 | const { query, variables } = JSON.parse(body);
|
34 |
|
35 |
|
36 | if (query.match('TesterCreateAppTokenMutation')) {
|
37 | return { data: { createAppToken: 'token' } };
|
38 | }
|
39 |
|
40 | if (query.match('TesterCreateBuildMutation')) {
|
41 | if (variables.isolatorUrl.startsWith('http://throw-an-error')) {
|
42 | throw new Error('fetch error');
|
43 | }
|
44 | lastBuild = variables;
|
45 | return {
|
46 | data: {
|
47 | createBuild: {
|
48 | number: 1,
|
49 | specCount: 1,
|
50 | componentCount: 1,
|
51 | webUrl: 'http://test.com',
|
52 | cachedUrl: 'https://5d67dc0374b2e300209c41e7-pfkaemtlit.chromatic.com/iframe.html',
|
53 | ...mockBuildFeatures,
|
54 | app: {
|
55 | account: {
|
56 | billingUrl: 'https://foo.bar',
|
57 | exceededThreshold: false,
|
58 | paymentRequired: false,
|
59 | },
|
60 | },
|
61 | snapshots: [
|
62 | {
|
63 | spec: { name: 'name', component: { displayName: 'component' } },
|
64 | parameters: { viewport: 320, viewportIsDefault: false },
|
65 | },
|
66 | ],
|
67 | },
|
68 | },
|
69 | };
|
70 | }
|
71 |
|
72 | if (query.match('TesterBuildQuery')) {
|
73 | return {
|
74 | data: {
|
75 | app: { build: { status: 'BUILD_PENDING', changeCount: 1 } },
|
76 | },
|
77 | };
|
78 | }
|
79 |
|
80 | if (query.match('TesterFirstCommittedAtQuery')) {
|
81 | return { data: { app: { firstBuild: { committedAt: null } } } };
|
82 | }
|
83 |
|
84 | if (query.match('TesterHasBuildsWithCommitsQuery')) {
|
85 | return { data: { app: { hasBuildsWithCommits: [] } } };
|
86 | }
|
87 |
|
88 | if (query.match('TesterGetUploadUrlsMutation')) {
|
89 | return {
|
90 | data: {
|
91 | getUploadUrls: {
|
92 | domain: 'https://chromatic.com',
|
93 | urls: [
|
94 | {
|
95 | path: 'iframe.html',
|
96 | url: 'https://cdn.example.com/iframe.html',
|
97 | contentType: 'text/html',
|
98 | },
|
99 | {
|
100 | path: 'index.html',
|
101 | url: 'https://cdn.example.com/index.html',
|
102 | contentType: 'text/html',
|
103 | },
|
104 | ],
|
105 | },
|
106 | },
|
107 | };
|
108 | }
|
109 |
|
110 | throw new Error(`Unknown Query: ${query}`);
|
111 | },
|
112 | }))
|
113 | );
|
114 |
|
115 | jest.mock('tree-kill');
|
116 |
|
117 | jest.mock('fs-extra', () => ({
|
118 | pathExists: async () => true,
|
119 | readFileSync: jest.requireActual('fs-extra').readFileSync,
|
120 | }));
|
121 |
|
122 | fs.readdirSync = jest.fn(() => ['iframe.html', 'index.html']);
|
123 | const fsStatSync = fs.statSync;
|
124 | fs.statSync = jest.fn((path) => {
|
125 | if (path.endsWith('/package.json')) return fsStatSync(path);
|
126 | return { isDirectory: () => false, size: 42 };
|
127 | });
|
128 |
|
129 | jest.mock('./git/git', () => ({
|
130 | hasPreviousCommit: () => true,
|
131 | getCommit: () => ({
|
132 | commit: 'commit',
|
133 | committedAt: 1234,
|
134 | committerEmail: 'test@test.com',
|
135 | committerName: 'tester',
|
136 | }),
|
137 | getBranch: () => 'branch',
|
138 | getBaselineCommits: () => ['baseline'],
|
139 | getSlug: () => 'user/repo',
|
140 | getVersion: () => '2.24.1',
|
141 | }));
|
142 |
|
143 | jest.mock('./lib/startStorybook');
|
144 | jest.mock('./lib/getStorybookInfo', () => () => ({
|
145 | version: '5.1.0',
|
146 | viewLayer: 'viewLayer',
|
147 | addons: [],
|
148 | }));
|
149 | jest.mock('./lib/tunnel');
|
150 | jest.mock('./lib/uploadFiles');
|
151 |
|
152 | let processEnv;
|
153 | beforeEach(() => {
|
154 | processEnv = process.env;
|
155 | process.env = {
|
156 | DISABLE_LOGGING: true,
|
157 | CHROMATIC_APP_CODE: undefined,
|
158 | CHROMATIC_PROJECT_TOKEN: undefined,
|
159 | };
|
160 | execa.mockReset();
|
161 | });
|
162 | afterEach(() => {
|
163 | process.env = processEnv;
|
164 | });
|
165 |
|
166 | const getContext = (argv) => {
|
167 | const env = getEnv();
|
168 | const log = new TestLogger();
|
169 | const packageJson = {
|
170 | scripts: {
|
171 | storybook: 'start-storybook -p 1337',
|
172 | otherStorybook: 'start-storybook -p 7070',
|
173 | notStorybook: 'lint',
|
174 | 'build-storybook': 'build-storybook',
|
175 | otherBuildStorybook: 'build-storybook',
|
176 | },
|
177 | };
|
178 | const packagePath = '';
|
179 | return { env, log, sessionId: ':sessionId', packageJson, packagePath, ...parseArgs(argv) };
|
180 | };
|
181 |
|
182 | it('fails on missing project token', async () => {
|
183 | const ctx = getContext([]);
|
184 | ctx.env.CHROMATIC_PROJECT_TOKEN = '';
|
185 | await runBuild(ctx);
|
186 | expect(ctx.exitCode).toBe(254);
|
187 | expect(ctx.log.errors[0]).toMatch(/Missing project token/);
|
188 | });
|
189 |
|
190 | it('runs in simple situations', async () => {
|
191 | const ctx = getContext(['--project-token=asdf1234']);
|
192 | await runBuild(ctx);
|
193 |
|
194 | expect(ctx.exitCode).toBe(1);
|
195 | expect(lastBuild).toMatchObject({
|
196 | input: {
|
197 | branch: 'branch',
|
198 | commit: 'commit',
|
199 | committedAt: 1234,
|
200 | baselineCommits: ['baseline'],
|
201 | fromCI: false,
|
202 | isTravisPrBuild: false,
|
203 | packageVersion: expect.any(String),
|
204 | storybookVersion: '5.1.0',
|
205 | viewLayer: 'viewLayer',
|
206 | committerEmail: 'test@test.com',
|
207 | committerName: 'tester',
|
208 | },
|
209 | isolatorUrl: `https://chromatic.com/iframe.html`,
|
210 | });
|
211 | });
|
212 |
|
213 | it('returns 0 with exit-zero-on-changes', async () => {
|
214 | const ctx = getContext(['--project-token=asdf1234', '--exit-zero-on-changes']);
|
215 | await runBuild(ctx);
|
216 | expect(ctx.exitCode).toBe(0);
|
217 | });
|
218 |
|
219 | it('returns 0 with exit-once-uploaded', async () => {
|
220 | const ctx = getContext(['--project-token=asdf1234', '--exit-once-uploaded']);
|
221 | await runBuild(ctx);
|
222 | expect(ctx.exitCode).toBe(0);
|
223 | });
|
224 |
|
225 | it('returns 0 when the build is publish only', async () => {
|
226 | mockBuildFeatures = {
|
227 | features: { uiTests: false, uiReview: false },
|
228 | wasLimited: false,
|
229 | };
|
230 | const ctx = getContext(['--project-token=asdf1234']);
|
231 | await runBuild(ctx);
|
232 | expect(ctx.exitCode).toBe(0);
|
233 | });
|
234 |
|
235 | it('calls out to npm build script passed and uploads files', async () => {
|
236 | const ctx = getContext(['--project-token=asdf1234', '--build-script-name=build-storybook']);
|
237 | await runBuild(ctx);
|
238 | expect(ctx.exitCode).toBe(1);
|
239 | expect(uploadFiles).toHaveBeenCalledWith(
|
240 | expect.any(Object),
|
241 | [
|
242 | {
|
243 | contentLength: 42,
|
244 | contentType: 'text/html',
|
245 | path: expect.stringMatching(/\/iframe\.html$/),
|
246 | url: 'https://cdn.example.com/iframe.html',
|
247 | },
|
248 | {
|
249 | contentLength: 42,
|
250 | contentType: 'text/html',
|
251 | path: expect.stringMatching(/\/index\.html$/),
|
252 | url: 'https://cdn.example.com/index.html',
|
253 | },
|
254 | ],
|
255 | expect.any(Function)
|
256 | );
|
257 | });
|
258 |
|
259 | it('skips building and uploads directly with storybook-build-dir', async () => {
|
260 | const ctx = getContext(['--project-token=asdf1234', '--storybook-build-dir=dirname']);
|
261 | await runBuild(ctx);
|
262 | expect(ctx.exitCode).toBe(1);
|
263 | expect(execa).not.toHaveBeenCalled();
|
264 | expect(uploadFiles).toHaveBeenCalledWith(
|
265 | expect.any(Object),
|
266 | [
|
267 | {
|
268 | contentLength: 42,
|
269 | contentType: 'text/html',
|
270 | path: expect.stringMatching(/\/iframe\.html$/),
|
271 | url: 'https://cdn.example.com/iframe.html',
|
272 | },
|
273 | {
|
274 | contentLength: 42,
|
275 | contentType: 'text/html',
|
276 | path: expect.stringMatching(/\/index\.html$/),
|
277 | url: 'https://cdn.example.com/index.html',
|
278 | },
|
279 | ],
|
280 | expect.any(Function)
|
281 | );
|
282 | });
|
283 |
|
284 | it('passes autoAcceptChanges to the index', async () => {
|
285 | const ctx = getContext(['--project-token=asdf1234', '--auto-accept-changes']);
|
286 | await runBuild(ctx);
|
287 | expect(lastBuild).toMatchObject({ input: { autoAcceptChanges: true } });
|
288 | });
|
289 |
|
290 | it('passes autoAcceptChanges to the index based on branch', async () => {
|
291 | await runBuild(getContext(['--project-token=asdf1234', '--auto-accept-changes=branch']));
|
292 | expect(lastBuild).toMatchObject({ input: { autoAcceptChanges: true } });
|
293 |
|
294 | await runBuild(getContext(['--project-token=asdf1234', '--auto-accept-changes=wrong-branch']));
|
295 | expect(lastBuild).toMatchObject({ input: { autoAcceptChanges: false } });
|
296 | });
|
297 |
|
298 | describe('tunneled build', () => {
|
299 | beforeEach(() => {
|
300 | startApp.mockReset().mockReturnValue({
|
301 | on: jest.fn(),
|
302 | stderr: { on: jest.fn(), resume: jest.fn() },
|
303 | stdout: { on: jest.fn(), resume: jest.fn() },
|
304 | });
|
305 | checkResponse.mockReset();
|
306 | openTunnel.mockReset().mockReturnValue({
|
307 | url: 'http://tunnel.com/?clientId=foo',
|
308 | cachedUrl: 'http://cached.tunnel.com?foo=bar#hash',
|
309 | close: jest.fn,
|
310 | });
|
311 | kill.mockReset().mockImplementation((pid, sig, cb) => cb());
|
312 | });
|
313 |
|
314 | it('properly deals with updating the isolatorUrl/cachedUrl in complex situations', async () => {
|
315 | const ctx = getContext(['--project-token=asdf1234', '--script-name=storybook']);
|
316 | await runBuild(ctx);
|
317 |
|
318 | expect(ctx.exitCode).toBe(1);
|
319 | expect(ctx.closeTunnel).toBeDefined();
|
320 | expect(lastBuild).toMatchObject({
|
321 | input: { cachedUrl: 'http://cached.tunnel.com/iframe.html?foo=bar' },
|
322 | isolatorUrl: `http://tunnel.com/?clientId=foo&path=${encodeURIComponent('/iframe.html')}`,
|
323 | });
|
324 | });
|
325 |
|
326 | it('calls out to npm script passed', async () => {
|
327 | const ctx = getContext(['--project-token=asdf1234', '--script-name=storybook']);
|
328 | await runBuild(ctx);
|
329 | expect(startApp).toHaveBeenCalledWith(
|
330 | expect.objectContaining({
|
331 | scriptName: 'storybook',
|
332 | url: 'http://localhost:1337/iframe.html',
|
333 | args: ['--', '--ci'],
|
334 | })
|
335 | );
|
336 | });
|
337 |
|
338 | it('calls out to the exec command passed', async () => {
|
339 | const ctx = getContext([
|
340 | '--project-token=asdf1234',
|
341 | '--exec=./run.sh',
|
342 | '--storybook-port=9001',
|
343 | ]);
|
344 | await runBuild(ctx);
|
345 | expect(startApp).toHaveBeenCalledWith(
|
346 | expect.objectContaining({
|
347 | commandName: './run.sh',
|
348 | url: 'http://localhost:9001/iframe.html',
|
349 | })
|
350 | );
|
351 | expect(openTunnel).toHaveBeenCalledWith(expect.objectContaining({ port: '9001' }));
|
352 | });
|
353 |
|
354 | it('skips start when already running', async () => {
|
355 | checkResponse.mockReturnValue(true);
|
356 | const ctx = getContext(['--project-token=asdf1234', '--script-name=storybook']);
|
357 | await runBuild(ctx);
|
358 | expect(startApp).not.toHaveBeenCalled();
|
359 | expect(openTunnel).toHaveBeenCalledWith(expect.objectContaining({ port: '1337' }));
|
360 | });
|
361 |
|
362 | it('fails when trying to use --do-not-start while not running', async () => {
|
363 | checkResponse.mockReturnValueOnce(false);
|
364 | const ctx = getContext([
|
365 | '--project-token=asdf1234',
|
366 | '--script-name=storybook',
|
367 | '--do-not-start',
|
368 | ]);
|
369 | await runBuild(ctx);
|
370 | expect(ctx.exitCode).toBe(255);
|
371 | expect(startApp).not.toHaveBeenCalled();
|
372 | });
|
373 |
|
374 | it('skips tunnel when using --storybook-url', async () => {
|
375 | checkResponse.mockReturnValue(true);
|
376 | const ctx = getContext([
|
377 | '--project-token=asdf1234',
|
378 | '--storybook-url=http://localhost:1337/iframe.html?foo=bar#hash',
|
379 | ]);
|
380 | await runBuild(ctx);
|
381 | expect(ctx.exitCode).toBe(1);
|
382 | expect(ctx.closeTunnel).toBeUndefined();
|
383 | expect(openTunnel).not.toHaveBeenCalled();
|
384 | expect(lastBuild).toMatchObject({
|
385 | isolatorUrl: 'http://localhost:1337/iframe.html?foo=bar#hash',
|
386 | });
|
387 | });
|
388 |
|
389 | it('stops the running Storybook if something goes wrong', async () => {
|
390 | openTunnel.mockImplementation(() => {
|
391 | throw new Error('tunnel error');
|
392 | });
|
393 | startApp.mockReturnValueOnce({ pid: 'childpid' });
|
394 | const ctx = getContext(['--project-token=asdf1234', '--script-name=storybook']);
|
395 | await runBuild(ctx);
|
396 | expect(ctx.exitCode).toBe(255);
|
397 | expect(ctx.log.errors[0]).toMatch('tunnel error');
|
398 | expect(kill).toHaveBeenCalledWith('childpid', 'SIGHUP', expect.any(Function));
|
399 | });
|
400 |
|
401 | it('stops the tunnel if something goes wrong', async () => {
|
402 | const close = jest.fn();
|
403 | openTunnel.mockReturnValueOnce({
|
404 | url: 'http://throw-an-error',
|
405 | cachedUrl: 'http://tunnel.com/',
|
406 | close,
|
407 | });
|
408 | const ctx = getContext(['--project-token=asdf1234', '--script-name=storybook']);
|
409 | await runBuild(ctx);
|
410 | expect(ctx.exitCode).toBe(255);
|
411 | expect(ctx.log.errors[0]).toMatch('fetch error');
|
412 | expect(close).toHaveBeenCalled();
|
413 | });
|
414 | });
|
415 |
|
416 | describe('in CI', () => {
|
417 | it('detects standard CI environments', async () => {
|
418 | process.env = { CI: 'true', DISABLE_LOGGING: 'true' };
|
419 | const ctx = getContext(['--project-token=asdf1234']);
|
420 | await runBuild(ctx);
|
421 | expect(ctx.exitCode).toBe(1);
|
422 | expect(lastBuild).toMatchObject({
|
423 | input: {
|
424 | fromCI: true,
|
425 | isTravisPrBuild: false,
|
426 | },
|
427 | });
|
428 | expect(ctx.options.interactive).toBe(false);
|
429 | });
|
430 |
|
431 | it('detects CI passed as option', async () => {
|
432 | process.env = { DISABLE_LOGGING: 'true' };
|
433 | const ctx = getContext(['--project-token=asdf1234', '--ci']);
|
434 | await runBuild(ctx);
|
435 | expect(ctx.exitCode).toBe(1);
|
436 | expect(lastBuild).toMatchObject({
|
437 | input: {
|
438 | fromCI: true,
|
439 | isTravisPrBuild: false,
|
440 | },
|
441 | });
|
442 | expect(ctx.options.interactive).toBe(false);
|
443 | });
|
444 |
|
445 | it('detects Netlify CI', async () => {
|
446 | process.env = { REPOSITORY_URL: 'foo', DISABLE_LOGGING: 'true' };
|
447 | const ctx = getContext(['--project-token=asdf1234']);
|
448 | await runBuild(ctx);
|
449 | expect(ctx.exitCode).toBe(1);
|
450 | expect(lastBuild).toMatchObject({
|
451 | input: {
|
452 | fromCI: true,
|
453 | isTravisPrBuild: false,
|
454 | },
|
455 | });
|
456 | expect(ctx.options.interactive).toBe(false);
|
457 | });
|
458 |
|
459 | it('detects Travis PR build, external', async () => {
|
460 | process.env = {
|
461 | CI: 'true',
|
462 | TRAVIS_EVENT_TYPE: 'pull_request',
|
463 | TRAVIS_PULL_REQUEST_SLUG: 'a',
|
464 | TRAVIS_REPO_SLUG: 'b',
|
465 | TRAVIS_PULL_REQUEST_SHA: 'travis-commit',
|
466 | TRAVIS_PULL_REQUEST_BRANCH: 'travis-branch',
|
467 | DISABLE_LOGGING: 'true',
|
468 | };
|
469 | const ctx = getContext(['--project-token=asdf1234']);
|
470 | await runBuild(ctx);
|
471 | expect(ctx.exitCode).toBe(1);
|
472 | expect(lastBuild).toMatchObject({
|
473 | input: {
|
474 | commit: 'travis-commit',
|
475 | branch: 'travis-branch',
|
476 | fromCI: true,
|
477 | isTravisPrBuild: true,
|
478 | },
|
479 | });
|
480 | expect(ctx.options.interactive).toBe(false);
|
481 | expect(ctx.log.warnings.length).toBe(0);
|
482 | });
|
483 |
|
484 | it('detects Travis PR build, internal', async () => {
|
485 | process.env = {
|
486 | CI: 'true',
|
487 | TRAVIS_EVENT_TYPE: 'pull_request',
|
488 | TRAVIS_PULL_REQUEST_SLUG: 'a',
|
489 | TRAVIS_REPO_SLUG: 'a',
|
490 | TRAVIS_PULL_REQUEST_SHA: 'travis-commit',
|
491 | TRAVIS_PULL_REQUEST_BRANCH: 'travis-branch',
|
492 | DISABLE_LOGGING: 'true',
|
493 | };
|
494 | const ctx = getContext(['--project-token=asdf1234']);
|
495 | await runBuild(ctx);
|
496 | expect(ctx.exitCode).toBe(1);
|
497 | expect(lastBuild).toMatchObject({
|
498 | input: {
|
499 | commit: 'travis-commit',
|
500 | branch: 'travis-branch',
|
501 | fromCI: true,
|
502 | isTravisPrBuild: true,
|
503 | },
|
504 | });
|
505 | expect(ctx.options.interactive).toBe(false);
|
506 | expect(ctx.log.warnings.length).toBe(1);
|
507 | expect(ctx.log.warnings[0]).toMatch(/Running on a Travis PR build from an internal branch/);
|
508 | });
|
509 | });
|
510 |
|
511 | it('checks for updates', async () => {
|
512 | const ctx = getContext(['--project-token=asdf1234']);
|
513 | ctx.pkg.version = '4.3.2';
|
514 | fetch.mockReturnValueOnce(
|
515 | Promise.resolve({ json: () => ({ 'dist-tags': { latest: '5.0.0' } }) })
|
516 | );
|
517 | await runAll(ctx);
|
518 | expect(ctx.exitCode).toBe(1);
|
519 | expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/chromatic');
|
520 | expect(ctx.log.warnings[0]).toMatch('Using outdated package');
|
521 | });
|
522 |
|
523 | it('prompts you to add a script to your package.json', async () => {
|
524 | process.stdout.isTTY = true;
|
525 | const ctx = getContext(['--project-token=asdf1234']);
|
526 | await runAll(ctx);
|
527 | expect(ctx.exitCode).toBe(1);
|
528 | expect(confirm).toHaveBeenCalled();
|
529 | });
|