From 17d216663d7da1250837e4bd4add81ee1281b2a8 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:52 +0100 Subject: [PATCH] test: smoke-test the packed packages in real consumers --- .github/workflows/checks.yml | 24 + README.md | 15 +- package.json | 3 +- projects/ngx-ui-watchtower/DESIGN.md | 6 + projects/ui-watchtower/package.json | 2 +- scripts/smoke/angular/angular.json | 24 + .../angular/src/app/app.config.server.ts | 8 + .../angular/src/app/app.routes.server.ts | 5 + scripts/smoke/angular/src/app/app.routes.ts | 4 + scripts/smoke/angular/src/app/app.ts | 9 + scripts/smoke/angular/src/app/home.ts | 28 ++ scripts/smoke/angular/src/index.html | 11 + scripts/smoke/angular/src/main.server.ts | 11 + scripts/smoke/angular/src/main.ts | 5 + scripts/smoke/angular/src/telemetry.schema.ts | 17 + scripts/smoke/angular/tsconfig.json | 18 + .../angular/variants/fragment.app.config.ts | 24 + .../angular/variants/shell.app.config.ts | 36 ++ scripts/smoke/core-node/consumer.mjs | 79 ++++ scripts/smoke/core-ts/nodom.ts | 17 + scripts/smoke/smoke.mjs | 410 ++++++++++++++++++ 21 files changed, 751 insertions(+), 5 deletions(-) create mode 100644 scripts/smoke/angular/angular.json create mode 100644 scripts/smoke/angular/src/app/app.config.server.ts create mode 100644 scripts/smoke/angular/src/app/app.routes.server.ts create mode 100644 scripts/smoke/angular/src/app/app.routes.ts create mode 100644 scripts/smoke/angular/src/app/app.ts create mode 100644 scripts/smoke/angular/src/app/home.ts create mode 100644 scripts/smoke/angular/src/index.html create mode 100644 scripts/smoke/angular/src/main.server.ts create mode 100644 scripts/smoke/angular/src/main.ts create mode 100644 scripts/smoke/angular/src/telemetry.schema.ts create mode 100644 scripts/smoke/angular/tsconfig.json create mode 100644 scripts/smoke/angular/variants/fragment.app.config.ts create mode 100644 scripts/smoke/angular/variants/shell.app.config.ts create mode 100644 scripts/smoke/core-node/consumer.mjs create mode 100644 scripts/smoke/core-ts/nodom.ts create mode 100644 scripts/smoke/smoke.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 280d18f..fbe6838 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -84,6 +84,30 @@ jobs: base-coverage-file: ./coverage/ui-watchtower/report.json skip-step: all + smoke: + name: Packed packages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build both packages + run: npm run build + + # Packs both packages and installs them, outside the workspace, into a + # core-only Node and TypeScript consumer and an unchanged Angular one. + - name: Smoke-test the packed packages + run: npm run smoke + lint: name: Lint and format runs-on: ubuntu-latest diff --git a/README.md b/README.md index 72f94fd..2059a80 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ projects/ngx-ui-watchtower/ README.md usage guide, published to npm DESIGN.md full design package.json the published package's manifest -scripts/ the public API snapshot +scripts/ the public API snapshot, and the packaging smoke test ``` The Angular package depends on the core, never the other way round. @@ -66,11 +66,12 @@ application does. Tests and the typecheck read the core's source. | `npm run typecheck` | Builds the core, then type-checks every tsconfig and type test | | `npm run api:check` | Compares the built public APIs with their committed snapshots | | `npm run api:update` | Writes those snapshots again, for an intended API change | +| `npm run smoke` | Installs the built packages, packed, into throwaway consumers | | `npm run lint` | ESLint, no warnings allowed | | `npm run format` | Formats with Prettier | | `npm run format:check` | Checks formatting without writing | -Three checks guard what consumers depend on: +Four checks guard what consumers depend on: - **Type tests** (`type-tests/` in each package) compile a consumer that uses every public export, and one that augments the name registries: registered @@ -84,7 +85,14 @@ Three checks guard what consumers depend on: (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records every message a shell and a fragment exchange. Shells and fragments are deployed separately, so it changes only with a deliberate protocol change: - `UPDATE_WIRE_FIXTURE=1 npm test` records it again. + `UPDATE_WIRE_FIXTURE=1 npm test` records it again. Every pairing of an + Angular or framework-free shell and fragment is held to it. +- **The smoke test** (`scripts/smoke/`) packs both packages and installs the + tarballs, outside this workspace, into a framework-free Node consumer (no + Angular, RxJS or `aws-rum-web` installed), a TypeScript one (the core's + type tests, and a project without the DOM typings), and an unchanged + Angular application, prerendered as a fragment without `aws-rum-web` and as + a shell with `/rum`. It installs from the registry, so it needs a network. ## Before you push @@ -97,6 +105,7 @@ npm run lint npm run format:check npm run build npm run api:check +npm run smoke ``` CI runs the same checks on every pull request and every push to `main`. diff --git a/package.json b/package.json index 5d3309e..2b85890 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", "typecheck": "tsc -p projects/ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rxjs/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ui-watchtower/type-tests/tsconfig.json && tsc -p projects/ui-watchtower/type-tests/tsconfig.augmented.json && ng build --configuration=production --project=ui-watchtower && tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", "api:check": "node scripts/api-snapshot.mjs", - "api:update": "node scripts/api-snapshot.mjs --update" + "api:update": "node scripts/api-snapshot.mjs --update", + "smoke": "node scripts/smoke/smoke.mjs" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/DESIGN.md b/projects/ngx-ui-watchtower/DESIGN.md index d4c7623..1f0e8fd 100644 --- a/projects/ngx-ui-watchtower/DESIGN.md +++ b/projects/ngx-ui-watchtower/DESIGN.md @@ -239,6 +239,12 @@ providers: [ `@absaoss-cps/ngx-ui-watchtower/rum` can't import the main entry's internals by relative path and carries small copies of what it needs: its Angular helpers and the reporter. +- **What is published is tested as published.** `npm run smoke` installs + both tarballs into consumers outside the workspace: a framework-free one + with neither Angular nor `aws-rum-web` installed, and an Angular + application, prerendered as a fragment without `aws-rum-web` — which then + holds no RUM code — and as a shell with `/rum`, whose RUM client is a chunk + loaded on demand. - **Test doubles live in the specs that use them**, declared inline, never in a shared file and never exported. The one browser gap needing a stub is `BroadcastChannel`, which jsdom doesn't implement. diff --git a/projects/ui-watchtower/package.json b/projects/ui-watchtower/package.json index 1e00897..bc739c4 100644 --- a/projects/ui-watchtower/package.json +++ b/projects/ui-watchtower/package.json @@ -1,6 +1,6 @@ { "name": "@absaoss-cps/ui-watchtower", - "version": "0.4.0", + "version": "0.1.0", "description": "Framework-independent telemetry engine: application logs, scenario health telemetry and business/UX events, for any JavaScript or TypeScript application.", "repository": { "type": "git", diff --git a/scripts/smoke/angular/angular.json b/scripts/smoke/angular/angular.json new file mode 100644 index 0000000..eec8f64 --- /dev/null +++ b/scripts/smoke/angular/angular.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "cli": { "analytics": false }, + "projects": { + "consumer": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "server": "src/main.server.ts", + "outputMode": "static", + "index": "src/index.html", + "outputPath": "dist", + "tsConfig": "tsconfig.json" + } + } + } + } + } +} diff --git a/scripts/smoke/angular/src/app/app.config.server.ts b/scripts/smoke/angular/src/app/app.config.server.ts new file mode 100644 index 0000000..1f253e9 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.config.server.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +export const config: ApplicationConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering(withRoutes(serverRoutes))] +}); diff --git a/scripts/smoke/angular/src/app/app.routes.server.ts b/scripts/smoke/angular/src/app/app.routes.server.ts new file mode 100644 index 0000000..dae0f3f --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.server.ts @@ -0,0 +1,5 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Prerender } +]; diff --git a/scripts/smoke/angular/src/app/app.routes.ts b/scripts/smoke/angular/src/app/app.routes.ts new file mode 100644 index 0000000..9742bb2 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.routes.ts @@ -0,0 +1,4 @@ +import { Routes } from '@angular/router'; +import { Home } from './home'; + +export const routes: Routes = [{ path: '', component: Home }]; diff --git a/scripts/smoke/angular/src/app/app.ts b/scripts/smoke/angular/src/app/app.ts new file mode 100644 index 0000000..8b1e188 --- /dev/null +++ b/scripts/smoke/angular/src/app/app.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + template: '' +}) +export class App {} diff --git a/scripts/smoke/angular/src/app/home.ts b/scripts/smoke/angular/src/app/home.ts new file mode 100644 index 0000000..07b0a19 --- /dev/null +++ b/scripts/smoke/angular/src/app/home.ts @@ -0,0 +1,28 @@ +import { Component, inject } from '@angular/core'; +import { + UwtBITelemetryService, + UwtLoggerService, + UwtScenarioTelemetryService +} from '@absaoss-cps/ngx-ui-watchtower'; + +/** Uses every service while it renders — on the server, when prerendered. */ +@Component({ + selector: 'app-home', + template: '

