@ 261108.1
current
'use strict'

import { styleText } from 'node:util'
import { exit } from 'node:process'
import net from 'node:net'
import { setTimeout as sleep } from 'node:timers/promises'
import { getResults, runTests as runTestSuite } from '../fixtures/cache-tests/test-engine/client/runner.mjs'
import { determineTestResult, testLookup } from '../fixtures/cache-tests/test-engine/lib/results.mjs'
import tests from '../fixtures/cache-tests/tests/index.mjs'
import { Agent, fetch, interceptors, setGlobalDispatcher } from '../../index.js'
import { runtimeFeatures } from '../../lib/util/runtime-features.js'
import MemoryCacheStore from '../../lib/cache/memory-cache-store.js'

if (!process.env.TEST_ENVIRONMENT) {
  throw new Error('missing TEST_ENVIRONMENT')
}

if (!process.env.BASE_URL) {
  throw new Error('missing BASE_URL')
}

/**
 * @type {import('./cache-tests.mjs').TestEnvironment}
 */
const environment = JSON.parse(process.env.TEST_ENVIRONMENT)
if (environment.cacheStore) {
  environment.opts.store = await makeCacheStore(environment.cacheStore)
}

// Start the test server
await import('../fixtures/cache-tests/test-engine/server/server.mjs')
// TODO: Remove when server.mjs waits for the server to listen.
// https://github.com/http-tests/cache-tests/issues/161
await waitForServer(process.env.BASE_URL)

// Output the testing setup
console.log('TEST ENVIRONMENT')
console.log(`       BASE_URL: ${styleText('gray', process.env.BASE_URL)}`)
if (environment.opts.store) {
  console.log(`          store: ${styleText('gray', environment.opts.store?.constructor.name ?? 'undefined')}`)
}
if (environment.opts.methods) {
  console.log(`        methods: ${styleText('gray', JSON.stringify(environment.opts.methods) ?? 'undefined')}`)
}
if (environment.opts.cacheByDefault) {
  console.log(` cacheByDefault: ${styleText('gray', `${environment.opts.cacheByDefault}`)}`)
}
if (environment.opts.type) {
  console.log(`           type: ${styleText('gray', environment.opts.type)}`)
}
if (environment.ignoredTests) {
  console.log(`  ignored tests: ${styleText('gray', JSON.stringify(environment.ignoredTests))}`)
}

// Setup the client
const client = new Agent().compose(interceptors.cache(environment.opts))
setGlobalDispatcher(client)

globalThis.fetch = fetch

// Run the suite
await runTestSuite(tests, true, process.env.BASE_URL)

let exitCode = 0

// Print the results
const stats = printResults(environment, getResults())
printStats(stats)

exit(exitCode)

/**
 * @param {import('./cache-tests.mjs').TestEnvironment['cacheStore']} type
 * @returns {Promise<import('../../types/cache-interceptor').default.CacheStore>}
 */
async function makeCacheStore (type) {
  const stores = {
    MemoryCacheStore
  }

  if (runtimeFeatures.has('sqlite')) {
    const { default: SqliteCacheStore } = await import('../../lib/cache/sqlite-cache-store.js')
    stores.SqliteCacheStore = SqliteCacheStore
  }

  const Store = stores[type]
  if (!Store) {
    throw new TypeError(`unknown cache store: ${type}`)
  }

  return new Store()
}

/**
 * @param {string} baseUrl
 * @returns {Promise<void>}
 */
async function waitForServer (baseUrl) {
  const { hostname, port, protocol } = new URL(baseUrl)
  const deadline = Date.now() + 30_000

  while (Date.now() < deadline) {
    if (await canConnect(hostname, Number(port), protocol)) {
      return
    }

    await sleep(250)
  }

  throw new Error(`cache test server did not start listening at ${baseUrl}`)
}

/**
 * @param {string} host
 * @param {number} port
 * @param {string} protocol
 * @returns {Promise<boolean>}
 */
function canConnect (host, port, protocol) {
  const { promise, resolve } = Promise.withResolvers()
  const socket = net.connect({ host, port })

  socket.once('connect', () => {
    socket.end()
    resolve(true)
  })

  socket.once('error', () => {
    resolve(false)
  })

  socket.setTimeout(1_000, () => {
    socket.destroy()
    resolve(false)
  })

  if (protocol !== 'http:' && protocol !== 'https:') {
    socket.destroy()
    resolve(false)
  }

  return promise
}

