- 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:
parent
461caa8086
commit
6de5392e90
11 changed files with 343 additions and 1 deletions
|
|
@ -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
44
.gitea/workflows/ci.yml
Normal 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
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -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
55
test/google.test.js
Normal 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), '');
|
||||
});
|
||||
});
|
||||
29
test/greetingSelector.test.js
Normal file
29
test/greetingSelector.test.js
Normal 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
74
test/helpers.test.js
Normal 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
27
test/setup.js
Normal 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
67
test/stepRunner.test.js
Normal 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
39
test/webexClient.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue