diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index d32c598..3b83e59 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -81,6 +81,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 9f6cc03..8527ec9 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` | Builds both packages, then compares their public APIs |
| `npm run api:update` | Builds both packages, then rewrites their snapshots |
+| `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 16e90a8..330c96f 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": "npm run build && node scripts/api-snapshot.mjs",
- "api:update": "npm run build && node scripts/api-snapshot.mjs --update"
+ "api:update": "npm run build && 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;
+}