1 | import 'mocha';
|
2 | import * as assert from 'assert';
|
3 | import * as sinon from 'sinon';
|
4 | import {run, setup, Sources, Sinks, Driver} from '../lib/cjs/index';
|
5 | import {setAdapt} from '../lib/adapt';
|
6 | import xs, {Stream} from 'xstream';
|
7 | import concat from 'xstream/extra/concat';
|
8 | import delay from 'xstream/extra/delay';
|
9 |
|
10 | let window: any;
|
11 | if (typeof global === 'object') {
|
12 | (global as any).window = {};
|
13 | window = (global as any).window;
|
14 | }
|
15 |
|
16 | describe('setup', function() {
|
17 | it('should be a function', function() {
|
18 | assert.strictEqual(typeof setup, 'function');
|
19 | });
|
20 |
|
21 | it('should throw if first argument is not a function', function() {
|
22 | assert.throws(() => {
|
23 | (setup as any)('not a function');
|
24 | }, /First argument given to Cycle must be the 'main' function/i);
|
25 | });
|
26 |
|
27 | it('should throw if second argument is not an object', function() {
|
28 | assert.throws(() => {
|
29 | (setup as any)(() => {}, 'not an object');
|
30 | }, /Second argument given to Cycle must be an object with driver functions/i);
|
31 | });
|
32 |
|
33 | it('should throw if second argument is an empty object', function() {
|
34 | assert.throws(() => {
|
35 | (setup as any)(() => {}, {});
|
36 | }, /Second argument given to Cycle must be an object with at least one/i);
|
37 | });
|
38 |
|
39 | it('should return sinks object and sources object', function() {
|
40 | function app(ext: any): any {
|
41 | return {
|
42 | other: ext.other.take(1).startWith('a'),
|
43 | };
|
44 | }
|
45 | function driver() {
|
46 | return xs.of('b');
|
47 | }
|
48 | let {sinks, sources} = setup(app, {other: driver});
|
49 | assert.strictEqual(typeof sinks, 'object');
|
50 | assert.strictEqual(typeof sinks.other.addListener, 'function');
|
51 | assert.strictEqual(typeof sources, 'object');
|
52 | assert.notStrictEqual(typeof sources.other, 'undefined');
|
53 | assert.notStrictEqual(sources.other, null);
|
54 | assert.strictEqual(typeof sources.other.addListener, 'function');
|
55 | });
|
56 |
|
57 | it('should type-check keyof sources and sinks in main and drivers', function() {
|
58 | type Sources = {
|
59 | str: Stream<string>;
|
60 | obj: Stream<object>;
|
61 | };
|
62 |
|
63 | function app(sources: Sources) {
|
64 | return {
|
65 | str: sources.str.take(1).startWith('a'),
|
66 |
|
67 |
|
68 |
|
69 | num: xs.of(100),
|
70 |
|
71 |
|
72 | };
|
73 | }
|
74 |
|
75 | const stringDriver: Driver<Stream<string>, Stream<string>> = (
|
76 | sink: Stream<string>,
|
77 | ) => xs.of('b');
|
78 |
|
79 | const numberWriteOnlyDriver: Driver<Stream<number>, void> = (
|
80 | sink: Stream<number>,
|
81 | ) => {};
|
82 |
|
83 | const objectReadOnlyDriver: Driver<void, Stream<object>> = () => xs.of({});
|
84 |
|
85 | setup(app, {
|
86 | str: stringDriver,
|
87 | num: numberWriteOnlyDriver,
|
88 | obj: objectReadOnlyDriver,
|
89 | });
|
90 | });
|
91 |
|
92 | it('should type-check keyof sources and sinks, supporting interfaces', function() {
|
93 | interface Sources {
|
94 | str: Stream<string>;
|
95 | obj: Stream<object>;
|
96 | }
|
97 |
|
98 | interface Sinks {
|
99 | str: Stream<string>;
|
100 | num: Stream<number>;
|
101 | }
|
102 |
|
103 | function app(sources: Sources): Sinks {
|
104 | return {
|
105 | str: sources.str.take(1).startWith('a'),
|
106 |
|
107 |
|
108 |
|
109 | num: xs.of(100),
|
110 |
|
111 |
|
112 | };
|
113 | }
|
114 |
|
115 | const stringDriver: Driver<Stream<string>, Stream<string>> = (
|
116 | sink: Stream<string>,
|
117 | ) => xs.of('b');
|
118 |
|
119 | const numberWriteOnlyDriver: Driver<Stream<number>, void> = (
|
120 | sink: Stream<number>,
|
121 | ) => {};
|
122 |
|
123 | const objectReadOnlyDriver: Driver<void, Stream<object>> = () => xs.of({});
|
124 |
|
125 | setup(app, {
|
126 | str: stringDriver,
|
127 | num: numberWriteOnlyDriver,
|
128 | obj: objectReadOnlyDriver,
|
129 | });
|
130 | });
|
131 |
|
132 | it('should type-check and allow more drivers than sinks', function() {
|
133 | type Sources = {
|
134 | str: Stream<string>;
|
135 | num: Stream<number>;
|
136 | obj: Stream<object>;
|
137 | };
|
138 |
|
139 | function app(sources: Sources) {
|
140 | return {};
|
141 | }
|
142 |
|
143 | function stringDriver(sink: Stream<string>) {
|
144 | return xs.of('b');
|
145 | }
|
146 |
|
147 | const numberDriver = (sink: Stream<number>) => xs.of(100);
|
148 |
|
149 | const objectReadOnlyDriver = () => xs.of({});
|
150 |
|
151 | setup(app, {
|
152 | str: stringDriver,
|
153 | num: numberDriver,
|
154 | obj: objectReadOnlyDriver,
|
155 | });
|
156 | });
|
157 |
|
158 | it('should call DevTool internal function to pass sinks', function() {
|
159 | let sandbox = sinon.sandbox.create();
|
160 | let spy = sandbox.spy();
|
161 | window['CyclejsDevTool_startGraphSerializer'] = spy;
|
162 |
|
163 | function app(ext: any): any {
|
164 | return {
|
165 | other: ext.other.take(1).startWith('a'),
|
166 | };
|
167 | }
|
168 | function driver() {
|
169 | return xs.of('b');
|
170 | }
|
171 | run(app, {other: driver});
|
172 |
|
173 | sinon.assert.calledOnce(spy);
|
174 | });
|
175 |
|
176 | it('should return a run() which in turn returns a dispose()', function(done) {
|
177 | type TestSources = {
|
178 | other: Stream<number>;
|
179 | };
|
180 |
|
181 | function app(sources: TestSources) {
|
182 | return {
|
183 | other: concat(
|
184 | sources.other
|
185 | .take(6)
|
186 | .map(x => String(x))
|
187 | .startWith('a'),
|
188 | xs.never(),
|
189 | ),
|
190 | };
|
191 | }
|
192 |
|
193 | function driver(sink: Stream<string>) {
|
194 | return sink.map(x => x.charCodeAt(0)).compose(delay(1));
|
195 | }
|
196 |
|
197 | const {sources, run} = setup(app, {other: driver});
|
198 |
|
199 | let dispose: any;
|
200 | sources.other.addListener({
|
201 | next: x => {
|
202 | assert.strictEqual(x, 97);
|
203 | dispose();
|
204 | },
|
205 | error: err => done(err),
|
206 | complete: () => done(),
|
207 | });
|
208 | dispose = run();
|
209 | });
|
210 |
|
211 | it('should support sink-only drivers', function(done) {
|
212 | function app(sources: any): any {
|
213 | return {
|
214 | other: xs.of(1, 2, 3),
|
215 | };
|
216 | }
|
217 |
|
218 | let driverCalled = false;
|
219 | function driver(sink: Stream<string>) {
|
220 | assert.strictEqual(typeof sink, 'object');
|
221 | assert.strictEqual(typeof sink.fold, 'function');
|
222 | driverCalled = true;
|
223 | }
|
224 |
|
225 | run(app, {other: driver});
|
226 |
|
227 | assert.strictEqual(driverCalled, true);
|
228 | done();
|
229 | });
|
230 |
|
231 | it('should not adapt() sinks', function(done) {
|
232 | function app(sources: any): any {
|
233 | return {
|
234 | other: xs.of(1, 2, 3),
|
235 | };
|
236 | }
|
237 |
|
238 | let driverCalled = false;
|
239 | function driver(sink: Stream<string>) {
|
240 | assert.strictEqual(typeof sink, 'object');
|
241 | assert.strictEqual(typeof sink.fold, 'function');
|
242 | driverCalled = true;
|
243 | return xs.of(10, 20, 30);
|
244 | }
|
245 |
|
246 | setAdapt(stream => 'this not a stream');
|
247 | run(app, {other: driver});
|
248 | setAdapt(x => x);
|
249 |
|
250 | assert.strictEqual(driverCalled, true);
|
251 | done();
|
252 | });
|
253 |
|
254 | it('should adapt() a simple source (stream)', function(done) {
|
255 | let appCalled = false;
|
256 | function app(sources: any): any {
|
257 | assert.strictEqual(typeof sources.other, 'string');
|
258 | assert.strictEqual(sources.other, 'this is adapted');
|
259 | appCalled = true;
|
260 |
|
261 | return {
|
262 | other: xs.of(1, 2, 3),
|
263 | };
|
264 | }
|
265 |
|
266 | function driver(sink: Stream<string>) {
|
267 | return xs.of(10, 20, 30);
|
268 | }
|
269 |
|
270 | setAdapt(stream => 'this is adapted');
|
271 | run(app, {other: driver});
|
272 | setAdapt(x => x);
|
273 |
|
274 | assert.strictEqual(appCalled, true);
|
275 | done();
|
276 | });
|
277 |
|
278 | it('should not work after has been disposed', function(done) {
|
279 | type MySources = {
|
280 | other: Stream<string>;
|
281 | };
|
282 |
|
283 | function app(sources: MySources) {
|
284 | return {other: xs.periodic(100).map(i => i + 1)};
|
285 | }
|
286 | function driver(num$: Stream<number>): Stream<string> {
|
287 | return num$.map(num => 'x' + num);
|
288 | }
|
289 |
|
290 | const {sources, run} = setup(app, {
|
291 | other: driver,
|
292 | });
|
293 |
|
294 | let dispose: any;
|
295 | sources.other.addListener({
|
296 | next: x => {
|
297 | assert.notStrictEqual(x, 'x3');
|
298 | if (x === 'x2') {
|
299 | dispose();
|
300 | }
|
301 | },
|
302 | error: err => done(err),
|
303 | complete: () => done(),
|
304 | });
|
305 | dispose = run();
|
306 | });
|
307 | });
|
308 |
|
309 | describe('run', function() {
|
310 | it('should be a function', function() {
|
311 | assert.strictEqual(typeof run, 'function');
|
312 | });
|
313 |
|
314 | it('should throw if first argument is not a function', function() {
|
315 | assert.throws(() => {
|
316 | (run as any)('not a function');
|
317 | }, /First argument given to Cycle must be the 'main' function/i);
|
318 | });
|
319 |
|
320 | it('should throw if second argument is not an object', function() {
|
321 | assert.throws(() => {
|
322 | (run as any)(() => {}, 'not an object');
|
323 | }, /Second argument given to Cycle must be an object with driver functions/i);
|
324 | });
|
325 |
|
326 | it('should throw if second argument is an empty object', function() {
|
327 | assert.throws(() => {
|
328 | (run as any)(() => {}, {});
|
329 | }, /Second argument given to Cycle must be an object with at least one/i);
|
330 | });
|
331 |
|
332 | it('should return a dispose function', function() {
|
333 | let sandbox = sinon.sandbox.create();
|
334 | const spy = sandbox.spy();
|
335 |
|
336 | type NiceSources = {
|
337 | other: Stream<string>;
|
338 | };
|
339 | type NiceSinks = {
|
340 | other: Stream<string>;
|
341 | };
|
342 |
|
343 | function app(sources: NiceSources): NiceSinks {
|
344 | return {
|
345 | other: sources.other.take(1).startWith('a'),
|
346 | };
|
347 | }
|
348 |
|
349 | function driver() {
|
350 | return xs.of('b').debug(spy);
|
351 | }
|
352 |
|
353 | let dispose = run(app, {other: driver});
|
354 | assert.strictEqual(typeof dispose, 'function');
|
355 | sinon.assert.calledOnce(spy);
|
356 | dispose();
|
357 | });
|
358 |
|
359 | it('should support driver that asynchronously subscribes to sink', function(
|
360 | done,
|
361 | ) {
|
362 | function app(sources: any): any {
|
363 | return {
|
364 | foo: xs.of(10),
|
365 | };
|
366 | }
|
367 |
|
368 | const expected = [10];
|
369 | function driver(sink: Stream<number>): Stream<any> {
|
370 | const buffer: Array<number> = [];
|
371 | sink.addListener({
|
372 | next: x => {
|
373 | buffer.push(x);
|
374 | },
|
375 | });
|
376 | setTimeout(() => {
|
377 | while (buffer.length > 0) {
|
378 | const x = buffer.shift();
|
379 | assert.strictEqual(x, expected.shift());
|
380 | }
|
381 | sink.subscribe({
|
382 | next(x) {
|
383 | assert.strictEqual(x, expected.shift());
|
384 | },
|
385 | error() {},
|
386 | complete() {},
|
387 | });
|
388 | });
|
389 | return xs.never();
|
390 | }
|
391 |
|
392 | run(app, {foo: driver});
|
393 |
|
394 | setTimeout(() => {
|
395 | assert.strictEqual(expected.length, 0);
|
396 | done();
|
397 | }, 100);
|
398 | });
|
399 |
|
400 | it('should forbid cross-driver synchronous races (#592)', function(done) {
|
401 | this.timeout(4000);
|
402 |
|
403 | function child(sources: any, num: number) {
|
404 | const vdom$ = sources.HTTP
|
405 |
|
406 |
|
407 | .map((res: any) => res.body.name)
|
408 | .map((name: string) => 'My name is ' + name);
|
409 |
|
410 | const request$ =
|
411 | num === 1
|
412 | ? xs.of({
|
413 | category: 'cat',
|
414 | url: 'http://jsonplaceholder.typicode.com/users/1',
|
415 | })
|
416 | : xs.never();
|
417 |
|
418 | return {
|
419 | HTTP: request$,
|
420 | DOM: vdom$,
|
421 | };
|
422 | }
|
423 |
|
424 | function mainHTTPThenDOM(sources: any) {
|
425 | const sinks$ = xs
|
426 | .periodic(100)
|
427 | .take(6)
|
428 | .map(i => {
|
429 | if (i % 2 === 1) {
|
430 | return child(sources, i);
|
431 | } else {
|
432 | return {
|
433 | HTTP: xs.empty(),
|
434 | DOM: xs.of(''),
|
435 | };
|
436 | }
|
437 | });
|
438 |
|
439 |
|
440 | return {
|
441 | HTTP: sinks$.map(sinks => sinks.HTTP).flatten(),
|
442 | DOM: sinks$.map(sinks => sinks.DOM).flatten(),
|
443 | };
|
444 | }
|
445 |
|
446 | function mainDOMThenHTTP(sources: any) {
|
447 | const sinks$ = xs
|
448 | .periodic(100)
|
449 | .take(6)
|
450 | .map(i => {
|
451 | if (i % 2 === 1) {
|
452 | return child(sources, i);
|
453 | } else {
|
454 | return {
|
455 | HTTP: xs.empty(),
|
456 | DOM: xs.of(''),
|
457 | };
|
458 | }
|
459 | });
|
460 |
|
461 |
|
462 | return {
|
463 | DOM: sinks$.map(sinks => sinks.DOM).flatten(),
|
464 | HTTP: sinks$.map(sinks => sinks.HTTP).flatten(),
|
465 | };
|
466 | }
|
467 |
|
468 | let requestsSent = 0;
|
469 | const expectedDOMSinks = [
|
470 | '',
|
471 | 'My name is Louis',
|
472 | '',
|
473 | '',
|
474 | '',
|
475 | 'My name is Louis',
|
476 | '',
|
477 | '',
|
478 | ];
|
479 |
|
480 | function domDriver(sink: Stream<string>) {
|
481 | sink.addListener({
|
482 | next: s => {
|
483 | assert.strictEqual(s, expectedDOMSinks.shift());
|
484 | },
|
485 | error: (err: any) => {},
|
486 | });
|
487 | }
|
488 |
|
489 | function httpDriver(sink: Stream<any>) {
|
490 | const source = sink.map(req => ({body: {name: 'Louis'}}));
|
491 | source.addListener({
|
492 | next: x => {},
|
493 | error: (err: any) => {},
|
494 | });
|
495 | return source.debug(x => {
|
496 | requestsSent += 1;
|
497 | });
|
498 | }
|
499 |
|
500 |
|
501 | const dispose = run(mainHTTPThenDOM, {
|
502 | HTTP: httpDriver,
|
503 | DOM: domDriver,
|
504 | });
|
505 | setTimeout(() => {
|
506 | assert.strictEqual(expectedDOMSinks.length, 4);
|
507 | assert.strictEqual(requestsSent, 1);
|
508 | dispose();
|
509 |
|
510 |
|
511 | run(mainDOMThenHTTP, {
|
512 | HTTP: httpDriver,
|
513 | DOM: domDriver,
|
514 | });
|
515 | setTimeout(() => {
|
516 | assert.strictEqual(expectedDOMSinks.length, 0);
|
517 | assert.strictEqual(requestsSent, 2);
|
518 | done();
|
519 | }, 1000);
|
520 | }, 1000);
|
521 | });
|
522 |
|
523 | it('should report errors from main() in the console', function(done) {
|
524 | const sandbox = sinon.sandbox.create();
|
525 | sandbox.stub(console, 'error');
|
526 |
|
527 | function main(sources: any): any {
|
528 | return {
|
529 | other: sources.other
|
530 | .take(1)
|
531 | .startWith('a')
|
532 | .map(() => {
|
533 | throw new Error('malfunction');
|
534 | }),
|
535 | };
|
536 | }
|
537 | function driver(sink: Stream<any>) {
|
538 | sink.addListener({
|
539 | next: () => {},
|
540 | error: (err: any) => {},
|
541 | });
|
542 | return xs.of('b');
|
543 | }
|
544 |
|
545 | let caught = false;
|
546 | try {
|
547 | run(main, {other: driver});
|
548 | } catch (e) {
|
549 | caught = true;
|
550 | }
|
551 | setTimeout(() => {
|
552 | sinon.assert.calledOnce(console.error as any);
|
553 | sinon.assert.calledWithExactly(
|
554 | console.error as any,
|
555 | sinon.match((err: any) => err.message === 'malfunction'),
|
556 | );
|
557 |
|
558 |
|
559 |
|
560 | assert.strictEqual(caught, false);
|
561 |
|
562 | sandbox.restore();
|
563 | done();
|
564 | }, 80);
|
565 | });
|
566 | });
|