Add node:test suite and Gitea Actions CI
Some checks are pending
CI / verify (push) Waiting to run

- 30-test suite (node --test, no framework) covering the exact functions
  that have regressed in previous rounds:
    * parseStoreArg / parseStoreNumber / storeEmail (helpers)
    * greetingForBrand (brand fallback)
    * formatE911Address / formatSuite (google response reducer)
    * parseNextLink (RFC-5988 pagination — now exported)
    * runStep success / non-critical / critical / no-bot paths
- test/setup.js stubs required env vars so any src/ module can be
  imported cleanly; loaded via --import once per test process
- npm run test wired up; directory form works on Node 20 + 22
- .gitea/workflows/ci.yml runs lint + format:check + test on push/PR
  to main, on Node 22 to match the runtime image
- Exclude test/ and .gitea/ from the Docker build context
- Exclude docker/ from bot Prettier scope (remote-agent is its own
  sub-project with its own tooling)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-06 16:07:02 -04:00
parent 461caa8086
commit 6de5392e90
11 changed files with 343 additions and 1 deletions

View file

@ -25,9 +25,11 @@ storeAddress.js
# Local scripts/tooling not needed at runtime
scripts/
test/
eslint.config.js
.prettierrc.json
.prettierignore
.gitea/
# Remote-agent packaging (its own Dockerfile / image; not part of the bot image)
docker/

44
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,44 @@
# Gitea Actions CI for wbxcallprov.
#
# Runs on every push and PR against main. Matches the runtime image:
# node:22-alpine in Dockerfile -> node-version: 22 here.
#
# The three checks are the same ones a developer runs locally before pushing:
# npm run lint - ESLint flat config, catches unused vars / var / ==
# npm run format:check - Prettier drift detection (writes are separate)
# npm test - node --test suite for pure-logic functions
#
# Tests never hit the network; dummy env vars come from test/setup.js.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
- name: Test
run: npm test

View file

@ -4,3 +4,7 @@ buildingFile.csv
locationFile.csv
package-lock.json
config/wbxTokens.json
# Remote agent is a self-contained sub-project with its own package.json
# and its own tooling; it manages its own formatting.
docker/

View file

@ -13,6 +13,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test --import ./test/setup.js test/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",

View file

