current commit
260813.2 · test_runner: extend tag filter with boolean expression DSL
import { expectWarning } from '../common/index.mjs';
import assert from 'node:assert';
import { test, suite, describe, it, before, beforeEach, after, afterEach, run } from 'node:test';

// Silence the one-shot ExperimentalWarning that fires the first time tags are
// registered. Validation tests focus on the throw, not the warning.
expectWarning('ExperimentalWarning', 'Test tags is an experimental feature and might change at any time');

test('non-array tags throws ERR_INVALID_ARG_TYPE', () => {
  for (const bad of ['db', 42, {}, true, Symbol('s'), null]) {
    assert.throws(
      () => test('x', { tags: bad }, () => {}),
      { code: 'ERR_INVALID_ARG_TYPE' },
      `expected throw for tags=${String(bad)}`,
    );
  }
});

test('non-string element throws ERR_INVALID_ARG_TYPE', () => {
  for (const bad of [42, {}, true, null, undefined, Symbol('s')]) {
    assert.throws(
      () => test('x', { tags: ['db', bad] }, () => {}),
      { code: 'ERR_INVALID_ARG_TYPE' },
      `expected throw for element=${String(bad)}`,
    );
  }
});

test('empty-string tag throws ERR_INVALID_ARG_VALUE', () => {
  assert.throws(
    () => test('x', { tags: [''] }, () => {}),
    { code: 'ERR_INVALID_ARG_VALUE' },
  );
  assert.throws(
    () => test('x', { tags: ['db', ''] }, () => {}),
    { code: 'ERR_INVALID_ARG_VALUE' },
  );
});

test('forbidden characters throw ERR_INVALID_ARG_VALUE', () => {
  for (const bad of ['a b', 'a\tb', 'a\nb', 'a&b', 'a|b', 'a!b', 'a(b', 'a)b', 'a*b', '*', '&&', '||']) {
    assert.throws(
      () => test('x', { tags: [bad] }, () => {}),
      { code: 'ERR_INVALID_ARG_VALUE' },
      `expected throw for tag=${JSON.stringify(bad)}`,
    );
  }
});

test('reserved words throw ERR_INVALID_ARG_VALUE in any casing', () => {
  for (const word of ['and', 'AND', 'And', 'or', 'OR', 'Or', 'not', 'NOT', 'Not']) {
    assert.throws(
      () => test('x', { tags: [word] }, () => {}),
      { code: 'ERR_INVALID_ARG_VALUE' },
      `expected throw for reserved word ${word}`,
    );
  }
});

test('Unicode and most punctuation are allowed', () => {
  // None of these should throw.
  test('unicode-1', { tags: ['café'] }, () => {});
  test('unicode-2', { tags: ['日本語'] }, () => {});
  test('punct-1', { tags: ['db:postgres'] }, () => {});
  test('punct-2', { tags: ['db-1'] }, () => {});
  test('punct-3', { tags: ['db.fast'] }, () => {});
  test('punct-4', { tags: ['db_fast'] }, () => {});
  test('punct-5', { tags: ['#critical'] }, () => {});
});

test('tags: [] is a no-op (does not throw)', () => {
  test('empty-tags', { tags: [] }, () => {});
});

test('case dedup: ["db", "DB", "Db"] collapses to single canonical entry', async (t) => {
  await t.test('child', { tags: ['db', 'DB', 'Db'] }, (ct) => {
    assert.deepStrictEqual(ct.tags, ['db']);
  });
});

test('declaration order is preserved on dedup', async (t) => {
  await t.test('child', { tags: ['Z', 'a', 'z', 'A'] }, (ct) => {
    assert.deepStrictEqual(ct.tags, ['z', 'a']);
  });
});

test('hooks silently ignore the tags option', () => {
  // Hooks must not throw if a caller mistakenly passes tags — the API has no
  // tags concept for hooks, but it should be tolerated. The validator only
  // runs on Test/Suite construction, not in the hook factory.
  before(() => {}, { tags: ['db'] });
  after(() => {}, { tags: ['db'] });
  beforeEach(() => {}, { tags: ['db'] });
  afterEach(() => {}, { tags: ['db'] });
});

test('suite() and describe() validate tags identically', () => {
  assert.throws(
    () => suite('s', { tags: 'db' }, () => {}),
    { code: 'ERR_INVALID_ARG_TYPE' },
  );
  assert.throws(
    () => describe('s', { tags: ['and'] }, () => {}),
    { code: 'ERR_INVALID_ARG_VALUE' },
  );
});

test('it() validates tags identically to test()', () => {
  assert.throws(
    () => it('i', { tags: [42] }, () => {}),
    { code: 'ERR_INVALID_ARG_TYPE' },
  );
});

test('it() validates tags identically to test()', () => {
  assert.throws(
    () => it('i', { tags: ['a b'] }, () => {}),
    { code: 'ERR_INVALID_ARG_VALUE' },
  );
});

test('run() rejects non-string testTagFilters values', () => {
  for (const bad of [[42], [{}], [null], [undefined], [true], [Symbol('s')]]) {
    assert.throws(
      () => run({ testTagFilters: bad }),
      { code: 'ERR_INVALID_ARG_TYPE' },
      `expected throw for testTagFilters=${JSON.stringify(bad)}`,
    );
  }
});

test('run() accepts a bare-string testTagFilters and normalizes it', async () => {
  // The string form is converted to a single-element array internally and
  // must not throw.
  const stream = run({ files: [], testTagFilters: 'db' });
  // eslint-disable-next-line no-unused-vars
  for await (const _ of stream);
});

test('run() treats an empty testTagFilters array as a no-op', async () => {
  const stream = run({ files: [], testTagFilters: [] });
  // eslint-disable-next-line no-unused-vars
  for await (const _ of stream);
});