diff --git a/test/content/da-auth.test.js b/test/content/da-auth.test.js index e4fd640ac..dd7402869 100644 --- a/test/content/da-auth.test.js +++ b/test/content/da-auth.test.js @@ -19,12 +19,6 @@ import { makeLogger } from './content-test-utils.js'; const TEST_PROJECT_DIR = '/tmp/test-da-project'; -function waitForTimeout(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - describe('getValidToken', () => { it('returns override token immediately without reading stored token', async () => { let fseCalled = false; @@ -60,6 +54,36 @@ describe('getValidToken', () => { assert.strictEqual(result, 'stored-token'); }); + // Mock http so no real socket is bound and no real 5-minute timeout timer is + // started (the real waitForToken() callback server would otherwise leak an + // open handle/timer past the end of the test, delaying the whole suite's exit). + // When listen() is called, simulate the /token request firing immediately. + // Also stub ensureGitIgnored, since it's covered by its own dedicated test below + // and would otherwise try to write a real .gitignore under TEST_PROJECT_DIR. + const mockHttp = { + 'node:http': { + default: { + createServer: (reqHandler) => { + const mockServer = { + listen: () => { + setImmediate(() => { + const fakeReq = { url: '/token?access_token=test-token&expires_in=3600' }; + const fakeRes = { writeHead: () => {}, end: () => {} }; + reqHandler(fakeReq, fakeRes); + }); + }, + close: () => {}, + on: () => {}, + }; + return mockServer; + }, + }, + }, + '../../src/content/content-git.js': { + ensureGitIgnored: async () => {}, + }, + }; + it('logs expiry message and calls login when stored token is expired', async () => { const tokenData = { access_token: 'old-token', @@ -74,22 +98,13 @@ describe('getValidToken', () => { writeJson: async () => {}, }, open: async () => {}, + ...mockHttp, }); const log = makeLogger(); - try { - await Promise.race([ - getValidToken(log, undefined, TEST_PROJECT_DIR), - waitForTimeout(100).then(() => { throw new Error('timeout'); }), - ]); - } catch (err) { - const msgs = log.logs.map((l) => l.msg).join(' '); - const isExpectedError = err.message === 'timeout' - || err.message.includes('login') - || err.message.includes('EADDRINUSE') - || err.message.includes('callback'); - const hasExpiredLog = msgs.includes('expired') || msgs.includes('login') || msgs.includes('browser'); - assert.ok(isExpectedError || hasExpiredLog); - } + const result = await getValidToken(log, undefined, TEST_PROJECT_DIR); + assert.strictEqual(result, 'test-token'); + const msgs = log.logs.map((l) => l.msg).join(' '); + assert.ok(msgs.includes('expired')); }); it('proceeds to login when no stored token file exists', async () => { @@ -101,21 +116,11 @@ describe('getValidToken', () => { writeJson: async () => {}, }, open: async () => {}, + ...mockHttp, }); const log = makeLogger(); - try { - await Promise.race([ - getValidToken(log, undefined, TEST_PROJECT_DIR), - waitForTimeout(100).then(() => { throw new Error('timeout'); }), - ]); - } catch (err) { - assert.ok( - err.message === 'timeout' - || err.message.includes('login') - || err.message.includes('EADDRINUSE') - || err.message.includes('callback'), - ); - } + const result = await getValidToken(log, undefined, TEST_PROJECT_DIR); + assert.strictEqual(result, 'test-token'); }); it('treats stored token without expires_at as expired', async () => { @@ -129,21 +134,13 @@ describe('getValidToken', () => { writeJson: async () => {}, }, open: async () => {}, + ...mockHttp, }); const log = makeLogger(); - try { - await Promise.race([ - getValidToken(log, undefined, TEST_PROJECT_DIR), - waitForTimeout(100).then(() => { throw new Error('timeout'); }), - ]); - } catch (err) { - assert.ok( - err.message === 'timeout' - || err.message.includes('login') - || err.message.includes('EADDRINUSE') - || err.message.includes('callback'), - ); - } + const result = await getValidToken(log, undefined, TEST_PROJECT_DIR); + assert.strictEqual(result, 'test-token'); + const msgs = log.logs.map((l) => l.msg).join(' '); + assert.ok(msgs.includes('expired')); }); it('calls ensureGitIgnored with the token file path after login', async () => { @@ -211,12 +208,23 @@ describe('getValidToken', () => { describe('startDaLoginRedirect', () => { it('returns the fixed :9898 callback as redirect_uri, regardless of the return url\'s own port', async () => { - // Mock http so no real socket is bound (the fire-and-forget callback server - // this starts is never asked for a token in this test). + // Mock http so no real socket is bound, and simulate an immediate /token + // callback so the internal 5-minute timeout timer gets cleared right away + // instead of leaking a handle for 5 minutes past the end of the test. const { startDaLoginRedirect } = await esmock('../../src/content/da-auth.js', { 'node:http': { default: { - createServer: () => ({ listen: () => {}, close: () => {}, on: () => {} }), + createServer: (reqHandler) => ({ + listen: () => { + setImmediate(() => { + const fakeReq = { url: '/token?access_token=test-token&expires_in=3600' }; + const fakeRes = { writeHead: () => {}, end: () => {} }; + reqHandler(fakeReq, fakeRes); + }); + }, + close: () => {}, + on: () => {}, + }), }, }, }); diff --git a/test/import-cmd.test.js b/test/import-cmd.test.js index 3cebed5fa..ba70120f0 100644 --- a/test/import-cmd.test.js +++ b/test/import-cmd.test.js @@ -24,11 +24,18 @@ import { } from './utils.js'; import ImportCommand from '../src/import.cmd.js'; -const { fetch } = fetchContext({ rejectUnauthorized: false }); +const importFetchCtx = fetchContext({ rejectUnauthorized: false }); +const { fetch } = importFetchCtx; const TEST_DIR = path.resolve(__rootdir, 'test', 'fixtures', 'import'); const SAMPLE_HOST = 'http://www.sample.com'; +after(async () => { + // close the keep-alive sockets of the module-level fetch context so the + // test process doesn't hang after the suite finishes. + await importFetchCtx.reset(); +}); + describe('Integration test for import command', function suite() { let nock; diff --git a/test/server.test.js b/test/server.test.js index 19a850d75..31b5e9a3f 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -284,6 +284,9 @@ describe('Helix Server', () => { // create a http proxy server process.env.ALL_PROXY = 'http://127.0.0.1:8002'; const proxyRequests = []; + // create a single fetch context for the proxy so it can be reset (i.e. its + // sockets closed) once the test is done, instead of leaking one per request. + const proxyFetchCtx = h1NoCache(); const proxy = await new Promise((resolve) => { const p = http .createServer(async (req, res) => { @@ -291,7 +294,7 @@ describe('Helix Server', () => { // Delete accept header due to nock conflict delete req.headers.accept; console.log('http proxy request', req.url); - const resp = await h1NoCache().fetch(req.url, {}); + const resp = await proxyFetchCtx.fetch(req.url, {}); console.log('http proxy response for', req.url, resp.status); res.writeHead(resp.status, resp.headers.plain()); res.write(await resp.buffer()); @@ -336,6 +339,7 @@ describe('Helix Server', () => { assert.deepStrictEqual(proxyRequests, [`http://127.0.0.1:${project.server.port}/readme.html`]); } finally { proxy.close(); + await proxyFetchCtx.reset(); await project.stop(); delete process.env.ALL_PROXY; } diff --git a/test/setup-env.js b/test/setup-env.js index 21a004171..8d9c19796 100644 --- a/test/setup-env.js +++ b/test/setup-env.js @@ -11,6 +11,16 @@ */ import { resolve } from 'path'; import { fileURLToPath } from 'url'; +import { resetContext } from '../src/fetch-utils.js'; // eslint-disable-next-line no-underscore-dangle global.__rootdir = resolve(fileURLToPath(import.meta.url), '..', '..'); + +// eslint-disable-next-line import/prefer-default-export +export const mochaHooks = { + async afterAll() { + // ensure keep-alive fetch contexts (and their sockets) don't keep the + // process alive after the test suite has finished. + await resetContext(); + }, +};