UNPKG

16.2 kBJavaScriptView Raw
1import execa from 'execa';
2import fs from 'fs';
3import { confirm } from 'node-ask';
4import fetch from 'node-fetch';
5import kill from 'tree-kill';
6
7import getEnv from './lib/getEnv';
8import parseArgs from './lib/parseArgs';
9import startApp, { checkResponse } from './lib/startStorybook';
10import TestLogger from './lib/testLogger';
11import openTunnel from './lib/tunnel';
12import uploadFiles from './lib/uploadFiles';
13import { runAll, runBuild } from './main';
14
15let lastBuild;
16let mockBuildFeatures;
17beforeEach(() => {
18 fetch.mockClear();
19 mockBuildFeatures = {
20 features: { uiTests: true, uiReview: true },
21 wasLimited: false,
22 };
23});
24
25jest.mock('execa');
26
27jest.mock('node-ask');
28
29jest.mock('node-fetch', () =>
30 jest.fn(async (url, { body } = {}) => ({
31 ok: true,
32 json: async () => {
33 const { query, variables } = JSON.parse(body);
34
35 // Authenticate
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
115jest.mock('tree-kill');
116
117jest.mock('fs-extra', () => ({
118 pathExists: async () => true,
119 readFileSync: jest.requireActual('fs-extra').readFileSync,
120}));
121
122fs.readdirSync = jest.fn(() => ['iframe.html', 'index.html']);
123const fsStatSync = fs.statSync;
124fs.statSync = jest.fn((path) => {
125 if (path.endsWith('/package.json')) return fsStatSync(path); // for meow
126 return { isDirectory: () => false, size: 42 };
127});
128
129jest.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
143jest.mock('./lib/startStorybook');
144jest.mock('./lib/getStorybookInfo', () => () => ({
145 version: '5.1.0',
146 viewLayer: 'viewLayer',
147 addons: [],
148}));
149jest.mock('./lib/tunnel');
150jest.mock('./lib/uploadFiles');
151
152let processEnv;
153beforeEach(() => {
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});
162afterEach(() => {
163 process.env = processEnv;
164});
165
166const 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
182it('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
190it('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
213it('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
219it('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
225it('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
235it('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
259it('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
284it('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
290it('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
298describe('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
416describe('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
511it('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
523it('prompts you to add a script to your package.json', async () => {
524 process.stdout.isTTY = true; // enable interactive mode
525 const ctx = getContext(['--project-token=asdf1234']);
526 await runAll(ctx);
527 expect(ctx.exitCode).toBe(1);
528 expect(confirm).toHaveBeenCalled();
529});