diff --git a/CLAUDE.md b/CLAUDE.md index 6a3bb89..d59d8d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,9 @@ src/ products/ # list, get, create, update paywalls/ # list, get, create, update, placements (placements using a paywall) placements/ # list, get, create, update (audiences[] or deprecated --paywall-id) - flows/ # list, get, create; config/ (get, update — builder config with optimistic lock) + flows/ # list, get, create; config/ (get, update — builder config with optimistic lock; + # preview — local config → render URL, opens on a TTY, prints bare URL when piped; + # capture is the caller's job, the CLI only builds the URL) segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, @@ -36,10 +38,12 @@ src/ errors.ts # ApiError, NetworkError, AuthRequiredError flags.ts # shared flags: --app (UUID), pagination output.ts # printResponse(), printList() helpers (auto-formats snake_case keys) + app-url.ts # dashboard base URL (ADAPTY_APP_URL): route building + rehosting API-issued links asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa') asa-flags.ts # shared asa flags: scope filters, period, money, batch caps asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json) asa-schemas.ts # response typings for asa entities + preview.ts # flow config normalization + render URL / gzip fragment building ``` ## Conventions @@ -52,6 +56,16 @@ src/ - Auth token stored at `~/.config/adapty/config.json` (mode 0o600) - `ADAPTY_TOKEN` env overrides stored token - `ADAPTY_API_URL` env overrides default API base URL +- `ADAPTY_APP_URL` env sets the dashboard base URL (default `https://app.adapty.io`), via `lib/app-url.ts`: + `flows config preview` builds the fixed `/flow-preview` route on it, and `auth login` rehosts the + API-issued verification link onto it (only when the env var is set — the API may serve that link from + another host) +- `flows config preview` only builds a URL: no browser automation, no Playwright, no screenshot. Capture + belongs to the caller (its own browser tool, or the flow skill's reference script) +- `flows config preview` always carries the config in the URL fragment; there is no file hand-off flag. It is + a quick-look escape hatch for small configs — past ~32KB of pretty-printed JSON the render page turns slow + and unreliable. The URL is long either way (~113K chars for a 668KB flow), so callers pipe it into the + screenshot tool rather than print or read it - API base: `https://api-admin.adapty.io/api/v1/developer` - `asa` topic talks to its own service: base `https://api-asa-admin.adapty.io/api/v1/cli`, overridden by `ADAPTY_ASA_API_URL`; same bearer token, but errors follow the ASA shape (per-item `errors[]`, FastAPI diff --git a/README.md b/README.md index 3ad0874..bea8316 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,33 @@ and the outcome shows up in `adapty asa automations runs`. | `--page` | Page number (default: 1) | | `--page-size` | Items per page (default: 20, max: 100; `asa` commands: default 100, max 1000) | +## Paywall Preview + +`adapty flows config preview ` turns a local flow config into a render URL. On a TTY it opens the +browser; piped or with `--json` it prints the URL alone. Screenshotting is the caller's job — the CLI only +builds the URL. + +> **Small configs only.** This is a quick-look escape hatch. The config travels in the URL fragment, and past +> roughly **32KB of pretty-printed JSON** the render page becomes slow and unreliable. Trim to the screen you +> care about, or preview the saved flow in the dashboard builder instead. + +> **That URL is huge.** Even a config the page renders happily produces thousands of characters, and a 668KB +> flow yields ~113,000. Nobody should read it — **agents especially should never let it into their context.** +> Pipe it into whatever captures the screenshot: +> +> ```sh +> adapty flows config preview flow.json --screen scr_abc | node capture.mjs --out shot.png +> +> # or, for a tool that wants a flag instead of stdin: +> node capture.mjs --url "$(adapty flows config preview flow.json --screen scr_abc)" --out shot.png +> ``` +> +> Prefer the pipe: it has no size limit, while an argument is capped by the shell (~1MB, so a config around +> 6MB). + +See [skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for +the flags and the size ceiling. + ## Environment Variables | Variable | Description | @@ -263,6 +290,7 @@ and the outcome shows up in `adapty asa automations runs`. | `ADAPTY_TOKEN` | Override stored auth token | | `ADAPTY_API_URL` | Override Developer API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) | | `ADAPTY_ASA_API_URL` | Override Apple Search Ads base URL (default: `https://api-asa-admin.adapty.io/api/v1/cli`) | +| `ADAPTY_APP_URL` | Override dashboard base URL (default: `https://app.adapty.io`). Used by `flows config preview` for the fixed `/flow-preview` route, and by `auth login` to keep the verification link on that host | The two API URLs are independent: pointing `ADAPTY_API_URL` at a staging host leaves `adapty asa` on the ASA default, and the other way round. diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 883c092..0e59c14 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -94,6 +94,64 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `access-levels create` | `--app`, `--sdk-id`, `--title` | | `access-levels update ` | `--app`, `--title` | +## Preview + +| Command | Required flags | +|-------------------------------------------|----------------| +| `flows config preview ` | none | + +Takes a **local** flow config JSON file, normalizes it, and builds a render URL that carries the whole config +in its gzipped fragment. **Treat it as a quick-look escape hatch for small configs:** past roughly **32KB of +pretty-printed JSON** the render page turns slow and unreliable, so trim to the screen you are working on +rather than throwing a whole 600KB flow at it. **No API call and no `--app`.** The CLI does not screenshot anything — it owns the +fragment format, capture is yours: open the URL with your browser/computer-use tool and screenshot the +`[data-screen-content]` element. + +Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config; both +normalize to `{flow, remoteConfigs}` (camelCase: that payload is a wire format shared with the render page). +`screens` must be an array — that is what the render page's own payload guard requires, so the CLI rejects +anything it would reject. + +Render page location is **env-only**: `ADAPTY_APP_URL` (default `https://app.adapty.io`) sets the host; the +`/flow-preview` route is fixed and there is no flag for it. The same env var also moves `auth login`'s +verification link onto that host, so a local or staging dashboard stays consistent across both commands. + +Flags: `--screen` (default: the render page falls back to the flow's first screen), `--device` (default: +`iphone-14`), `--orientation` (`portrait` | `landscape`, default `portrait`). + +Output depends on where stdout goes, because the URL is far too long to read: + +- **TTY** — opens the URL in the browser and prints a one-line confirmation, not the URL. +- **Piped or redirected** — prints the bare URL and nothing else. +- **`--json`** — `{render_url}`, and never opens a browser. + +⚠️ **Never read this command's output.** Piped or `--json`, it emits one very long line — thousands of +characters even for a config the page renders well, ~113,000 for a 668KB flow — because the entire config is +gzipped into the fragment. Running it as a bare command and letting the output land in your transcript burns +context for zero information. Always hand it to the next process instead — and never `echo`, `cat` or +`--json | jq .render_url` it just to look. + +`render_url` is `/flow-preview?screen=&device=&orientation=#config=`. +The fragment is gzipped unconditionally and carries **no prefix** — the page compresses too, so there is no +plain shape to mark it apart from. An unknown `device` renders an error message instead of a screen, so pass +one the builder knows. + +**Keep the URL out of your context.** Pipe it straight into whatever captures the screenshot — stdin has no +size limit: + +```sh +adapty flows config preview flow.json --screen scr_abc | node capture.mjs --out shot.png +``` + +If the tool insists on a flag, command substitution works too, capped by the shell's ~1MB argument limit (a +config around 6MB, since configs compress roughly 6x): + +```sh +node capture.mjs --url "$(adapty flows config preview flow.json --screen scr_abc)" --out shot.png +``` + +There is no file-based hand-off flag: the config always rides in the URL. + ## Apple Search Ads (`asa` topic) Different service behind the same token. **No `--app`**: every command is scoped to the company the token diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 297ee3a..f33c52d 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -2,6 +2,7 @@ import {Command} from '@oclif/core' import open from 'open' import {ApiClient} from '../../lib/api-client.js' +import {onAppHost} from '../../lib/app-url.js' import {buildUserAgent} from '../../lib/client-from-config.js' import {readConfig, writeConfig} from '../../lib/config.js' import {ApiError} from '../../lib/errors.js' @@ -53,12 +54,14 @@ static examples = ['<%= config.bin %> auth login'] this.error(error instanceof Error ? error.message : 'Failed to initiate auth flow', {exit: 1}) } + const verificationUrl = this.verificationUrl(device.verification_uri_complete) + this.log(`\nYour code: ${device.user_code}\n`) - this.log(`If browser doesn't open, visit: ${device.verification_uri_complete}\n`) + this.log(`If browser doesn't open, visit: ${verificationUrl}\n`) if (process.stdin.isTTY === true) { try { - await open(device.verification_uri_complete) + await open(verificationUrl) } catch { // browser open failed silently — URL already printed } @@ -150,4 +153,13 @@ static examples = ['<%= config.bin %> auth login'] process.removeListener('SIGINT', onSignal) } } + + /** Keeps the browser on the configured dashboard host, when ADAPTY_APP_URL asks for one. */ + private verificationUrl(issuedUrl: string): string { + try { + return onAppHost(issuedUrl) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), {exit: 2}) + } + } } diff --git a/src/commands/flows/config/preview.ts b/src/commands/flows/config/preview.ts new file mode 100644 index 0000000..35cf46a --- /dev/null +++ b/src/commands/flows/config/preview.ts @@ -0,0 +1,91 @@ +import {Args, Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' +import {resolve} from 'node:path' +import open from 'open' + +import {APP_URL_ENV_VAR} from '../../../lib/app-url.js' +import { + buildRenderUrl, + DEFAULT_DEVICE_ID, + DEFAULT_ORIENTATION, + normalizePreviewConfig, + ORIENTATIONS, + type PreviewPayload, +} from '../../../lib/preview.js' + +export interface PreviewResult { + render_url: string +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export default class FlowsConfigPreview extends Command { + static args = { + config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}), + } +static description = `Build a render URL for a local flow config and open it. A quick-look escape hatch for small configs: the whole config rides in the URL fragment, and past roughly 32KB of pretty-printed JSON the render page gets slow and unreliable. Opens the browser on a TTY; when piped or with --json, prints the URL alone — and that URL is long (~113K characters for a 668KB flow), so pipe it into whatever takes the screenshot ("| node capture.mjs") or pass it with --url and command substitution. Never print or read it: agents burn context for zero information. The render host comes from $${APP_URL_ENV_VAR}.` +static enableJsonFlag = true +static examples = [ + '<%= config.bin %> flows config preview ./config.json', + '<%= config.bin %> flows config preview ./config.json --screen welcome --device ipad-pro --orientation landscape', + '# pipe the URL straight into a screenshot tool, never print it\n<%= config.bin %> flows config preview ./config.json | node capture.mjs --out shot.png', + '# or pass it as an argument, for tools that want a flag\nnode capture.mjs --url "$(<%= config.bin %> flows config preview ./config.json)" --out shot.png', + ] +static flags = { + device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}), + orientation: Flags.string({ + default: DEFAULT_ORIENTATION, + description: 'Device orientation to render in', + options: [...ORIENTATIONS], + }), + screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}), + } + + async run(): Promise { + const {args, flags} = await this.parse(FlowsConfigPreview) + + const configPath = resolve(args.config_file) + let raw: unknown + try { + raw = JSON.parse(await readFile(configPath, 'utf8')) + } catch (error) { + this.error(`Could not read config file ${configPath}: ${describeError(error)}`, {exit: 2}) + } + + let payload: PreviewPayload + try { + payload = normalizePreviewConfig(raw) + } catch (error) { + this.error(describeError(error), {exit: 2}) + } + + let renderUrl: string + try { + renderUrl = buildRenderUrl({device: flags.device, orientation: flags.orientation, screen: flags.screen}, payload) + } catch (error) { + this.error(describeError(error), {exit: 2}) + } + + const result: PreviewResult = {render_url: renderUrl} + if (this.jsonEnabled()) return result + + // Piped output stays a bare URL so it composes — the whole config rides in the fragment, which + // is why a TTY gets the browser opened instead of a screenful of base64. + if (process.stdout.isTTY !== true) { + this.log(renderUrl) + return result + } + + const target = [flags.screen ?? 'first screen', flags.device, flags.orientation].join(', ') + try { + await open(renderUrl) + this.log(`Opened the preview in your browser (${target}).`) + } catch { + this.log(renderUrl) + } + + return result + } +} diff --git a/src/lib/app-url.ts b/src/lib/app-url.ts new file mode 100644 index 0000000..c9034c2 --- /dev/null +++ b/src/lib/app-url.ts @@ -0,0 +1,35 @@ +export const APP_URL_ENV_VAR = 'ADAPTY_APP_URL' +export const DEFAULT_APP_URL = 'https://app.adapty.io' + +/** Dashboard origin: ADAPTY_APP_URL when set, production otherwise. Only its origin is used. */ +function appBaseUrl(): URL { + const base = process.env[APP_URL_ENV_VAR] ?? DEFAULT_APP_URL + try { + return new URL(base) + } catch { + throw new Error(`Invalid ${APP_URL_ENV_VAR}: ${base}`) + } +} + +/** Builds a dashboard URL for a fixed route, e.g. the flow preview page. */ +export function appUrl(path: string): URL { + return new URL(path, appBaseUrl()) +} + +/** + * Moves a link the API issued (the device-flow verification URI) onto the configured dashboard + * host, so pointing the CLI at a local or staging dashboard keeps the browser there too. Without + * ADAPTY_APP_URL the link is left exactly as issued — the API is free to serve it from any host. + */ +export function onAppHost(issuedUrl: string): string { + if (!process.env[APP_URL_ENV_VAR]) return issuedUrl + + let issued: URL + try { + issued = new URL(issuedUrl) + } catch { + return issuedUrl + } + + return appUrl(`${issued.pathname}${issued.search}${issued.hash}`).toString() +} diff --git a/src/lib/preview.ts b/src/lib/preview.ts new file mode 100644 index 0000000..3c257f8 --- /dev/null +++ b/src/lib/preview.ts @@ -0,0 +1,70 @@ +import {gzipSync} from 'node:zlib' + +import {appUrl} from './app-url.js' + +/** The render route is fixed; only its host is configurable, via ADAPTY_APP_URL. */ +export const PREVIEW_PATH = '/flow-preview' +export const DEFAULT_DEVICE_ID = 'iphone-14' +/** Orientations the render page accepts; anything else falls back to its own default. */ +export const ORIENTATIONS = ['landscape', 'portrait'] as const +export const DEFAULT_ORIENTATION = 'portrait' + +/** Payload the render page expects. Wire format shared with the UI, hence camelCase. */ +export interface PreviewPayload { + flow: Record + remoteConfigs: unknown[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare + * builder config, and returns the shape the render page injects. + * + * `screens` must be an array: that is exactly what the page's own payload guard checks + * before it treats the payload as a builder draft, so a config without it would be + * rejected there instead — better to say so here, against the file the user named. + */ +export function normalizePreviewConfig(raw: unknown): PreviewPayload { + if (!isRecord(raw)) { + throw new Error('Config file must contain a JSON object.') + } + + const envelopeConfig = isRecord(raw.config) ? raw.config : null + const flow = envelopeConfig ?? raw + if (!Array.isArray(flow.screens)) { + throw new TypeError( + 'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a bare builder config — either way `screens` must be an array.', + ) + } + + const remoteConfigs = envelopeConfig && Array.isArray(raw.remote_configs) ? raw.remote_configs : [] + return {flow, remoteConfigs} +} + +/** + * Fragment wire format shared with the render page: bare `base64url(gzip(utf8(JSON)))`, no + * prefix — the page compresses unconditionally too, so there is no plain shape to mark it + * apart from. Node's base64url already omits the `=` padding the page strips. + */ +function encodeConfigFragment(payload: PreviewPayload): string { + return gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')).toString('base64url') +} + +export interface RenderTarget { + device: string + orientation: string + /** Omitted lets the render page fall back to the flow's first screen. */ + screen?: string +} + +export function buildRenderUrl(target: RenderTarget, payload: PreviewPayload): string { + const url = appUrl(PREVIEW_PATH) + if (target.screen) url.searchParams.set('screen', target.screen) + url.searchParams.set('device', target.device) + url.searchParams.set('orientation', target.orientation) + url.hash = `config=${encodeConfigFragment(payload)}` + return url.toString() +} diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 71b47f5..04fe8c9 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -29,6 +29,7 @@ describe('auth login', () => { afterEach(() => { restoreFetch(fetchStub) + delete process.env.ADAPTY_APP_URL }) it('calls POST /auth/device then POST /auth/token', async () => { @@ -47,4 +48,18 @@ describe('auth login', () => { stub: fetchStub, }) }) + + it('points the verification link at ADAPTY_APP_URL when one is configured', async () => { + process.env.ADAPTY_APP_URL = 'http://localhost:3000' + const {stdout} = await runCommand('auth login') + + expect(stdout).to.contain('http://localhost:3000/activate?code=TEST-CODE') + expect(stdout).to.not.contain('https://auth.adapty.io') + }) + + it('leaves the verification link as issued without ADAPTY_APP_URL', async () => { + const {stdout} = await runCommand('auth login') + + expect(stdout).to.contain('https://auth.adapty.io/activate?code=TEST-CODE') + }) }) diff --git a/test/commands/flows-preview.test.ts b/test/commands/flows-preview.test.ts new file mode 100644 index 0000000..8d52bc2 --- /dev/null +++ b/test/commands/flows-preview.test.ts @@ -0,0 +1,61 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {fileURLToPath} from 'node:url' + +import type {PreviewResult} from '../../src/commands/flows/config/preview.js' + +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) + +describe('flows config preview', () => { + beforeEach(() => { + process.env.ADAPTY_APP_URL = 'https://app.example' + }) + + afterEach(() => { + delete process.env.ADAPTY_APP_URL + }) + + it('prints a render URL carrying the config in the fragment', async () => { + const {result} = await runCommand(['flows:config:preview', FIXTURE_PATH, '--json']) + if (!result) throw new Error('preview returned no result') + + expect( + result.render_url.startsWith('https://app.example/flow-preview?device=iphone-14&orientation=portrait#config='), + ).to.equal(true) + }) + + it('puts the requested screen and orientation in the URL', async () => { + const {result} = await runCommand([ + 'flows:config:preview', + FIXTURE_PATH, + '--screen', + 'offer', + '--orientation', + 'landscape', + '--json', + ]) + + expect(result?.render_url).to.contain('screen=offer') + expect(result?.render_url).to.contain('orientation=landscape') + }) + + it('rejects an orientation the render page does not accept', async () => { + const {error} = await runCommand(['flows:config:preview', FIXTURE_PATH, '--orientation', 'sideways']) + expect(error?.message).to.contain('sideways') + }) + + it('never repeats the config: --json output is about the size of one fragment', async () => { + const {result} = await runCommand(['flows:config:preview', FIXTURE_PATH, '--json']) + if (!result) throw new Error('preview returned no result') + + const fragment = result.render_url.slice(result.render_url.indexOf('#config=') + '#config='.length) + expect(JSON.stringify(result).length).to.be.lessThan(fragment.length * 1.5) + }) + + it('prints the URL alone when stdout is piped, so it can be composed', async () => { + const {stdout} = await runCommand(['flows:config:preview', FIXTURE_PATH]) + + expect(stdout.trim().split('\n')).to.have.length(1) + expect(stdout.trim().startsWith('https://app.example/flow-preview?')).to.equal(true) + }) +}) diff --git a/test/fixtures/flow-config.json b/test/fixtures/flow-config.json new file mode 100644 index 0000000..777fb44 --- /dev/null +++ b/test/fixtures/flow-config.json @@ -0,0 +1,66 @@ +{ + "config": { + "id": "9f1b7c4e-6d2a-4f58-8b3d-1a0c5e7d9f21", + "default_locale": "en", + "locales": ["en"], + "theme": { + "colors": {"accent": "#5B4DF5", "background": "#FFFFFF", "text_primary": "#111114"}, + "radius": {"button": 14}, + "typography": {"body": {"size": 15}, "title": {"size": 28, "weight": 700}} + }, + "screens": [ + { + "id": "welcome", + "type": "onboarding", + "elements": [ + {"content_key": "welcome.title", "id": "welcome_title", "style": "title", "type": "text"}, + { + "action": {"screen_id": "offer", "type": "navigate"}, + "content_key": "welcome.cta", + "id": "welcome_cta", + "type": "button" + } + ] + }, + { + "id": "offer", + "type": "paywall", + "elements": [ + {"content_key": "offer.title", "id": "offer_title", "style": "title", "type": "text"}, + { + "id": "offer_products", + "products": [ + {"badge_key": "offer.badge_best_value", "product_id": "premium_yearly", "selected": true}, + {"badge_key": null, "product_id": "premium_monthly", "selected": false} + ], + "type": "product_list" + }, + {"action": {"type": "purchase"}, "content_key": "offer.purchase", "id": "offer_purchase", "type": "button"} + ] + }, + { + "id": "offer_discount", + "type": "paywall", + "shown_when": {"screen_id": "offer", "type": "close_attempt"}, + "elements": [ + {"content_key": "discount.title", "id": "discount_title", "style": "title", "type": "text"}, + {"action": {"type": "close"}, "content_key": "discount.close", "id": "discount_close", "type": "link"} + ] + } + ], + "localizations": { + "en": { + "discount.close": "No thanks", + "discount.title": "One last thing", + "offer.badge_best_value": "Best value", + "offer.purchase": "Start free trial", + "offer.title": "Unlock Premium", + "welcome.cta": "Get started", + "welcome.title": "Track everything that matters" + } + } + }, + "remote_configs": [{"data": {"experiment": "onboarding_v4"}, "locale": "en"}], + "status": "draft", + "updated_at": "2026-08-14T09:12:44Z" +} diff --git a/test/lib/app-url.test.ts b/test/lib/app-url.test.ts new file mode 100644 index 0000000..5d2fafb --- /dev/null +++ b/test/lib/app-url.test.ts @@ -0,0 +1,41 @@ +import {expect} from 'chai' + +import {APP_URL_ENV_VAR, appUrl, onAppHost} from '../../src/lib/app-url.js' + +describe('app url', () => { + afterEach(() => { + delete process.env[APP_URL_ENV_VAR] + }) + + it('builds a route on the production dashboard by default', () => { + expect(appUrl('/flow-preview').toString()).to.equal('https://app.adapty.io/flow-preview') + }) + + it('builds a route on the configured host', () => { + process.env[APP_URL_ENV_VAR] = 'http://localhost:3000' + expect(appUrl('/flow-preview').toString()).to.equal('http://localhost:3000/flow-preview') + }) + + it('rejects a host it cannot parse', () => { + process.env[APP_URL_ENV_VAR] = 'not a url' + expect(() => appUrl('/flow-preview')).to.throw(APP_URL_ENV_VAR) + }) + + it('leaves an API-issued link alone when no host is configured', () => { + expect(onAppHost('https://auth.adapty.io/activate?code=TEST-CODE')).to.equal( + 'https://auth.adapty.io/activate?code=TEST-CODE', + ) + }) + + it('moves an API-issued link onto the configured host, keeping path, query and hash', () => { + process.env[APP_URL_ENV_VAR] = 'http://localhost:3000' + expect(onAppHost('https://auth.adapty.io/activate?code=TEST-CODE#x')).to.equal( + 'http://localhost:3000/activate?code=TEST-CODE#x', + ) + }) + + it('passes through a link it cannot parse', () => { + process.env[APP_URL_ENV_VAR] = 'http://localhost:3000' + expect(onAppHost('not a url')).to.equal('not a url') + }) +}) diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts new file mode 100644 index 0000000..39df2db --- /dev/null +++ b/test/lib/preview.test.ts @@ -0,0 +1,100 @@ +import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {gunzipSync} from 'node:zlib' + +import {APP_URL_ENV_VAR} from '../../src/lib/app-url.js' +import {buildRenderUrl, normalizePreviewConfig} from '../../src/lib/preview.js' + +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) +const FIXTURE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown + +/** + * Decodes the fragment the way the render page does, to prove the wire format round-trips: + * the page reads `config=` out of the hash by regex and gunzips it, with no prefix to strip. + */ +function decodeConfigFragment(hash: string): unknown { + const encoded = /(?:^|[#&])config=([^&]*)/.exec(hash)?.[1] + if (!encoded) throw new Error(`No config fragment in ${hash}`) + return JSON.parse(gunzipSync(Buffer.from(encoded, 'base64url')).toString('utf8')) as unknown +} + +describe('preview', () => { + describe('config normalization', () => { + it('maps a dashboard-api envelope to the injection payload', () => { + const payload = normalizePreviewConfig({ + config: {screens: [{id: 'welcome'}]}, + remote_configs: [{locale: 'en'}], + status: 'draft', + updated_at: '2026-02-19T00:00:00Z', + }) + + expect(payload).to.deep.equal({flow: {screens: [{id: 'welcome'}]}, remoteConfigs: [{locale: 'en'}]}) + }) + + it('defaults missing remote_configs to an empty list', () => { + expect(normalizePreviewConfig({config: {screens: []}}).remoteConfigs).to.deep.equal([]) + }) + + it('wraps a bare builder config', () => { + const flow = {locales: [{code: 'en'}], screens: [{id: 'welcome'}], theme: {}} + expect(normalizePreviewConfig(flow)).to.deep.equal({flow, remoteConfigs: []}) + }) + + it('rejects a config the render page would reject: no screens array', () => { + expect(() => normalizePreviewConfig({hello: 'world'})).to.throw('`screens` must be an array') + expect(() => normalizePreviewConfig({locales: [], theme: {}})).to.throw('`screens` must be an array') + expect(() => normalizePreviewConfig({config: {theme: {}}})).to.throw('`screens` must be an array') + expect(() => normalizePreviewConfig({screens: {welcome: {}}})).to.throw('`screens` must be an array') + expect(() => normalizePreviewConfig([1, 2])).to.throw('must contain a JSON object') + }) + }) + + describe('render url', () => { + afterEach(() => { + delete process.env[APP_URL_ENV_VAR] + }) + + const target = {device: 'iphone-14', orientation: 'portrait'} as const + + it('defaults to the dashboard host and the fixed preview route', () => { + const url = new URL(buildRenderUrl(target, normalizePreviewConfig(FIXTURE))) + + expect(url.origin).to.equal('https://app.adapty.io') + expect(url.pathname).to.equal('/flow-preview') + }) + + it('takes the host from ADAPTY_APP_URL, keeping the route', () => { + process.env[APP_URL_ENV_VAR] = 'http://localhost:3000' + const url = new URL(buildRenderUrl(target, normalizePreviewConfig(FIXTURE))) + + expect(url.origin).to.equal('http://localhost:3000') + expect(url.pathname).to.equal('/flow-preview') + }) + + it('rejects a host it cannot parse', () => { + process.env[APP_URL_ENV_VAR] = 'not a url' + expect(() => buildRenderUrl(target, normalizePreviewConfig(FIXTURE))).to.throw(APP_URL_ENV_VAR) + }) + + it('omits the screen param when none was asked for, letting the page pick the first', () => { + const url = new URL(buildRenderUrl(target, normalizePreviewConfig(FIXTURE))) + + expect(url.searchParams.get('screen')).to.equal(null) + expect(url.searchParams.get('device')).to.equal('iphone-14') + expect(url.searchParams.get('orientation')).to.equal('portrait') + }) + + it('carries a real config through the gzipped fragment, unprefixed and padding-free', () => { + const payload = normalizePreviewConfig(FIXTURE) + const url = new URL( + buildRenderUrl({device: 'ipad-pro', orientation: 'landscape', screen: 'offer'}, payload), + ) + + expect(url.searchParams.get('screen')).to.equal('offer') + expect(url.searchParams.get('orientation')).to.equal('landscape') + expect(url.hash.slice('#config='.length)).to.match(/^[\w-]+$/) + expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) + }) + }) +})