/**
 * @param {import('./cache-tests.mjs').TestEnvironment} environment
 * @param {any} results
 * @returns {import('./cache-tests.mjs').TestStats}
 */
function printResults (environment, results) {
  /**
   * @type {import('./cache-tests.mjs').TestStats}
   */
  const stats = {
    total: Object.keys(results).length - (environment.ignoredTests?.length || 0),
    skipped: 0,
    passed: 0,
    failed: 0,
    optionalFailed: 0,
    setupFailed: 0,
    testHarnessFailed: 0,
    dependencyFailed: 0,
    retried: 0
  }

  for (const testId in results) {
    if (environment.ignoredTests?.includes(testId)) {
      continue
    }

    const test = testLookup(tests, testId)
    // eslint-disable-next-line no-unused-vars
    const [code, _, icon] = determineTestResult(tests, testId, results, false)

    let status
    let color
    switch (code) {
      case '-':
        status = 'skipped'
        color = 'gray'
        stats.skipped++
        break
      case '\uf058':
        status = 'pass'
        color = 'green'
        stats.passed++
        break
      case '\uf057':
        status = 'failed'
        color = 'red'
        stats.failed++
        exitCode = 1
        break
      case '\uf05a':
        status = 'failed (optional)'
        color = 'yellow'
        stats.optionalFailed++
        break
      case '\uf055':
        status = 'yes'
        color = 'green'
        stats.passed++
        break
      case '\uf056':
        status = 'no'
        color = 'yellow'
        stats.optionalFailed++
        break
      case '\uf059':
        status = 'setup failure'
        color = 'red'
        stats.setupFailed++
        break
      case '\uf06a':
        status = 'test harness failure'
        color = 'red'
        stats.testHarnessFailed++
        break
      case '\uf192':
        status = 'dependency failure'
        color = 'red'
        stats.dependencyFailed++
        break
      case '\uf01e':
        status = 'retry'
        color = 'yellow'
        stats.retried++
        break
      default:
        status = 'unknown'
        color = ['strikethrough', 'white']
        break
    }

    if (process.env.CI && status !== 'failed') {
      continue
    }

    console.log(`${icon} ${styleText(color, `${status} - ${test.name}`)} (${styleText('gray', testId)})`)
    if (results[testId] !== true) {
      const [type, message] = results[testId]
      console.log(`    ${styleText(color, `${type}: ${message}`)}`)
    }
  }

  return stats
}

/**
 * @param {import('./cache-tests.mjs').TestStats} stats
 */
function printStats (stats) {
  const {
    total,
    skipped,
    passed,
    failed,
    optionalFailed,
    setupFailed,
    testHarnessFailed,
    dependencyFailed,
    retried
  } = stats

  if (total < 0) {
    throw new Error('Total tests cannot be negative')
  }

  console.log(`\n        Total tests: ${total}`)
  console.log(`            ${styleText('gray', 'Skipped')}: ${skipped} (${((skipped / total) * 100).toFixed(1)}%)`)
  console.log(`             ${styleText('green', 'Passed')}: ${passed} (${((passed / total) * 100).toFixed(1)}%)`)
  console.log(`             ${styleText('red', 'Failed')}: ${failed} (${((failed / total) * 100).toFixed(1)}%)`)
  console.log(`  ${styleText('yellow', 'Failed (optional)')}: ${optionalFailed} (${((optionalFailed / total) * 100).toFixed(1)}%)`)
  console.log(`       ${styleText('red', 'Setup failed')}: ${setupFailed} (${((setupFailed / total) * 100).toFixed(1)}%)`)
  console.log(`${styleText('red', 'Test Harness Failed')}: ${testHarnessFailed} (${((testHarnessFailed / total) * 100).toFixed(1)}%)`)
  console.log(`  ${styleText('red', 'Dependency Failed')}: ${dependencyFailed} (${((dependencyFailed / total) * 100).toFixed(1)}%)`)
  console.log(`            ${styleText('yellow', 'Retried')}: ${retried} (${((retried / total) * 100).toFixed(1)}%)`)
}