1import { vol } from 'memfs';
2
3import { asMock } from '../../../__tests__/asMock';
4import { NgrokInstance, NgrokResolver } from '../../doctor/ngrok/NgrokResolver';
5import { startAdbReverseAsync } from '../../platforms/android/adbReverse';
6import { AsyncNgrok } from '../AsyncNgrok';
7
8jest.mock('../../../log');
9jest.mock('../../../utils/delay', () => ({
10  delayAsync: jest.fn(async () => {}),
11  resolveWithTimeout: jest.fn(async (fn) => fn()),
12}));
13jest.mock('../../../api/settings');
14jest.mock('../../doctor/ngrok/NgrokResolver', () => {
15  const instance: NgrokInstance = {
16    getActiveProcess: jest.fn(),
17    connect: jest.fn(async () => 'http://localhost:3000'),
18    kill: jest.fn(),
19  };
20
21  return {
22    isNgrokClientError: jest.requireActual('../../doctor/ngrok/NgrokResolver').isNgrokClientError,
23    NgrokResolver: jest.fn(() => ({
24      resolveAsync: jest.fn(async () => instance),
25      get: jest.fn(async () => instance),
26    })),
27  };
28});
29jest.mock('../../platforms/android/adbReverse', () => ({
30  startAdbReverseAsync: jest.fn(async () => true),
31}));
32jest.mock('../../../utils/exit');
33
34function createNgrokInstance() {
35  const projectRoot = '/';
36  const port = 3000;
37  const ngrok = new AsyncNgrok(projectRoot, port);
38  ngrok.getActiveUrl = jest.fn(ngrok.getActiveUrl.bind(ngrok));
39  ngrok.stopAsync = jest.fn(ngrok.stopAsync.bind(ngrok));
40  return {
41    projectRoot,
42    port,
43    ngrok,
44  };
45}
46
47const originalEnv = process.env;
48
49afterAll(() => {
50  process.env = originalEnv;
51});
52
53beforeEach(() => {
54  vol.reset();
55});
56
57describe('getActiveUrl', () => {
58  it(`is loaded on start`, async () => {
59    const { ngrok } = createNgrokInstance();
60    expect(ngrok.getActiveUrl()).toBeNull();
61    await ngrok.startAsync();
62    expect(ngrok.getActiveUrl()).toEqual('http://localhost:3000');
63  });
64});
65
66describe('startAsync', () => {
67  beforeEach(() => {
68    delete process.env.EXPO_TUNNEL_SUBDOMAIN;
69  });
70  it(`fails if adb reverse doesn't work`, async () => {
71    const { ngrok } = createNgrokInstance();
72    asMock(startAdbReverseAsync).mockResolvedValueOnce(false);
73
74    await expect(ngrok.startAsync()).rejects.toThrow(/adb/);
75  });
76  it(`starts`, async () => {
77    const { ngrok } = createNgrokInstance();
78    expect(await ngrok._connectToNgrokAsync()).toEqual('http://localhost:3000');
79  });
80  it(`starts with custom subdomain`, async () => {
81    process.env.EXPO_TUNNEL_SUBDOMAIN = 'test';
82    const { ngrok } = createNgrokInstance();
83    expect(await ngrok._connectToNgrokAsync()).toEqual('http://localhost:3000');
84    const instance = await new NgrokResolver('/').resolveAsync();
85    expect(instance.connect).toBeCalledWith(expect.objectContaining({ subdomain: 'test' }));
86  });
87  it(`starts with any subdomain`, async () => {
88    process.env.EXPO_TUNNEL_SUBDOMAIN = '1';
89    const { ngrok } = createNgrokInstance();
90    expect(await ngrok._connectToNgrokAsync()).toEqual('http://localhost:3000');
91    const instance = await new NgrokResolver('/').resolveAsync();
92    expect(instance.connect).toBeCalledWith(
93      expect.objectContaining({ subdomain: expect.stringMatching(/.*-anonymous-3000$/) })
94    );
95  });
96
97  it(`retries three times`, async () => {
98    const { ngrok } = createNgrokInstance();
99
100    // Add a connect which always fails.
101    const connect = jest.fn(() => {
102      throw new Error('woops');
103    });
104    ngrok.resolver.resolveAsync = jest.fn(async () => ({ connect } as any));
105
106    await expect(
107      ngrok._connectToNgrokAsync({
108        // Lower the time out to speed up the test.
109        timeout: 10,
110      })
111    ).rejects.toThrow(/woops/);
112    // Runs the function three times.
113    expect(connect).toHaveBeenCalledTimes(3);
114  });
115  it(`fixes invalid URL error by changing the randomness`, async () => {
116    const { ngrok, projectRoot } = createNgrokInstance();
117    vol.fromJSON({}, projectRoot);
118
119    ngrok._resetProjectRandomnessAsync = jest.fn(ngrok._resetProjectRandomnessAsync.bind(ngrok));
120    // Add a connect which throws an invalid URL error, then works the second time.
121    const connect = jest
122      .fn()
123      .mockImplementationOnce(() => {
124        const err = new Error();
125        // @ts-expect-error
126        err.body = { msg: '...', error_code: 103 };
127
128        throw err;
129      })
130      .mockImplementationOnce(() => 'http://localhost:3000');
131    ngrok.resolver.resolveAsync = jest.fn(async () => ({ connect } as any));
132
133    await ngrok._connectToNgrokAsync();
134
135    // Once for the initial generation and once for the retry.
136    expect(ngrok._resetProjectRandomnessAsync).toHaveBeenCalledTimes(2);
137    expect(connect).toHaveBeenCalledTimes(2);
138  });
139});
140
141describe('_getProjectHostnameAsync', () => {
142  it(`generates a valid hostname`, async () => {
143    const { projectRoot, ngrok } = createNgrokInstance();
144    vol.fromJSON({}, projectRoot);
145
146    const hostname = await ngrok._getProjectHostnameAsync();
147    expect(hostname).toEqual(expect.stringMatching(/.*\.anonymous\.3000\.exp\.direct/));
148
149    // URL-safe
150    expect(encodeURIComponent(hostname)).toEqual(hostname);
151
152    // Works twice in a row...
153    expect(await ngrok._getProjectHostnameAsync()).toEqual(
154      expect.stringMatching(/.*\.anonymous\.3000\.exp\.direct/)
155    );
156
157    // randomness is persisted
158    expect(JSON.parse(vol.toJSON()['/.expo/settings.json']).urlRandomness).toBeDefined();
159  });
160});
161