UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

320 lines (279 loc) 12.1 kB
/** * Integration tests for the uibuilder telemetry Cloudflare Worker. * * Tests run against a live (or locally running) Worker via HTTP. * * Configuration — set these before running: * WORKER_URL URL of the deployed Worker, e.g. * https://uibuilder-telemetry.<subdomain>.workers.dev * Defaults to http://localhost:8787 (wrangler dev) * STATS_TOKEN The secret set via `wrangler secret put STATS_TOKEN` * * Recommended: create a .env.test.local file in this directory: * WORKER_URL=https://uibuilder-telemetry.<subdomain>.workers.dev * STATS_TOKEN=<your-token> * * Then run: npm test * Or for live watch: npm run test:watch */ import { describe, it, expect, beforeAll } from 'vitest' import { randomUUID } from 'node:crypto' import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' // ── Load .env.test.local if present (no external dependency needed) ─────────── const __dirname = dirname(fileURLToPath(import.meta.url)) const envFile = resolve(__dirname, '..', '.env.test.local') if (existsSync(envFile)) { for (const line of readFileSync(envFile, 'utf8').split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const eq = trimmed.indexOf('=') if (eq === -1) continue const key = trimmed.slice(0, eq).trim() const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '') if (key && !(key in process.env)) process.env[key] = val } } // ── Configuration ───────────────────────────────────────────────────────────── const WORKER_URL = (process.env.WORKER_URL ?? 'http://localhost:8787').replace(/\/$/, '') const STATS_TOKEN = process.env.STATS_TOKEN ?? '' if (!STATS_TOKEN) { console.warn( '\n⚠ STATS_TOKEN is not set. Stats-endpoint tests will be skipped.\n' + ' Set it in .env.test.local or as an environment variable.\n', ) } // ── Test fixture data — five pseudo Node-RED instances ──────────────────────── /** * @typedef {object} Instance * @property {string} uuid * @property {string} uib_version * @property {string} nr_version * @property {string} node_version * @property {string} os_platform * @property {number} uib_count * @property {number} markweb_count * @property {Array<{family: string, version: string, count: number}>} browsers */ /** @type {Instance[]} */ const INSTANCES = [ { uuid: randomUUID(), uib_version: '7.7.0', nr_version: '4.0.3', node_version: '20.11.0', os_platform: 'linux', uib_count: 3, markweb_count: 1, browsers: [ { family: 'Chrome', version: '124.0.0', count: 8 }, { family: 'Firefox', version: '125.0', count: 2 }, ], }, { uuid: randomUUID(), uib_version: '7.7.0', nr_version: '4.0.3', node_version: '20.11.0', os_platform: 'win32', uib_count: 1, markweb_count: 0, browsers: [ { family: 'Chrome', version: '124.0.0', count: 3 }, { family: 'Edge', version: '124.0.0', count: 1 }, ], }, { uuid: randomUUID(), uib_version: '7.6.0', nr_version: '3.1.9', node_version: '18.20.0', os_platform: 'darwin', uib_count: 2, markweb_count: 2, browsers: [ { family: 'Safari', version: '17.4', count: 5 }, ], }, { uuid: randomUUID(), uib_version: '7.7.0', nr_version: '4.0.2', node_version: '22.1.0', os_platform: 'linux', uib_count: 5, markweb_count: 0, browsers: [ { family: 'Chrome', version: '123.0.0', count: 15 }, ], }, { uuid: randomUUID(), uib_version: '7.5.0', nr_version: '3.1.9', node_version: '18.20.0', os_platform: 'linux', uib_count: 0, markweb_count: 3, browsers: [ { family: 'Firefox', version: '124.0', count: 4 }, ], }, ] // A separate UUID used only for rate-limit testing — never submitted beforehand const RATE_LIMIT_UUID = randomUUID() // ── Helpers ─────────────────────────────────────────────────────────────────── /** * POST a telemetry body to the Worker. * @param {object} body * @returns {Promise<Response>} */ const postTelemetry = (body) => fetch(`${WORKER_URL}/telemetry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) /** * GET /stats with an Authorization header. * @param {string} [token] * @returns {Promise<Response>} */ const getStats = (token = STATS_TOKEN) => fetch(`${WORKER_URL}/stats`, { headers: { 'Authorization': `Bearer ${token}` }, }) // ── Tests ───────────────────────────────────────────────────────────────────── describe('POST /telemetry', () => { describe('valid submissions', () => { it.each(INSTANCES.map((inst, i) => [i + 1, inst]))( 'accepts instance %i (%s)', async (_, inst) => { const res = await postTelemetry({ uuid: inst.uuid, uib_version: inst.uib_version, nr_version: inst.nr_version, node_version: inst.node_version, os_platform: inst.os_platform, uib_count: inst.uib_count, markweb_count: inst.markweb_count, browsers: inst.browsers, }) expect(res.status).toBe(200) expect(await res.text()).toBe('OK') }, ) it('accepts a minimal payload (uuid only)', async () => { const res = await postTelemetry({ uuid: randomUUID() }) expect(res.status).toBe(200) }) it('silently ignores extra unknown fields', async () => { const res = await postTelemetry({ uuid: randomUUID(), totally_unknown_field: 'should be ignored', another: 12345, }) expect(res.status).toBe(200) }) it('accepts browser entries up to the maximum of 20', async () => { const browsers = Array.from({ length: 25 }, (_, i) => ({ family: `Browser${i}`, version: '1.0', count: 1, })) const res = await postTelemetry({ uuid: randomUUID(), browsers }) expect(res.status).toBe(200) }) }) describe('input validation', () => { it('rejects a missing uuid with 400', async () => { const res = await postTelemetry({ uib_version: '7.7.0' }) expect(res.status).toBe(400) }) it('rejects a non-UUID string uuid with 400', async () => { const res = await postTelemetry({ uuid: 'not-a-uuid' }) expect(res.status).toBe(400) }) it('rejects an empty uuid with 400', async () => { const res = await postTelemetry({ uuid: '' }) expect(res.status).toBe(400) }) it('rejects malformed JSON with 400', async () => { const res = await fetch(`${WORKER_URL}/telemetry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{ this is not json }', }) expect(res.status).toBe(400) }) it('rejects an empty body with 400', async () => { const res = await fetch(`${WORKER_URL}/telemetry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '', }) expect(res.status).toBe(400) }) }) describe('rate limiting', () => { beforeAll(async () => { // First submission for this UUID — must succeed before we can test the limit const first = await postTelemetry({ uuid: RATE_LIMIT_UUID, uib_version: '7.7.0', uib_count: 1, }) expect(first.status, 'Pre-condition: first submission must succeed').toBe(200) }) it('rejects a second submission within the rate-limit window with 429', async () => { const res = await postTelemetry({ uuid: RATE_LIMIT_UUID, uib_count: 1 }) expect(res.status).toBe(429) }) }) describe('unknown routes', () => { it('returns 404 for an unknown GET path', async () => { const res = await fetch(`${WORKER_URL}/unknown`) expect(res.status).toBe(404) }) it('returns 404 for GET /telemetry (wrong method + path)', async () => { const res = await fetch(`${WORKER_URL}/telemetry`) expect(res.status).toBe(404) }) }) }) describe('GET /stats', () => { it('returns 401 with no Authorization header', async () => { const res = await fetch(`${WORKER_URL}/stats`) expect(res.status).toBe(401) }) it('returns 401 with a wrong token', async () => { const res = await getStats('definitely-wrong-token') expect(res.status).toBe(401) }) it.skipIf(!STATS_TOKEN)('returns 200 with the correct token', async () => { const res = await getStats() expect(res.status).toBe(200) }) it.skipIf(!STATS_TOKEN)('returns valid JSON with expected top-level keys', async () => { const res = await getStats() const data = await res.json() expect(data).toHaveProperty('period', 'last_30_days') expect(data).toHaveProperty('summary') expect(data).toHaveProperty('browsers') expect(data).toHaveProperty('uib_versions') expect(data).toHaveProperty('platforms') }) it.skipIf(!STATS_TOKEN)('summary contains numeric aggregate fields', async () => { const { summary } = await getStats().then(r => r.json()) expect(typeof summary.active_instances).toBe('number') expect(typeof summary.total_uib_nodes).toBe('number') expect(typeof summary.total_markweb_nodes).toBe('number') // We submitted 5 instances in this run; total should be at least that expect(summary.active_instances).toBeGreaterThanOrEqual(5) }) it.skipIf(!STATS_TOKEN)('browsers array contains entries matching submitted data', async () => { const { browsers } = await getStats().then(r => r.json()) expect(Array.isArray(browsers)).toBe(true) const families = browsers.map(b => b.browser_family) // Chrome was submitted by three different instances expect(families).toContain('Chrome') // Firefox was submitted by two instances expect(families).toContain('Firefox') }) it.skipIf(!STATS_TOKEN)('uib_versions array contains submitted versions', async () => { const { uib_versions } = await getStats().then(r => r.json()) expect(Array.isArray(uib_versions)).toBe(true) const versions = uib_versions.map(v => v.uib_version) expect(versions).toContain('7.7.0') expect(versions).toContain('7.6.0') expect(versions).toContain('7.5.0') }) it.skipIf(!STATS_TOKEN)('platforms array contains submitted OS platforms', async () => { const { platforms } = await getStats().then(r => r.json()) expect(Array.isArray(platforms)).toBe(true) const oses = platforms.map(p => p.os_platform) expect(oses).toContain('linux') expect(oses).toContain('win32') expect(oses).toContain('darwin') }) })