@ -61,7 +61,7 @@ export async function webexListAll(initialUrl, { itemsKey = 'items' } = {}) {
return collected;
}
function parseNextLink(linkHeader) {
export function parseNextLink(linkHeader) {
if (!linkHeader) return null;
for (const part of linkHeader.split(',')) {
const match = part.trim().match(/^<([^>]+)>;\s*rel="?next"?/);

55
test/google.test.js Normal file
View file

@ -0,0 +1,55 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatE911Address, formatSuite } from '../src/integrations/google.js';
// Minimal shape of the Google Address Validation response we care about.
function makeResponse(components) {
return { result: { address: { addressComponents: components } } };
}
const fullAddress = makeResponse([
{ componentType: 'street_number', componentName: { text: '77' } },
{ componentType: 'route', componentName: { text: 'Willowbrook Rd' } },
{ componentType: 'subpremise', componentName: { text: 'Ste 2091' } },
{ componentType: 'locality', componentName: { text: 'Wayne' } },
{ componentType: 'administrative_area_level_1', componentName: { text: 'NJ' } },
{ componentType: 'postal_code', componentName: { text: '07470' } },
{ componentType: 'country', componentName: { text: 'United States' } },
]);
describe('formatE911Address', () => {
it('flattens the address components into an E911-shaped string', () => {
assert.equal(formatE911Address(fullAddress), '77 Willowbrook Rd, Wayne, NJ 07470');
});
it('tolerates missing components without crashing', () => {
const partial = makeResponse([
{ componentType: 'street_number', componentName: { text: '10' } },
{ componentType: 'route', componentName: { text: 'Main St' } },
{ componentType: 'locality', componentName: { text: 'Anywhere' } },
]);
// Missing state/zip render as empty slots; the join stays intact.
assert.equal(formatE911Address(partial), '10 Main St, Anywhere,');
});
it('handles an empty component list', () => {
// The template renders three comma-separated sections; with every
// field empty they collapse to just the separators after trim.
assert.equal(formatE911Address(makeResponse([])), ', ,');
});
});
describe('formatSuite', () => {
it('returns the subpremise text when present', () => {
assert.equal(formatSuite(fullAddress), 'Ste 2091');
});
it('returns empty string when there is no subpremise', () => {
const noSuite = makeResponse([
{ componentType: 'street_number', componentName: { text: '77' } },
{ componentType: 'route', componentName: { text: 'Willowbrook Rd' } },
]);
assert.equal(formatSuite(noSuite), '');
});
});

View file

@ -0,0 +1,29 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { greetingForBrand } from '../src/flows/greetingSelector.js';
import { GREETINGS } from '../src/constants.js';
describe('greetingForBrand', () => {
it('returns the correct file+label for a known brand', () => {
const result = greetingForBrand({ brand: 'Aerie', storeNumber: 792 });
assert.equal(result.file, GREETINGS.Aerie.file);
assert.equal(result.fileName, '792 - Aerie Greeting.wav');
});
it('picks the American Eagle greeting for the AE brand', () => {
const result = greetingForBrand({
brand: 'American Eagle Outfitters',
storeNumber: 499,
});
assert.equal(result.file, GREETINGS['American Eagle Outfitters'].file);
assert.equal(result.fileName, '499 - AE Greeting.wav');
});
it('falls back to the AE greeting for unknown brands', () => {
const result = greetingForBrand({ brand: 'MysteryBrand', storeNumber: 42 });
assert.equal(result.file, GREETINGS['American Eagle Outfitters'].file);
// Filename still reflects the caller's storeNumber and the AE label.
assert.equal(result.fileName, '42 - AE Greeting.wav');
});
});

74
test/helpers.test.js Normal file
View file

@ -0,0 +1,74 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseStoreArg, parseStoreNumber, storeEmail } from '../src/commands/helpers.js';
// parseStoreArg regressed once already: the framework's trigger.prompt is
// everything AFTER the matched command (e.g. " 792" for "/storeInfo 792"),
// but the old implementation split and took index [1], which returned
// undefined. These tests lock that behavior down.
describe('parseStoreArg', () => {
it('reads first token from trigger.prompt (DM path)', () => {
assert.equal(parseStoreArg({ prompt: ' 792' }), '792');
});
it('handles a prompt with trailing junk', () => {
assert.equal(parseStoreArg({ prompt: ' 499 extra text' }), '499');
});
it('falls back to trigger.args when prompt is empty (DM)', () => {
// In DMs the framework populates args as [command, ...positional].
assert.equal(parseStoreArg({ prompt: '', args: ['/storeInfo', '792'] }), '792');
});
it('falls back to trigger.args in mentioned group rooms', () => {
// In group rooms args[0] is the bot name; we look for the first
// token starting with '/' and take the one after it.
assert.equal(
parseStoreArg({ prompt: '', args: ['aeoCallProvisioning', '/storeInfo', '792'] }),
'792',
);
});
it('returns undefined when nothing usable is present', () => {
assert.equal(parseStoreArg({}), undefined);
assert.equal(parseStoreArg({ prompt: ' ', args: [] }), undefined);
assert.equal(parseStoreArg(null), undefined);
assert.equal(parseStoreArg(undefined), undefined);
});
});
describe('parseStoreNumber', () => {
it('accepts 1-5 digit numeric strings', () => {
assert.equal(parseStoreNumber({ prompt: ' 1' }), '1');
assert.equal(parseStoreNumber({ prompt: ' 499' }), '499');
assert.equal(parseStoreNumber({ prompt: ' 12345' }), '12345');
});
it('rejects non-numeric input', () => {
assert.equal(parseStoreNumber({ prompt: ' abc' }), null);
assert.equal(parseStoreNumber({ prompt: ' 49a' }), null);
assert.equal(parseStoreNumber({ prompt: ' 4-9' }), null);
});
it('rejects overly long numbers', () => {
assert.equal(parseStoreNumber({ prompt: ' 123456' }), null);
});
it('returns null when the arg is missing', () => {
assert.equal(parseStoreNumber({}), null);
assert.equal(parseStoreNumber({ prompt: '' }), null);
});
});
describe('storeEmail', () => {
it('zero-pads to 5 digits and appends @ae.com', () => {
assert.equal(storeEmail(499), 'ae00499@ae.com');
assert.equal(storeEmail(1), 'ae00001@ae.com');
assert.equal(storeEmail(12345), 'ae12345@ae.com');
});
it('accepts numeric-string input', () => {
assert.equal(storeEmail('42'), 'ae00042@ae.com');
});
});

27
test/setup.js Normal file
View file

@ -0,0 +1,27 @@
// Loaded before every test file via `node --test --import ./test/setup.js`.
//
// Its only job is to satisfy the `required(...)` env checks in src/config.js
// so we can `import` any module in the tree from a test file without
// module-load blowing up. None of these values are ever used at rest — the
// tests exercise pure functions, and any code path that would actually make
// a network call is either mocked or not exercised by the test suite.
//
// If you add a new `required('FOO')` call in src/config.js, add a stub here.
const dummy = {
WEBEX_BOT_TOKEN: 'test-bot-token',
WEBEX_SVC_CLIENT_ID: 'test-svc-client-id',
WEBEX_SVC_CLIENT_SECRET: 'test-svc-client-secret',
TWILIO_ACCOUNT_SID: 'test-twilio-sid',
TWILIO_AUTH_TOKEN: 'test-twilio-token',
SIW_USERNAME: 'test-siw-user',
SIW_PASSWORD: 'test-siw-password',
GOOGLE_API_KEY: 'test-google-key',
WS_TOKEN: 'test-ws-token',
};
for (const [key, value] of Object.entries(dummy)) {
if (process.env[key] === undefined || process.env[key] === '') {
process.env[key] = value;
}
}

67
test/stepRunner.test.js Normal file
View file

@ -0,0 +1,67 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { runStep } from '../src/flows/stepRunner.js';
// Minimal bot double: records every `say` call so we can assert on the
// success/failure blockquote UX without touching a real Webex framework.
function makeBot() {
const said = [];
return {
said,
say(format, text) {
said.push({ format, text });
},
};
}
describe('runStep', () => {
let bot;
beforeEach(() => {
bot = makeBot();
});
it('returns the resolved value and shows a success blockquote', async () => {
const result = await runStep(bot, 'Did the thing', async () => 42);
assert.equal(result, 42);
assert.equal(bot.said.length, 1);
assert.match(bot.said[0].text, /success.*Did the thing/);
});
it('honors an override success message', async () => {
await runStep(bot, 'internal-desc', async () => 'ok', { successMessage: 'Nice.' });
assert.match(bot.said[0].text, /success.*Nice\./);
});
it('swallows non-critical failures, returns undefined, and shows a failure blockquote', async () => {
const result = await runStep(bot, 'That step', async () => {
throw new Error('nope');
});
assert.equal(result, undefined);
assert.equal(bot.said.length, 1);
assert.match(bot.said[0].text, /failure.*Error: That step/);
// Non-critical failures MUST NOT tag the message as aborting.
assert.doesNotMatch(bot.said[0].text, /aborting/);
});
it('re-throws on a critical failure and annotates the message', async () => {
await assert.rejects(
runStep(
bot,
'Enabled location for Webex Calling',
async () => {
throw new Error('cascade');
},
{ critical: true },
),
/cascade/,
);
assert.equal(bot.said.length, 1);
assert.match(bot.said[0].text, /aborting \(critical step\)/);
});
it('works when bot is undefined (script/CLI usage)', async () => {
const result = await runStep(undefined, 'headless', async () => 'ok');
assert.equal(result, 'ok');
});
});

39
test/webexClient.test.js Normal file
View file

@ -0,0 +1,39 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseNextLink } from '../src/webex/client.js';
// RFC-5988 Link headers: webexListAll walks these to page through big
// list endpoints (locations, users, etc). If this parser breaks silently,
// list calls only return the first page.
describe('parseNextLink', () => {
it('extracts the URL from a next-only Link header', () => {
const header = '<https://webexapis.com/v1/people?cursor=abc>; rel="next"';
assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=abc');
});
it('picks the next rel out of a multi-rel header', () => {
const header =
'<https://webexapis.com/v1/people?cursor=abc>; rel="next", ' +
'<https://webexapis.com/v1/people?cursor=first>; rel="first"';
assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=abc');
});
it('accepts unquoted rel values', () => {
const header = '<https://webexapis.com/v1/people?cursor=xyz>; rel=next';
assert.equal(parseNextLink(header), 'https://webexapis.com/v1/people?cursor=xyz');
});
it('returns null when there is no next rel', () => {
const header =
'<https://webexapis.com/v1/people?cursor=prev>; rel="prev", ' +
'<https://webexapis.com/v1/people?cursor=first>; rel="first"';
assert.equal(parseNextLink(header), null);
});
it('returns null for missing / empty headers', () => {
assert.equal(parseNextLink(null), null);
assert.equal(parseNextLink(undefined), null);
assert.equal(parseNextLink(''), null);
});
});