xref: /expo/apps/test-suite/screens/TestScreen.js (revision cb0e1ce3)
1'use strict';
2
3import React from 'react';
4import { NativeModules, StyleSheet, Platform, ScrollView, Text, View } from 'react-native';
5import jasmineModule from 'jasmine-core/lib/jasmine-core/jasmine';
6import Immutable from 'immutable';
7
8const { ExponentTest } = NativeModules;
9
10export default class TestScreen extends React.Component {
11  constructor(props, context) {
12    super(props, context);
13    this.state = TestScreen.initialState;
14    this._results = '';
15    this._failures = '';
16    this._scrollViewRef = null;
17  }
18
19  componentDidMount() {
20    const { navigation } = this.props;
21    const selectedModules = navigation.getParam('selected');
22    this._runTests(selectedModules);
23    this._isMounted = true;
24  }
25
26  componentWillUnmount() {
27    this._isMounted = false;
28  }
29
30  static navigationOptions = {
31    title: 'Test Runner',
32  };
33
34  static initialState = {
35    portalChildShouldBeVisible: false,
36    state: Immutable.fromJS({
37      suites: [],
38      path: ['suites'], // Path to current 'children' List in state
39    }),
40    testPortal: null,
41    numFailed: 0,
42    done: false,
43  };
44
45  setPortalChild = testPortal => {
46    if (this._isMounted) return this.setState({ testPortal });
47  };
48
49  cleanupPortal = () => {
50    return new Promise(resolve => {
51      if (this._isMounted) this.setState({ testPortal: null }, resolve);
52    });
53  };
54
55  _runTests = async modules => {
56    // Reset results state
57    this.setState(TestScreen.initialState);
58
59    const { jasmineEnv, jasmine } = await this._setupJasmine();
60
61    await Promise.all(
62      modules.map(m =>
63        m.test(jasmine, {
64          setPortalChild: this.setPortalChild,
65          cleanupPortal: this.cleanupPortal,
66        })
67      )
68    );
69
70    jasmineEnv.execute();
71  };
72
73  async _setupJasmine() {
74    // Init
75    jasmineModule.DEFAULT_TIMEOUT_INTERVAL = 10000;
76    const jasmineCore = jasmineModule.core(jasmineModule);
77    const jasmineEnv = jasmineCore.getEnv();
78
79    // Add our custom reporters too
80    jasmineEnv.addReporter(this._jasmineSetStateReporter());
81    jasmineEnv.addReporter(this._jasmineConsoleReporter());
82
83    // Get the interface and make it support `async ` by default
84    const jasmine = jasmineModule.interface(jasmineCore, jasmineEnv);
85    const doneIfy = fn => async done => {
86      try {
87        await Promise.resolve(fn());
88        done();
89      } catch (e) {
90        done.fail(e);
91      }
92    };
93    const oldIt = jasmine.it;
94    jasmine.it = (desc, fn, t) => oldIt.apply(jasmine, [desc, doneIfy(fn), t]);
95    const oldXit = jasmine.xit;
96    jasmine.xit = (desc, fn, t) => oldXit.apply(jasmine, [desc, doneIfy(fn), t]);
97    const oldFit = jasmine.fit;
98    jasmine.fit = (desc, fn, t) => oldFit.apply(jasmine, [desc, doneIfy(fn), t]);
99
100    return {
101      jasmineCore,
102      jasmineEnv,
103      jasmine,
104    };
105  }
106
107  // A jasmine reporter that writes results to the console
108  _jasmineConsoleReporter() {
109    const failedSpecs = [];
110    const app = this;
111
112    return {
113      specDone(result) {
114        if (result.status === 'passed' || result.status === 'failed') {
115          // Open log group if failed
116          const grouping = result.status === 'passed' ? '---' : '+++';
117          if (ExponentTest && ExponentTest.log) {
118            ExponentTest.log(`${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.fullName}`);
119          }
120          const emoji = result.status === 'passed' ? ':green_heart:' : ':broken_heart:';
121          console.log(`${grouping} ${emoji} ${result.fullName}`);
122          this._results += `${grouping} ${result.fullName}\n`;
123
124          if (result.status === 'failed') {
125            this._failures += `${grouping} ${result.fullName}\n`;
126            result.failedExpectations.forEach(({ matcherName, message }) => {
127              if (ExponentTest && ExponentTest.log) {
128                ExponentTest.log(`${matcherName}: ${message}`);
129              }
130              console.log(`${matcherName}: ${message}`);
131              this._results += `${matcherName}: ${message}\n`;
132              this._failures += `${matcherName}: ${message}\n`;
133            });
134            failedSpecs.push(result);
135          }
136        }
137      },
138
139      jasmineStarted() {
140        console.log('--- tests started');
141      },
142
143      jasmineDone() {
144        console.log('--- tests done');
145        console.log('--- sending results to runner');
146        if (app._isMounted) {
147          app.setState({ done: true, numFailed: failedSpecs.length });
148        }
149        const result = {
150          magic: '[TEST-SUITE-END]', // NOTE: Runner/Run.js waits to see this
151          failed: failedSpecs.length,
152          results: this._results,
153        };
154        if (Platform.OS === 'web') {
155          // This log needs to be an object for puppeteer tests
156          console.log(result);
157        } else {
158          const jsonResult = JSON.stringify(result);
159          console.log(jsonResult);
160        }
161
162        if (ExponentTest) {
163          ExponentTest.completed(
164            JSON.stringify({
165              failed: failedSpecs.length,
166              failures: this._failures,
167              results: this._results,
168            })
169          );
170        }
171      },
172    };
173  }
174
175  // A jasmine reporter that writes results to this.state
176  _jasmineSetStateReporter() {
177    const app = this;
178    return {
179      suiteStarted(jasmineResult) {
180        if (app._isMounted) {
181          app.setState(({ state }) => ({
182            state: state
183              .updateIn(state.get('path'), children =>
184                children.push(
185                  Immutable.fromJS({
186                    result: jasmineResult,
187                    children: [],
188                    specs: [],
189                  })
190                )
191              )
192              .update('path', path => path.push(state.getIn(path).size, 'children')),
193          }));
194        }
195      },
196
197      suiteDone() {
198        if (app._isMounted) {
199          app.setState(({ state }) => ({
200            state: state
201              .updateIn(
202                state
203                  .get('path')
204                  .pop()
205                  .pop(),
206                children =>
207                  children.update(children.size - 1, child =>
208                    child.set('result', child.get('result'))
209                  )
210              )
211              .update('path', path => path.pop().pop()),
212          }));
213        }
214      },
215
216      specStarted(jasmineResult) {
217        if (app._isMounted) {
218          app.setState(({ state }) => ({
219            state: state.updateIn(
220              state
221                .get('path')
222                .pop()
223                .pop(),
224              children =>
225                children.update(children.size - 1, child =>
226                  child.update('specs', specs => specs.push(Immutable.fromJS(jasmineResult)))
227                )
228            ),
229          }));
230        }
231      },
232
233      specDone(jasmineResult) {
234        if (app.state.testPortal) {
235          console.warn(
236            `The test portal has not been cleaned up by \`${
237              jasmineResult.fullName
238            }\`. Call \`cleanupPortal\` before finishing the test.`
239          );
240        }
241        if (app._isMounted) {
242          app.setState(({ state }) => ({
243            state: state.updateIn(
244              state
245                .get('path')
246                .pop()
247                .pop(),
248              children =>
249                children.update(children.size - 1, child =>
250                  child.update('specs', specs =>
251                    specs.set(specs.size - 1, Immutable.fromJS(jasmineResult))
252                  )
253                )
254            ),
255          }));
256        }
257      },
258    };
259  }
260
261  _renderSpecResult = r => {
262    const status = r.get('status') || 'running';
263    return (
264      <View
265        key={r.get('id')}
266        style={{
267          paddingLeft: 10,
268          marginVertical: 3,
269          borderColor: {
270            running: '#ff0',
271            passed: '#0f0',
272            failed: '#f00',
273            disabled: '#888',
274          }[status],
275          borderLeftWidth: 3,
276        }}>
277        <Text style={{ fontSize: 16 }}>
278          {
279            {
280              running: '�� ',
281              passed: '�� ',
282              failed: '�� ',
283            }[status]
284          }
285          {r.get('description')} ({status})
286        </Text>
287        {r.get('failedExpectations').map((e, i) => (
288          <Text key={i}>{e.get('message')}</Text>
289        ))}
290      </View>
291    );
292  };
293
294  _renderSuiteResult = (r, depth) => {
295    const titleStyle =
296      depth === 0
297        ? { marginBottom: 8, fontSize: 16, fontWeight: 'bold' }
298        : { marginVertical: 8, fontSize: 16 };
299    const containerStyle =
300      depth === 0
301        ? {
302            paddingLeft: 16,
303            paddingVertical: 16,
304            borderBottomWidth: 1,
305            borderColor: '#ddd',
306          }
307        : { paddingLeft: 16 };
308    return (
309      <View key={r.get('result').get('id')} style={containerStyle}>
310        <Text style={titleStyle}>{r.get('result').get('description')}</Text>
311        {r.get('specs').map(this._renderSpecResult)}
312        {r.get('children').map(r => this._renderSuiteResult(r, depth + 1))}
313      </View>
314    );
315  };
316
317  _onScrollViewContentSizeChange = () => {
318    if (this._scrollViewRef) {
319      this._scrollViewRef.scrollToEnd();
320    }
321  };
322
323  _renderDoneText = () => {
324    if (this.state.done) {
325      return (
326        <Text style={styles.doneMessage}>
327          All done! {this.state.numFailed} {this.state.numFailed === 1 ? 'test' : 'tests'} failed.
328        </Text>
329      );
330    }
331  };
332
333  _renderPortal = () => {
334    const styles = {
335      top: 0,
336      left: 0,
337      right: 0,
338      bottom: 0,
339      position: 'absolute',
340      alignItems: 'center',
341      justifyContent: 'center',
342      backgroundColor: 'rgb(255, 255, 255)',
343      opacity: this.state.portalChildShouldBeVisible ? 0.5 : 0,
344    };
345
346    if (this.state.testPortal) {
347      return (
348        <View style={styles} pointerEvents="none">
349          {this.state.testPortal}
350        </View>
351      );
352    }
353  };
354
355  render() {
356    return (
357      <View style={styles.scrollViewContainer} testID="test_suite_container">
358        <ScrollView
359          style={styles.scrollView}
360          contentContainerStyle={styles.scrollViewContent}
361          ref={ref => (this._scrollViewRef = ref)}
362          onContentSizeChange={this._onScrollViewContentSizeChange}>
363          {this.state.state.get('suites').map(r => this._renderSuiteResult(r, 0))}
364          {this._renderDoneText()}
365        </ScrollView>
366        {this._renderPortal()}
367      </View>
368    );
369  }
370}
371
372const styles = StyleSheet.create({
373  scrollViewContainer: {
374    flex: 1,
375    alignItems: 'stretch',
376    justifyContent: 'center',
377  },
378  scrollViewContent: {
379    padding: 5,
380  },
381  scrollView: {
382    flex: 1,
383  },
384  doneMessage: {
385    textAlign: 'center',
386    fontWeight: 'bold',
387    fontSize: 20,
388  },
389});
390