xref: /expo/apps/test-suite/App.js (revision ef951f14)
1'use strict';
2
3import React from 'react';
4import { Dimensions, Linking, NativeModules, Platform, ScrollView, Text, View } from 'react-native';
5import { Constants } from 'expo';
6import jasmineModule from 'jasmine-core/lib/jasmine-core/jasmine';
7import Immutable from 'immutable';
8
9import * as TestUtils from './TestUtils';
10
11let { ExponentTest } = NativeModules;
12
13// List of all modules for tests. Each file path must be statically present for
14// the packager to pick them all up.
15async function getTestModulesAsync() {
16  // The tests don't complete on CircleCI on iOS so we test just that the app launches and runs
17  if (Platform.OS === 'ios') {
18    let isInCI = await TestUtils.shouldSkipTestsRequiringPermissionsAsync();
19    if (isInCI) {
20      return [];
21    }
22  }
23
24  if (Platform.OS === 'web') {
25    return [
26      require('./tests/Import1'),
27      require('./tests/Crypto'),
28      require('./tests/Random'),
29    ];
30  }
31
32  let modules = [
33    // require('./tests/Basic1'),
34    // require('./tests/Basic2'),
35    require('./tests/Import1'),
36    require('./tests/Import2'),
37    require('./tests/Import3'),
38    require('./tests/Asset'),
39    require('./tests/Audio'),
40    require('./tests/Calendar'),
41    require('./tests/Constants'),
42    require('./tests/Contacts'),
43    require('./tests/Crypto'),
44    require('./tests/FileSystem'),
45    require('./tests/GoogleSignIn'),
46    require('./tests/Haptics'),
47    require('./tests/Localization'),
48    require('./tests/Location'),
49    require('./tests/Linking'),
50    require('./tests/Recording'),
51    require('./tests/ScreenOrientation'),
52    require('./tests/SecureStore'),
53    require('./tests/Segment'),
54    require('./tests/Speech'),
55    require('./tests/SQLite'),
56    require('./tests/Random'),
57    require('./tests/Payments'),
58    require('./tests/AdMobInterstitial'),
59    require('./tests/AdMobBanner'),
60    require('./tests/AdMobPublisherBanner'),
61    require('./tests/AdMobRewarded'),
62    require('./tests/Video'),
63    require('./tests/Permissions'),
64    require('./tests/MediaLibrary'),
65    require('./tests/Notifications'),
66    require('./tests/FBNativeAd'),
67    require('./tests/FBBannerAd'),
68    require('./tests/TaskManager'),
69  ];
70  if (Constants.isDevice) {
71    modules = modules.concat([require('./tests/Brightness')]);
72    modules = modules.concat([require('./tests/BarCodeScanner')]);
73    if (Platform.OS === 'android') {
74      modules = modules.concat([require('./tests/JSC')]);
75      // The Camera tests are flaky on iOS, i.e. they fail randomly
76      modules = modules.concat([require('./tests/Camera')]);
77    }
78  }
79  return modules;
80}
81
82export default class App extends React.Component {
83  // --- Lifecycle -------------------------------------------------------------
84
85  constructor(props, context) {
86    super(props, context);
87    this.state = App.initialState;
88    this._results = '';
89    this._failures = '';
90    this._scrollViewRef = null;
91  }
92
93  componentDidMount() {
94    this._runTests(this.props.exp.initialUri);
95    Linking.addEventListener('url', ({ url }) => url && this._runTests(url));
96  }
97
98  // --- Test running ----------------------------------------------------------
99
100  static initialState = {
101    portalChildShouldBeVisible: false,
102    testRunnerError: null,
103    state: Immutable.fromJS({
104      suites: [],
105      path: ['suites'], // Path to current 'children' List in state
106    }),
107  };
108
109  setPortalChild = testPortal => this.setState({ testPortal });
110  cleanupPortal = () => new Promise(resolve => this.setState({ testPortal: null }, resolve));
111
112  async _runTests(uri) {
113    // If the URL contains two pluses let's keep the existing state instead of rerunning tests.
114    // This way we are able to test the Linking module.
115    if (uri && uri.indexOf('++') > -1) {
116      return;
117    }
118
119    // Reset results state
120    this.setState(App.initialState);
121
122    const { jasmineEnv, jasmine } = await this._setupJasmine();
123
124    // Load tests, confining to the ones named in the uri
125    let modules = await getTestModulesAsync();
126    if (uri && uri.indexOf('--/') > -1) {
127      const deepLink = uri.substring(uri.indexOf('--/') + 3);
128      const filterJSON = JSON.parse(deepLink);
129      if (filterJSON.includeModules) {
130        console.log('Only testing these modules: ' + JSON.stringify(filterJSON.includeModules));
131        const includeModulesRegexes = filterJSON.includeModules.map(m => new RegExp(m));
132        modules = modules.filter(m => {
133          for (let i = 0; i < includeModulesRegexes.length; i++) {
134            if (includeModulesRegexes[i].test(m.name)) {
135              return true;
136            }
137          }
138
139          return false;
140        });
141
142        if (modules.length === 0) {
143          this.setState({
144            testRunnerError: `No tests were found that satisfy ${deepLink}`,
145          });
146          return;
147        }
148      }
149    }
150
151    await Promise.all(
152      modules.map(m =>
153        m.test(jasmine, {
154          setPortalChild: this.setPortalChild,
155          cleanupPortal: this.cleanupPortal,
156        })
157      )
158    );
159
160    jasmineEnv.execute();
161  }
162
163  async _setupJasmine() {
164    // Init
165    jasmineModule.DEFAULT_TIMEOUT_INTERVAL = 10000;
166    const jasmineCore = jasmineModule.core(jasmineModule);
167    const jasmineEnv = jasmineCore.getEnv();
168
169    // Add our custom reporters too
170    jasmineEnv.addReporter(this._jasmineSetStateReporter());
171    jasmineEnv.addReporter(this._jasmineConsoleReporter());
172
173    // Get the interface and make it support `async ` by default
174    const jasmine = jasmineModule.interface(jasmineCore, jasmineEnv);
175    const doneIfy = fn => async done => {
176      try {
177        await Promise.resolve(fn());
178        done();
179      } catch (e) {
180        done.fail(e);
181      }
182    };
183    const oldIt = jasmine.it;
184    jasmine.it = (desc, fn, t) => oldIt.apply(jasmine, [desc, doneIfy(fn), t]);
185    const oldXit = jasmine.xit;
186    jasmine.xit = (desc, fn, t) => oldXit.apply(jasmine, [desc, doneIfy(fn), t]);
187    const oldFit = jasmine.fit;
188    jasmine.fit = (desc, fn, t) => oldFit.apply(jasmine, [desc, doneIfy(fn), t]);
189
190    return {
191      jasmineCore,
192      jasmineEnv,
193      jasmine,
194    };
195  }
196
197  // A jasmine reporter that writes results to the console
198  _jasmineConsoleReporter(jasmineEnv) {
199    const failedSpecs = [];
200
201    return {
202      specDone(result) {
203        if (result.status === 'passed' || result.status === 'failed') {
204          // Open log group if failed
205          const grouping = result.status === 'passed' ? '---' : '+++';
206          if (ExponentTest && ExponentTest.log) {
207            ExponentTest.log(`${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.fullName}`);
208          }
209          const emoji = result.status === 'passed' ? ':green_heart:' : ':broken_heart:';
210          console.log(`${grouping} ${emoji} ${result.fullName}`);
211          this._results += `${grouping} ${result.fullName}\n`;
212
213          if (result.status === 'failed') {
214            this._failures += `${grouping} ${result.fullName}\n`;
215            result.failedExpectations.forEach(({ matcherName, message }) => {
216              if (ExponentTest && ExponentTest.log) {
217                ExponentTest.log(`${matcherName}: ${message}`);
218              }
219              console.log(`${matcherName}: ${message}`);
220              this._results += `${matcherName}: ${message}\n`;
221              this._failures += `${matcherName}: ${message}\n`;
222            });
223            failedSpecs.push(result);
224          }
225        }
226      },
227
228      suiteDone(result) {},
229
230      jasmineStarted() {
231        console.log('--- tests started');
232      },
233
234      jasmineDone() {
235        console.log('--- tests done');
236        console.log('--- send results to runner');
237        const result = {
238          magic: '[TEST-SUITE-END]', // NOTE: Runner/Run.js waits to see this
239          failed: failedSpecs.length,
240          results: this._results,
241        };
242        if (Platform.OS === 'web') {
243          // This log needs to be an object for puppeteer tests
244          console.log(result);
245        } else {
246          const jsonResult = JSON.stringify(result);
247          console.log(jsonResult);
248        }
249
250        if (ExponentTest) {
251          // Native logs are truncated so log just the failures for now
252          ExponentTest.completed(
253            JSON.stringify({
254              failed: failedSpecs.length,
255              failures: this._failures,
256            })
257          );
258        }
259      },
260    };
261  }
262
263  // A jasmine reporter that writes results to this.state
264  _jasmineSetStateReporter(jasmineEnv) {
265    const app = this;
266    return {
267      suiteStarted(jasmineResult) {
268        app.setState(({ state }) => ({
269          state: state
270            .updateIn(state.get('path'), children =>
271              children.push(
272                Immutable.fromJS({
273                  result: jasmineResult,
274                  children: [],
275                  specs: [],
276                })
277              )
278            )
279            .update('path', path => path.push(state.getIn(path).size, 'children')),
280        }));
281      },
282
283      suiteDone(jasmineResult) {
284        app.setState(({ state }) => ({
285          state: state
286            .updateIn(
287              state
288                .get('path')
289                .pop()
290                .pop(),
291              children =>
292                children.update(children.size - 1, child =>
293                  child.set('result', child.get('result'))
294                )
295            )
296            .update('path', path => path.pop().pop()),
297        }));
298      },
299
300      specStarted(jasmineResult) {
301        app.setState(({ state }) => ({
302          state: state.updateIn(
303            state
304              .get('path')
305              .pop()
306              .pop(),
307            children =>
308              children.update(children.size - 1, child =>
309                child.update('specs', specs => specs.push(Immutable.fromJS(jasmineResult)))
310              )
311          ),
312        }));
313      },
314
315      specDone(jasmineResult) {
316        if (app.state.testPortal) {
317          console.warn(
318            `The test portal has not been cleaned up by \`${
319              jasmineResult.fullName
320            }\`. Call \`cleanupPortal\` before finishing the test.`
321          );
322        }
323
324        app.setState(({ state }) => ({
325          state: state.updateIn(
326            state
327              .get('path')
328              .pop()
329              .pop(),
330            children =>
331              children.update(children.size - 1, child =>
332                child.update('specs', specs =>
333                  specs.set(specs.size - 1, Immutable.fromJS(jasmineResult))
334                )
335              )
336          ),
337        }));
338      },
339    };
340  }
341
342  // --- Rendering -------------------------------------------------------------
343
344  _renderSpecResult = r => {
345    const status = r.get('status') || 'running';
346    return (
347      <View
348        key={r.get('id')}
349        style={{
350          paddingLeft: 10,
351          marginVertical: 3,
352          borderColor: {
353            running: '#ff0',
354            passed: '#0f0',
355            failed: '#f00',
356            disabled: '#888',
357          }[status],
358          borderLeftWidth: 3,
359        }}>
360        <Text style={{ fontSize: 16 }}>
361          {
362            {
363              running: '�� ',
364              passed: '�� ',
365              failed: '�� ',
366            }[status]
367          }
368          {r.get('description')} ({status})
369        </Text>
370        {r.get('failedExpectations').map((e, i) => (
371          <Text key={i}>{e.get('message')}</Text>
372        ))}
373      </View>
374    );
375  };
376  _renderSuiteResult = (r, depth) => {
377    const titleStyle =
378      depth == 0
379        ? { marginBottom: 8, fontSize: 16, fontWeight: 'bold' }
380        : { marginVertical: 8, fontSize: 16 };
381    const containerStyle =
382      depth == 0
383        ? {
384            paddingLeft: 16,
385            paddingVertical: 16,
386            borderBottomWidth: 1,
387            borderColor: '#ddd',
388          }
389        : { paddingLeft: 16 };
390    return (
391      <View key={r.get('result').get('id')} style={containerStyle}>
392        <Text style={titleStyle}>{r.get('result').get('description')}</Text>
393        {r.get('specs').map(this._renderSpecResult)}
394        {r.get('children').map(r => this._renderSuiteResult(r, depth + 1))}
395      </View>
396    );
397  };
398  _onScrollViewContentSizeChange = (contentWidth, contentHeight) => {
399    if (this._scrollViewRef) {
400      this._scrollViewRef.scrollTo({
401        y: Math.max(0, contentHeight - Dimensions.get('window').height) + Constants.statusBarHeight,
402      });
403    }
404  };
405  _renderPortal = () => {
406    const styles = {
407      top: 0,
408      left: 0,
409      right: 0,
410      bottom: 0,
411      position: 'absolute',
412      alignItems: 'center',
413      justifyContent: 'center',
414      backgroundColor: 'rgb(255, 255, 255)',
415      opacity: this.state.portalChildShouldBeVisible ? 0.5 : 0,
416    };
417
418    if (this.state.testPortal) {
419      return (
420        <View style={styles} pointerEvents="none">
421          {this.state.testPortal}
422        </View>
423      );
424    }
425  };
426
427  render() {
428    if (this.state.testRunnerError) {
429      return (
430        <View
431          style={{
432            flex: 1,
433            marginTop: Constants.statusBarHeight || 18,
434            alignItems: 'center',
435            justifyContent: 'center',
436          }}>
437          <Text style={{ color: 'red' }}>{this.state.testRunnerError}</Text>
438        </View>
439      );
440    }
441
442    return (
443      <View
444        style={{
445          flex: 1,
446          marginTop: Constants.statusBarHeight || 18,
447          alignItems: 'stretch',
448          justifyContent: 'center',
449        }}
450        testID="test_suite_container">
451        <ScrollView
452          style={{
453            flex: 1,
454          }}
455          contentContainerStyle={{
456            padding: 5,
457          }}
458          ref={ref => (this._scrollViewRef = ref)}
459          onContentSizeChange={this._onScrollViewContentSizeChange}>
460          {this.state.state.get('suites').map(r => this._renderSuiteResult(r, 0))}
461        </ScrollView>
462        {this._renderPortal()}
463      </View>
464    );
465  }
466}
467