server-webhooks-api.test.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import process from 'node:process';
  7. import { test } from 'node:test';
  8. import net from 'node:net';
  9. test('webhook API requires authentication', async () => {
  10. const { child, baseUrl } = await startTestServer();
  11. try {
  12. const list = await fetch(`${baseUrl}/api/webhooks`);
  13. assert.equal(list.status, 401);
  14. assert.equal((await list.json()).error, 'Authentication required.');
  15. const create = await fetch(`${baseUrl}/api/webhooks`, {
  16. method: 'POST',
  17. headers: { 'Content-Type': 'application/json' },
  18. body: JSON.stringify({
  19. name: 'No auth',
  20. url: 'http://127.0.0.1:9/hook',
  21. events: ['sent']
  22. })
  23. });
  24. assert.equal(create.status, 401);
  25. const deliveries = await fetch(`${baseUrl}/api/webhook-deliveries`);
  26. assert.equal(deliveries.status, 401);
  27. } finally {
  28. child.kill('SIGTERM');
  29. await waitForExit(child, 1000);
  30. }
  31. });
  32. test('mailbox webhook API confines email.received to its owned mailbox', async () => {
  33. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  34. try {
  35. seedUsers(dataDir, sessionSecret, [
  36. { username: 'mailbox-alice', email: 'mailbox-alice@example.com', password: 'password123', status: 'active' },
  37. { username: 'mailbox-bob', email: 'mailbox-bob@example.com', password: 'password123', status: 'active' }
  38. ]);
  39. const aliceCookie = await login(baseUrl, 'mailbox-alice', 'password123');
  40. const bobCookie = await login(baseUrl, 'mailbox-bob', 'password123');
  41. await createSendingDomain(baseUrl, aliceCookie, { domain: 'mailbox-hooks.example' });
  42. const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
  43. method: 'POST',
  44. headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
  45. body: JSON.stringify({ address: 'support@mailbox-hooks.example', password: 'mailbox-pass-123' })
  46. });
  47. assert.equal(createMailbox.status, 201);
  48. const mailbox = (await createMailbox.json()).mailbox;
  49. const createWebhook = await fetch(`${baseUrl}/api/webhooks`, {
  50. method: 'POST',
  51. headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
  52. body: JSON.stringify({
  53. name: 'Support arrival',
  54. url: 'http://127.0.0.1:9/receipt',
  55. events: ['received'],
  56. mailboxId: mailbox.id
  57. })
  58. });
  59. assert.equal(createWebhook.status, 201);
  60. const webhook = (await createWebhook.json()).webhook;
  61. assert.equal(webhook.mailboxId, mailbox.id);
  62. assert.equal(webhook.domainId, null);
  63. assert.deepEqual(webhook.events, ['received']);
  64. const filtered = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
  65. headers: { Cookie: aliceCookie }
  66. });
  67. assert.equal(filtered.status, 200);
  68. assert.equal((await filtered.json()).webhooks[0].id, webhook.id);
  69. const bobLookup = await fetch(`${baseUrl}/api/webhooks?mailboxId=${mailbox.id}`, {
  70. headers: { Cookie: bobCookie }
  71. });
  72. assert.equal(bobLookup.status, 400);
  73. const accountReceipt = await fetch(`${baseUrl}/api/webhooks`, {
  74. method: 'POST',
  75. headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
  76. body: JSON.stringify({
  77. name: 'Invalid account arrival',
  78. url: 'http://127.0.0.1:9/invalid-account',
  79. events: ['received']
  80. })
  81. });
  82. assert.equal(accountReceipt.status, 400);
  83. const mailboxSend = await fetch(`${baseUrl}/api/webhooks`, {
  84. method: 'POST',
  85. headers: { 'Content-Type': 'application/json', Cookie: aliceCookie },
  86. body: JSON.stringify({
  87. name: 'Invalid mailbox send',
  88. url: 'http://127.0.0.1:9/invalid-mailbox',
  89. events: ['sent'],
  90. mailboxId: mailbox.id
  91. })
  92. });
  93. assert.equal(mailboxSend.status, 400);
  94. } finally {
  95. child.kill('SIGTERM');
  96. await waitForExit(child, 1000);
  97. }
  98. });
  99. test('webhook API isolates users and returns secret only on create/rotate', async () => {
  100. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  101. try {
  102. seedUsers(dataDir, sessionSecret, [
  103. { username: 'alice', email: 'alice@example.com', password: 'password123', status: 'active' },
  104. { username: 'bob', email: 'bob@example.com', password: 'password123', status: 'active' }
  105. ]);
  106. const aliceCookie = await login(baseUrl, 'alice', 'password123');
  107. const bobCookie = await login(baseUrl, 'bob', 'password123');
  108. const aliceDomain = await createSendingDomain(baseUrl, aliceCookie, { domain: 'alice-hooks.example' });
  109. const bobDomain = await createSendingDomain(baseUrl, bobCookie, { domain: 'bob-hooks.example' });
  110. const create = await fetch(`${baseUrl}/api/webhooks`, {
  111. method: 'POST',
  112. headers: {
  113. 'Content-Type': 'application/json',
  114. Cookie: aliceCookie
  115. },
  116. body: JSON.stringify({
  117. name: 'Alice primary',
  118. url: 'http://127.0.0.1:9/alice',
  119. events: ['sent', 'failed'],
  120. enabled: true
  121. })
  122. });
  123. assert.equal(create.status, 201);
  124. const created = await create.json();
  125. assert.equal(created.webhook.name, 'Alice primary');
  126. assert.ok(created.webhook.secret);
  127. assert.match(created.webhook.secret, /^whsec_/);
  128. assert.equal(created.webhook.secretPrefix, created.webhook.secret.slice(0, 8));
  129. assert.deepEqual(created.webhook.events, ['sent', 'failed']);
  130. assert.equal(created.webhook.domainId, null);
  131. assert.equal(created.webhook.enabled, true);
  132. const aliceWebhookId = created.webhook.id;
  133. const firstSecret = created.webhook.secret;
  134. const bobCreate = await fetch(`${baseUrl}/api/webhooks`, {
  135. method: 'POST',
  136. headers: {
  137. 'Content-Type': 'application/json',
  138. Cookie: bobCookie
  139. },
  140. body: JSON.stringify({
  141. name: 'Bob primary',
  142. url: 'http://127.0.0.1:9/bob',
  143. events: ['bounced'],
  144. domainId: bobDomain.id
  145. })
  146. });
  147. assert.equal(bobCreate.status, 201);
  148. const bobWebhook = (await bobCreate.json()).webhook;
  149. assert.equal(bobWebhook.domainId, bobDomain.id);
  150. assert.ok(bobWebhook.secret);
  151. const aliceList = await fetch(`${baseUrl}/api/webhooks`, {
  152. headers: { Cookie: aliceCookie }
  153. });
  154. assert.equal(aliceList.status, 200);
  155. const aliceListBody = await aliceList.json();
  156. assert.equal(aliceListBody.webhooks.length, 1);
  157. assert.equal(aliceListBody.webhooks[0].id, aliceWebhookId);
  158. assert.equal('secret' in aliceListBody.webhooks[0], false);
  159. assert.equal(aliceListBody.webhooks[0].secretPrefix, firstSecret.slice(0, 8));
  160. const bobSeesAlice = await fetch(`${baseUrl}/api/webhooks`, {
  161. headers: { Cookie: bobCookie }
  162. });
  163. assert.equal(bobSeesAlice.status, 200);
  164. const bobList = await bobSeesAlice.json();
  165. assert.equal(bobList.webhooks.length, 1);
  166. assert.equal(bobList.webhooks[0].id, bobWebhook.id);
  167. assert.equal(bobList.webhooks[0].name, 'Bob primary');
  168. assert.equal('secret' in bobList.webhooks[0], false);
  169. const bobPatchAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  170. method: 'PATCH',
  171. headers: {
  172. 'Content-Type': 'application/json',
  173. Cookie: bobCookie
  174. },
  175. body: JSON.stringify({ name: 'Hijacked' })
  176. });
  177. assert.equal(bobPatchAlice.status, 404);
  178. const bobDeleteAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  179. method: 'DELETE',
  180. headers: { Cookie: bobCookie }
  181. });
  182. assert.equal(bobDeleteAlice.status, 404);
  183. const bobRotateAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
  184. method: 'POST',
  185. headers: { Cookie: bobCookie }
  186. });
  187. assert.equal(bobRotateAlice.status, 404);
  188. const bobTestAlice = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/test`, {
  189. method: 'POST',
  190. headers: { Cookie: bobCookie }
  191. });
  192. assert.equal(bobTestAlice.status, 404);
  193. const stealDomain = await fetch(`${baseUrl}/api/webhooks`, {
  194. method: 'POST',
  195. headers: {
  196. 'Content-Type': 'application/json',
  197. Cookie: aliceCookie
  198. },
  199. body: JSON.stringify({
  200. name: 'Steal bob domain',
  201. url: 'http://127.0.0.1:9/steal',
  202. events: ['sent'],
  203. domainId: bobDomain.id
  204. })
  205. });
  206. assert.equal(stealDomain.status, 400);
  207. assert.match((await stealDomain.json()).error, /域名/);
  208. const domainScoped = await fetch(`${baseUrl}/api/webhooks`, {
  209. method: 'POST',
  210. headers: {
  211. 'Content-Type': 'application/json',
  212. Cookie: aliceCookie
  213. },
  214. body: JSON.stringify({
  215. name: 'Alice domain',
  216. url: 'http://127.0.0.1:9/alice-domain',
  217. events: ['failed'],
  218. domainId: aliceDomain.id
  219. })
  220. });
  221. assert.equal(domainScoped.status, 201);
  222. assert.equal((await domainScoped.json()).webhook.domainId, aliceDomain.id);
  223. const filtered = await fetch(`${baseUrl}/api/webhooks?domainId=${aliceDomain.id}`, {
  224. headers: { Cookie: aliceCookie }
  225. });
  226. assert.equal(filtered.status, 200);
  227. const filteredBody = await filtered.json();
  228. assert.equal(filteredBody.webhooks.length, 1);
  229. assert.equal(filteredBody.webhooks[0].name, 'Alice domain');
  230. const accountOnly = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
  231. headers: { Cookie: aliceCookie }
  232. });
  233. assert.equal(accountOnly.status, 200);
  234. const accountOnlyBody = await accountOnly.json();
  235. assert.equal(accountOnlyBody.webhooks.length, 1);
  236. assert.equal(accountOnlyBody.webhooks[0].name, 'Alice primary');
  237. const patch = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}`, {
  238. method: 'PATCH',
  239. headers: {
  240. 'Content-Type': 'application/json',
  241. Cookie: aliceCookie
  242. },
  243. body: JSON.stringify({
  244. name: 'Alice renamed',
  245. events: ['sent'],
  246. enabled: false
  247. })
  248. });
  249. assert.equal(patch.status, 200);
  250. const patched = await patch.json();
  251. assert.equal(patched.webhook.name, 'Alice renamed');
  252. assert.deepEqual(patched.webhook.events, ['sent']);
  253. assert.equal(patched.webhook.enabled, false);
  254. assert.equal('secret' in patched.webhook, false);
  255. const rotate = await fetch(`${baseUrl}/api/webhooks/${aliceWebhookId}/rotate-secret`, {
  256. method: 'POST',
  257. headers: { Cookie: aliceCookie }
  258. });
  259. assert.equal(rotate.status, 200);
  260. const rotated = await rotate.json();
  261. assert.ok(rotated.webhook.secret);
  262. assert.notEqual(rotated.webhook.secret, firstSecret);
  263. assert.equal(rotated.webhook.secretPrefix, rotated.webhook.secret.slice(0, 8));
  264. const afterRotateList = await fetch(`${baseUrl}/api/webhooks?domainId=null`, {
  265. headers: { Cookie: aliceCookie }
  266. });
  267. assert.equal('secret' in (await afterRotateList.json()).webhooks[0], false);
  268. const invalidEvents = await fetch(`${baseUrl}/api/webhooks`, {
  269. method: 'POST',
  270. headers: {
  271. 'Content-Type': 'application/json',
  272. Cookie: aliceCookie
  273. },
  274. body: JSON.stringify({
  275. name: 'Bad events',
  276. url: 'http://127.0.0.1:9/bad',
  277. events: ['queued']
  278. })
  279. });
  280. assert.equal(invalidEvents.status, 400);
  281. const insecureUrl = await fetch(`${baseUrl}/api/webhooks`, {
  282. method: 'POST',
  283. headers: {
  284. 'Content-Type': 'application/json',
  285. Cookie: aliceCookie
  286. },
  287. body: JSON.stringify({
  288. name: 'Bad url',
  289. url: 'http://example.com/hook',
  290. events: ['sent']
  291. })
  292. });
  293. assert.equal(insecureUrl.status, 400);
  294. } finally {
  295. child.kill('SIGTERM');
  296. await waitForExit(child, 1000);
  297. }
  298. });
  299. test('webhook test and replay endpoints work', async () => {
  300. const { child, baseUrl } = await startTestServer();
  301. try {
  302. const cookie = await login(baseUrl, 'admin', 'password123');
  303. await createSendingDomain(baseUrl, cookie, { domain: 'webhook-test.example' });
  304. const create = await fetch(`${baseUrl}/api/webhooks`, {
  305. method: 'POST',
  306. headers: {
  307. 'Content-Type': 'application/json',
  308. Cookie: cookie
  309. },
  310. body: JSON.stringify({
  311. name: 'Test endpoint',
  312. url: 'http://127.0.0.1:9/test',
  313. events: ['sent', 'bounced']
  314. })
  315. });
  316. assert.equal(create.status, 201);
  317. const webhook = (await create.json()).webhook;
  318. const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  319. method: 'POST',
  320. headers: { Cookie: cookie }
  321. });
  322. assert.equal(testDelivery.status, 202);
  323. const testBody = await testDelivery.json();
  324. assert.ok(testBody.delivery);
  325. assert.equal(testBody.delivery.webhookId, webhook.id);
  326. assert.equal(testBody.delivery.sendEventId, 0);
  327. assert.equal(testBody.delivery.eventType, 'sent');
  328. assert.equal(testBody.delivery.status, 'pending');
  329. assert.equal(testBody.delivery.attemptCount, 0);
  330. const payload = JSON.parse(testBody.delivery.payloadJson);
  331. assert.equal(payload.data.test, true);
  332. assert.equal(payload.data.message_id, 'mh-test');
  333. assert.equal(payload.type, 'email.sent');
  334. const deliveryId = testBody.delivery.id;
  335. const listDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries?webhookId=${webhook.id}`, {
  336. headers: { Cookie: cookie }
  337. });
  338. assert.equal(listDeliveries.status, 200);
  339. const listed = await listDeliveries.json();
  340. assert.equal(listed.deliveries.length, 1);
  341. assert.equal(listed.deliveries[0].id, deliveryId);
  342. const replay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
  343. method: 'POST',
  344. headers: { Cookie: cookie }
  345. });
  346. assert.equal(replay.status, 200);
  347. const replayed = await replay.json();
  348. assert.equal(replayed.delivery.id, deliveryId);
  349. assert.equal(replayed.delivery.status, 'pending');
  350. assert.equal(replayed.delivery.attemptCount, 0);
  351. assert.equal(JSON.parse(replayed.delivery.payloadJson).id, `whd_${deliveryId}`);
  352. const retest = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  353. method: 'POST',
  354. headers: { Cookie: cookie }
  355. });
  356. assert.equal(retest.status, 202);
  357. const retested = await retest.json();
  358. assert.equal(retested.delivery.id, deliveryId);
  359. assert.equal(retested.delivery.status, 'pending');
  360. const allDeliveries = await fetch(`${baseUrl}/api/webhook-deliveries`, {
  361. headers: { Cookie: cookie }
  362. });
  363. assert.equal(allDeliveries.status, 200);
  364. assert.ok((await allDeliveries.json()).deliveries.length >= 1);
  365. } finally {
  366. child.kill('SIGTERM');
  367. await waitForExit(child, 1000);
  368. }
  369. });
  370. test('webhook delivery replay is isolated by user', async () => {
  371. const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
  372. try {
  373. seedUsers(dataDir, sessionSecret, [
  374. { username: 'carol', email: 'carol@example.com', password: 'password123', status: 'active' },
  375. { username: 'dave', email: 'dave@example.com', password: 'password123', status: 'active' }
  376. ]);
  377. const carolCookie = await login(baseUrl, 'carol', 'password123');
  378. const daveCookie = await login(baseUrl, 'dave', 'password123');
  379. const create = await fetch(`${baseUrl}/api/webhooks`, {
  380. method: 'POST',
  381. headers: {
  382. 'Content-Type': 'application/json',
  383. Cookie: carolCookie
  384. },
  385. body: JSON.stringify({
  386. name: 'Carol hook',
  387. url: 'http://127.0.0.1:9/carol',
  388. events: ['failed']
  389. })
  390. });
  391. assert.equal(create.status, 201);
  392. const webhook = (await create.json()).webhook;
  393. const testDelivery = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  394. method: 'POST',
  395. headers: { Cookie: carolCookie }
  396. });
  397. assert.equal(testDelivery.status, 202);
  398. const deliveryId = (await testDelivery.json()).delivery.id;
  399. const daveList = await fetch(`${baseUrl}/api/webhook-deliveries`, {
  400. headers: { Cookie: daveCookie }
  401. });
  402. assert.equal(daveList.status, 200);
  403. assert.equal((await daveList.json()).deliveries.length, 0);
  404. const daveReplay = await fetch(`${baseUrl}/api/webhook-deliveries/${deliveryId}/replay`, {
  405. method: 'POST',
  406. headers: { Cookie: daveCookie }
  407. });
  408. assert.equal(daveReplay.status, 404);
  409. const deleted = await fetch(`${baseUrl}/api/webhooks/${webhook.id}`, {
  410. method: 'DELETE',
  411. headers: { Cookie: carolCookie }
  412. });
  413. assert.equal(deleted.status, 200);
  414. assert.equal((await deleted.json()).deleted, true);
  415. const missing = await fetch(`${baseUrl}/api/webhooks/${webhook.id}/test`, {
  416. method: 'POST',
  417. headers: { Cookie: carolCookie }
  418. });
  419. assert.equal(missing.status, 404);
  420. } finally {
  421. child.kill('SIGTERM');
  422. await waitForExit(child, 1000);
  423. }
  424. });
  425. async function startTestServer() {
  426. const port = await freePort();
  427. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webhooks-api-'));
  428. const sessionSecret = 'test-session-secret-webhooks';
  429. const child = spawn(process.execPath, ['src/server.js'], {
  430. cwd: process.cwd(),
  431. env: {
  432. ...process.env,
  433. PORT: String(port),
  434. DATA_DIR: dataDir,
  435. ADMIN_PASSWORD: 'password123',
  436. SESSION_SECRET: sessionSecret,
  437. DNS_AUTO_CHECK_ENABLED: 'false',
  438. SUBMISSION_ENABLED: 'false',
  439. IMAP_ENABLED: 'false',
  440. POP3_ENABLED: 'false',
  441. WEBHOOK_WORKER_ENABLED: '0',
  442. WEBHOOK_ALLOW_HTTP_LOCAL: '1',
  443. DELIVERY_TRACKING_ENABLED: 'false'
  444. },
  445. stdio: ['ignore', 'pipe', 'pipe']
  446. });
  447. await waitForOutput(child, 'MailHub listening');
  448. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  449. }
  450. async function login(baseUrl, username, password) {
  451. const response = await fetch(`${baseUrl}/api/login`, {
  452. method: 'POST',
  453. headers: { 'Content-Type': 'application/json' },
  454. body: JSON.stringify({ username, password })
  455. });
  456. assert.equal(response.status, 200);
  457. const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
  458. assert.ok(cookie);
  459. return cookie;
  460. }
  461. function seedUsers(dataDir, sessionSecret, users) {
  462. const script = `
  463. import { initDatabase, createUser } from './src/db.js';
  464. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  465. for (const user of JSON.parse(process.env.SEED_USERS)) {
  466. createUser(user);
  467. }
  468. `;
  469. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  470. cwd: process.cwd(),
  471. env: {
  472. ...process.env,
  473. DATA_DIR: dataDir,
  474. SESSION_SECRET: sessionSecret,
  475. SEED_USERS: JSON.stringify(users)
  476. },
  477. encoding: 'utf8'
  478. });
  479. assert.equal(result.status, 0, result.stderr || result.stdout);
  480. }
  481. async function createSendingDomain(baseUrl, cookie, data = {}) {
  482. const domain = data.domain || 'send.example';
  483. const response = await fetch(`${baseUrl}/api/domains`, {
  484. method: 'POST',
  485. headers: {
  486. 'Content-Type': 'application/json',
  487. Cookie: cookie
  488. },
  489. body: JSON.stringify({
  490. domain,
  491. selector: data.selector || 'mh',
  492. senderHost: data.senderHost || `mail.${domain}`,
  493. sendingIp: data.sendingIp || '127.0.0.1'
  494. })
  495. });
  496. assert.equal(response.status, 201);
  497. return (await response.json()).domain;
  498. }
  499. function freePort() {
  500. return new Promise((resolve, reject) => {
  501. const server = net.createServer();
  502. server.listen(0, '127.0.0.1', () => {
  503. const address = server.address();
  504. server.close(() => {
  505. if (address && typeof address === 'object') resolve(address.port);
  506. else reject(new Error('Unable to allocate a test port.'));
  507. });
  508. });
  509. });
  510. }
  511. function waitForOutput(child, text) {
  512. return new Promise((resolve, reject) => {
  513. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
  514. const chunks = [];
  515. const onData = (chunk) => {
  516. chunks.push(String(chunk));
  517. if (chunks.join('').includes(text)) {
  518. clearTimeout(timeout);
  519. child.stdout.off('data', onData);
  520. child.stderr.off('data', onData);
  521. resolve();
  522. }
  523. };
  524. child.stdout.on('data', onData);
  525. child.stderr.on('data', onData);
  526. child.once('exit', (code) => {
  527. clearTimeout(timeout);
  528. reject(new Error(`Server exited early with code ${code}: ${chunks.join('')}`));
  529. });
  530. });
  531. }
  532. function waitForExit(child, timeoutMs) {
  533. if (child.exitCode !== null) return Promise.resolve(true);
  534. return new Promise((resolve) => {
  535. const timeout = setTimeout(() => {
  536. child.off('exit', onExit);
  537. resolve(false);
  538. }, timeoutMs);
  539. const onExit = () => {
  540. clearTimeout(timeout);
  541. resolve(true);
  542. };
  543. child.once('exit', onExit);
  544. });
  545. }