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