scenario {{ status }}

' +}) +export class Home { + readonly status: string; + + constructor() { + const scenarios = inject(UwtScenarioTelemetryService); + const scenario = scenarios.start({ name: 'render-home' }); + scenario.step('render'); + inject(UwtBITelemetryService).track('home_rendered', { server: true }); + inject(UwtLoggerService).getLogger('smoke').log('Home rendered'); + scenario.complete(); + this.status = scenario.status ?? 'running'; + + // @ts-expect-error not a registered scenario: the augmentation applies + scenarios.findByName('render-hom'); + } +} diff --git a/scripts/smoke/angular/src/index.html b/scripts/smoke/angular/src/index.html new file mode 100644 index 0000000..6e87903 --- /dev/null +++ b/scripts/smoke/angular/src/index.html @@ -0,0 +1,11 @@ + + + + + ui-watchtower smoke + + + + + + diff --git a/scripts/smoke/angular/src/main.server.ts b/scripts/smoke/angular/src/main.server.ts new file mode 100644 index 0000000..eea9a7e --- /dev/null +++ b/scripts/smoke/angular/src/main.server.ts @@ -0,0 +1,11 @@ +import { + BootstrapContext, + bootstrapApplication +} from '@angular/platform-browser'; +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => + bootstrapApplication(App, config, context); + +export default bootstrap; diff --git a/scripts/smoke/angular/src/main.ts b/scripts/smoke/angular/src/main.ts new file mode 100644 index 0000000..3b1a614 --- /dev/null +++ b/scripts/smoke/angular/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(App, appConfig).catch((error) => console.error(error)); diff --git a/scripts/smoke/angular/src/telemetry.schema.ts b/scripts/smoke/angular/src/telemetry.schema.ts new file mode 100644 index 0000000..a3510e2 --- /dev/null +++ b/scripts/smoke/angular/src/telemetry.schema.ts @@ -0,0 +1,17 @@ +/** The application's names, as an existing consumer declares them. */ +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + 'render-home': true; + } + interface UwtScenarioSteps { + render: true; + } + interface UwtBIEventNames { + home_rendered: true; + } + interface UwtLoggerNames { + smoke: true; + } +} + +export {}; diff --git a/scripts/smoke/angular/tsconfig.json b/scripts/smoke/angular/tsconfig.json new file mode 100644 index 0000000..8b8c5e7 --- /dev/null +++ b/scripts/smoke/angular/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "strictTemplates": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/smoke/angular/variants/fragment.app.config.ts b/scripts/smoke/angular/variants/fragment.app.config.ts new file mode 100644 index 0000000..ee30ffb --- /dev/null +++ b/scripts/smoke/angular/variants/fragment.app.config.ts @@ -0,0 +1,24 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower'; +import { routes } from './app.routes'; + +/** A fragment: no RUM, and aws-rum-web is not installed. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetrySink('broadcast') + ] +}; diff --git a/scripts/smoke/angular/variants/shell.app.config.ts b/scripts/smoke/angular/variants/shell.app.config.ts new file mode 100644 index 0000000..2607f83 --- /dev/null +++ b/scripts/smoke/angular/variants/shell.app.config.ts @@ -0,0 +1,36 @@ +import { + ApplicationConfig, + provideZonelessChangeDetection +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + UWT_LOG_API_PROVIDER, + UwtNoopLogApiProvider +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER +} from '@absaoss-cps/ngx-ui-watchtower/rum'; +import { routes } from './app.routes'; + +/** A shell: AWS RUM, hosting its fragments. */ +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideUwtTelemetry({ + application: 'smoke', + environment: 'test', + version: '1.0.0' + }), + provideUwtTelemetryRumSink(), + { + provide: UWT_RUM_CREDENTIALS_PROVIDER, + useValue: { load: async () => null } + }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost() + ] +}; diff --git a/scripts/smoke/core-node/consumer.mjs b/scripts/smoke/core-node/consumer.mjs new file mode 100644 index 0000000..a3bfc11 --- /dev/null +++ b/scripts/smoke/core-node/consumer.mjs @@ -0,0 +1,79 @@ +/** + * A framework-free Node application using the installed core: no Angular, + * RxJS or aws-rum-web is installed beside it, and there is no browser. + */ +import assert from 'node:assert/strict'; +import { + UWT_TELEMETRY_EVENT_TYPE, + uwtCreateTelemetry, + UwtTelemetrySink +} from '@absaoss-cps/ui-watchtower'; + +assert.equal(typeof globalThis.window, 'undefined'); +assert.equal(typeof globalThis.document, 'undefined'); + +/** The application's own destination. */ +class Recording extends UwtTelemetrySink { + events = []; + + record(eventType, payload) { + this.events.push({ eventType, payload }); + } + + recordError() {} + + getSessionId() { + return 'session-1'; + } + + setUserId() {} + + getUserId() { + return undefined; + } + + flush() {} +} + +const destination = new Recording(); +const records = []; +const telemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination, + logApi: { + send: (record) => records.push(record), + query: async () => records + } +}); + +const scenario = telemetry.scenarios.start({ + name: 'import', + metadata: { token: 'secret' } +}); +scenario.step('parse'); +scenario.complete(); +telemetry.bi.track('file_imported', { rows: 3 }); +telemetry.logging.getLogger('import').warn('Slow import'); + +assert.deepEqual( + destination.events.map((event) => event.eventType), + [UWT_TELEMETRY_EVENT_TYPE.scenario, UWT_TELEMETRY_EVENT_TYPE.bi] +); +assert.equal(destination.events[0].payload.status, 'success'); +assert.equal(destination.events[0].payload.metadata.token, '[redacted]'); +assert.equal((await telemetry.logging.query()).length, 1); +telemetry.dispose(); + +// The RUM entry point loads aws-rum-web only once a sink starts, which +// without a page it never does: it is not installed here. +const { UwtRumTelemetrySink } = await import('@absaoss-cps/ui-watchtower/rum'); +const rum = new UwtRumTelemetrySink({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + credentialsProvider: { + load: async () => assert.fail('a sink without a page never starts') + } +}); +await rum.init(); +rum.dispose(); + +console.log('core-only Node consumer: ok'); diff --git a/scripts/smoke/core-ts/nodom.ts b/scripts/smoke/core-ts/nodom.ts new file mode 100644 index 0000000..74df43b --- /dev/null +++ b/scripts/smoke/core-ts/nodom.ts @@ -0,0 +1,17 @@ +/** + * A project without the DOM typings — a Node service, a worker — compiles + * against the core's main entry point: its declarations need none. + */ +import { + uwtCreateTelemetry, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtTelemetry +} from '@absaoss-cps/ui-watchtower'; + +export const telemetry: UwtTelemetry = uwtCreateTelemetry({ + identity: { application: 'worker', environment: 'test', version: '1.0.0' }, + destination: new UwtNoopTelemetrySink(), + logApi: new UwtNoopLogApiProvider(), + document: null +}); diff --git a/scripts/smoke/smoke.mjs b/scripts/smoke/smoke.mjs new file mode 100644 index 0000000..a37b9d1 --- /dev/null +++ b/scripts/smoke/smoke.mjs @@ -0,0 +1,410 @@ +/** + * Proves the packed packages work outside this workspace — no path alias, no + * source — installed from their tarballs, as an application installs them. + * + * npm run build && npm run smoke (-- --keep keeps the consumers) + * + * 1. Packs both packages and checks what each tarball holds. + * 2. A core-only Node consumer: no Angular, RxJS or aws-rum-web installed. + * 3. A core-only TypeScript consumer: the core's type tests, and a project + * without the DOM typings. + * 4. An unchanged Angular consumer, built and prerendered as a fragment + * without aws-rum-web installed, then as a shell with /rum; and the + * Angular package's type tests. + * + * The Angular package depends on a core version that may not be published + * yet, so every consumer resolves it to the packed core through `overrides`. + * Installs from the registry and npm's cache; writes nothing in the + * workspace. + */ +import { execFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('../..', import.meta.url)); +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const WORK = mkdtempSync(join(tmpdir(), 'uwt-smoke-')); +const KEEP = process.argv.includes('--keep'); +const workspace = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +/** The workspace's own range for a package, so consumers match what it tests. */ +function rangeOf(name) { + const range = + workspace.dependencies?.[name] ?? workspace.devDependencies?.[name]; + check(range, `the workspace declares ${name}`); + return range; +} + +function check(condition, what) { + if (!condition) { + throw new Error(`smoke: expected ${what}`); + } +} + +function run(command, args, cwd) { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, NG_CLI_ANALYTICS: 'false', CI: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024 + }); +} + +function step(message) { + console.log(`✔ ${message}`); +} + +/** Packs a built package, keeping its manifest and file list. */ +function pack(dist) { + const [packed] = JSON.parse( + run('npm', ['pack', '--json', '--pack-destination', WORK], join(ROOT, dist)) + ); + return { + ...packed, + tarball: join(WORK, packed.filename), + manifest: JSON.parse(readFileSync(join(ROOT, dist, 'package.json'), 'utf8')) + }; +} + +/** What every published tarball must, and must not, hold. */ +function checkTarball(pkg, optionalPeers) { + const files = new Set(pkg.files.map((file) => file.path)); + for (const required of ['README.md', 'LICENSE', 'package.json']) { + check(files.has(required), `${pkg.name} to ship ${required}`); + } + for (const [subpath, target] of Object.entries(pkg.manifest.exports)) { + if (subpath === './package.json') { + continue; + } + for (const file of [target.types, target.default]) { + check( + files.has(file.replace(/^\.\//, '')), + `${pkg.name} to ship ${file} for ${subpath}` + ); + } + } + for (const file of files) { + check( + !/(^|\/)src\//.test(file) && + !/\.spec\.|tsconfig|ng-package\.json/.test(file) && + !(file.endsWith('.ts') && !file.endsWith('.d.ts')), + `${pkg.name} not to ship ${file}` + ); + } + const { manifest } = pkg; + check(!manifest.private, `${pkg.name} not to be private`); + check( + manifest.publishConfig?.access === 'public', + `${pkg.name} to publish publicly` + ); + check(manifest.license === 'Apache-2.0', `${pkg.name} to carry its license`); + for (const [name, range] of Object.entries({ + ...manifest.dependencies, + ...manifest.peerDependencies + })) { + check( + !/^(file|link|workspace|portal):/.test(range), + `${pkg.name} to depend on ${name} by a published version, not ${range}` + ); + } + for (const peer of optionalPeers) { + check( + manifest.peerDependenciesMeta?.[peer]?.optional === true, + `${pkg.name} to declare ${peer} an optional peer` + ); + } +} + +/** A consumer project in its own directory, outside the workspace. */ +function consumer(name, manifest, template) { + const dir = join(WORK, name); + if (template) { + cpSync(join(HERE, template), dir, { recursive: true }); + } else { + mkdirSync(dir); + } + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: `uwt-smoke-${name}`, + version: '0.0.0', + private: true, + ...manifest + }, + null, + 2 + ) + ); + return dir; +} + +function install(dir, ...packages) { + run( + 'npm', + [ + 'install', + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel=error', + ...packages + ], + dir + ); +} + +/** Every installed copy of a package, by its directory. */ +function copiesOf(dir, name) { + const nodes = JSON.parse(run('npm', ['query', `#${name}`], dir)); + return new Set(nodes.map((node) => node.path)).size; +} + +/** Compiles a consumer's copy of a package's type tests. */ +function typeTests(dir, from, compiler, extra) { + cpSync(from, join(dir, 'type-tests'), { + recursive: true, + filter: (path) => !path.endsWith('.json') + }); + const base = { extends: '../tsconfig.json', include: [] }; + const configs = { + 'tsconfig.json': { files: ['compat.ts', 'empty-registry.ts'] }, + 'tsconfig.augmented.json': { files: ['augmented.ts'] } + }; + for (const [file, files] of Object.entries(configs)) { + writeFileSync( + join(dir, 'type-tests', file), + JSON.stringify({ + ...base, + compilerOptions: { noEmit: true, ...extra }, + ...files + }) + ); + run( + join(dir, 'node_modules', '.bin', compiler), + ['-p', `type-tests/${file}`], + dir + ); + } +} + +/** + * Builds the Angular consumer as one variant, prerendering its page; returns + * where the RUM sink and the RUM client (aws-rum-web) ended up. + */ +function buildAngular(dir, variant) { + cpSync( + join(dir, 'variants', `${variant}.app.config.ts`), + join(dir, 'src', 'app', 'app.config.ts') + ); + rmSync(join(dir, 'dist'), { recursive: true, force: true }); + run( + 'node', + [join(dir, 'node_modules', '@angular', 'cli', 'bin', 'ng.js'), 'build'], + dir + ); + const browser = join(dir, 'dist', 'browser'); + const page = readFileSync(join(browser, 'index.html'), 'utf8'); + check( + page.includes('scenario success'), + `the ${variant} page prerendered, its scenario run on the server` + ); + const code = Object.fromEntries( + readdirSync(browser) + .filter((file) => file.endsWith('.js')) + .map((file) => [file, readFileSync(join(browser, file), 'utf8')]) + ); + const files = Object.keys(code); + return { + page, + main: code['main.js'] ?? '', + // The sink's default endpoint, and the client's own API path: each in + // its own package only. + sink: files.filter((file) => code[file].includes('dataplane.rum')), + client: files.filter((file) => code[file].includes('appmonitors')) + }; +} + +try { + // 1. The tarballs. + const core = pack('dist/ui-watchtower'); + const ngx = pack('dist/ngx-ui-watchtower'); + checkTarball(core, ['aws-rum-web', 'rxjs']); + checkTarball(ngx, ['aws-rum-web']); + const coreRange = ngx.manifest.dependencies['@absaoss-cps/ui-watchtower']; + check( + coreRange?.replace(/^[\^~]/, '') === core.version, + `the Angular package to depend on the core it was built against (${core.version}), not ${coreRange}` + ); + check( + !ngx.manifest.peerDependencies['@absaoss-cps/ui-watchtower'], + 'the core to be a dependency of the Angular package, not a peer' + ); + step(`packed ${core.filename} and ${ngx.filename}: contents and manifests`); + + const coreSpec = `file:${core.tarball}`; + const overrides = { '@absaoss-cps/ui-watchtower': coreSpec }; + + // 2. A Node application, the core only. + const node = consumer( + 'core-node', + { + type: 'module', + dependencies: { '@absaoss-cps/ui-watchtower': coreSpec } + }, + 'core-node' + ); + install(node); + for (const absent of ['@angular/core', 'rxjs', 'aws-rum-web']) { + check( + !existsSync(join(node, 'node_modules', absent)), + `the core alone not to install ${absent}` + ); + } + run('node', ['consumer.mjs'], node); + step( + 'core-only Node consumer: no Angular, RxJS or aws-rum-web; runs, disposes' + ); + + // 3. A TypeScript application, the core only. + const ts = consumer( + 'core-ts', + { + dependencies: { + '@absaoss-cps/ui-watchtower': coreSpec, + rxjs: rangeOf('rxjs') + }, + devDependencies: { typescript: rangeOf('typescript') } + }, + 'core-ts' + ); + writeFileSync( + join(ts, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM'], + types: [], + skipLibCheck: false + } + }) + ); + writeFileSync( + join(ts, 'tsconfig.nodom.json'), + JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { noEmit: true, lib: ['ES2022'] }, + files: ['nodom.ts'] + }) + ); + install(ts); + typeTests(ts, join(ROOT, 'projects', 'ui-watchtower', 'type-tests'), 'tsc'); + run( + join(ts, 'node_modules', '.bin', 'tsc'), + ['-p', 'tsconfig.nodom.json'], + ts + ); + step( + 'core-only TypeScript consumer: type tests, augmentation, no DOM typings' + ); + + // 4. An Angular application, as it was written before the core existed. + const angular = [ + '@angular/common', + '@angular/compiler', + '@angular/core', + '@angular/platform-browser' + ]; + const app = consumer( + 'angular', + { + dependencies: { + ...Object.fromEntries(angular.map((name) => [name, rangeOf(name)])), + '@angular/platform-server': rangeOf('@angular/core'), + '@angular/router': rangeOf('@angular/core'), + '@angular/ssr': rangeOf('@angular/build'), + '@absaoss-cps/ngx-ui-watchtower': `file:${ngx.tarball}`, + rxjs: rangeOf('rxjs'), + tslib: rangeOf('tslib') + }, + devDependencies: { + '@angular/build': rangeOf('@angular/build'), + '@angular/cli': rangeOf('@angular/cli'), + '@angular/compiler-cli': rangeOf('@angular/compiler-cli'), + typescript: rangeOf('typescript') + }, + overrides + }, + 'angular' + ); + install(app); + check(copiesOf(app, '@angular/core') === 1, 'one copy of Angular'); + check( + copiesOf(app, '@absaoss-cps/ui-watchtower') === 1, + 'one copy of the core, installed with the Angular package' + ); + check( + !existsSync(join(app, 'node_modules', 'aws-rum-web')), + 'aws-rum-web not installed for an application without /rum' + ); + const fragment = buildAngular(app, 'fragment'); + check( + !fragment.sink.length && !fragment.client.length, + 'neither the RUM sink nor the RUM client in a build without /rum' + ); + step( + 'Angular fragment without aws-rum-web: built, prerendered, one Angular, one core' + ); + typeTests( + app, + join(ROOT, 'projects', 'ngx-ui-watchtower', 'type-tests'), + 'ngc' + ); + step( + 'Angular consumer type tests: every export, augmentation, compiled by ngc' + ); + + install(app, `aws-rum-web@${rangeOf('aws-rum-web')}`); + const shell = buildAngular(app, 'shell'); + const [client] = shell.client; + check( + shell.client.length === 1 && client !== 'main.js', + `the RUM client in a chunk of its own, not in main.js: ${shell.client}` + ); + check( + shell.main.includes(`import("./${client}")`) && + !shell.main.includes(`from"./${client}"`) && + !shell.page.includes(client), + 'the RUM client loaded on demand only, never up front' + ); + step( + 'Angular shell with /rum: built, prerendered, aws-rum-web loaded on demand' + ); + + if (KEEP) { + console.log(`smoke: consumers kept in ${WORK}`); + } else { + rmSync(WORK, { recursive: true, force: true }); + } + console.log('smoke: ok'); +} catch (error) { + console.error(error.stdout ?? '', error.stderr ?? ''); + console.error(error.message); + console.error(`smoke: consumers kept in ${WORK}`); + process.exitCode = 1; +}