From 219a44d3eb3db3d4ae37e4129071d63fb9d419a8 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Tue, 18 Aug 2026 12:40:47 +0500 Subject: [PATCH 01/12] feat(preview): render a local flow config to a PNG with headless chromium --- README.md | 1 + package.json | 5 +- pnpm-lock.yaml | 32 +++++ skills/adapty-cli/references/cli-commands.md | 16 +++ src/commands/preview.ts | 77 ++++++++++++ src/lib/preview-config.ts | 72 +++++++++++ src/lib/preview-render.ts | 124 +++++++++++++++++++ test/lib/preview.test.ts | 72 +++++++++++ 8 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/commands/preview.ts create mode 100644 src/lib/preview-config.ts create mode 100644 src/lib/preview-render.ts create mode 100644 test/lib/preview.test.ts diff --git a/README.md b/README.md index 3ad0874..89e57d2 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,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_PREVIEW_RENDER_URL` | Base URL of the public paywall render page used by `adapty preview` (no default) | 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/package.json b/package.json index ddb578b..b605aed 100644 --- a/package.json +++ b/package.json @@ -145,5 +145,8 @@ "test": "mocha --forbid-only \"test/**/*.test.ts\"", "version": "oclif readme && git add README.md" }, - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "optionalDependencies": { + "playwright": "^1.62.1" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4d7c7..a68631e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,10 @@ importers: typescript: specifier: ^5 version: 5.9.3 + optionalDependencies: + playwright: + specifier: ^1.62.1 + version: 1.62.1 packages: @@ -1717,6 +1721,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2400,6 +2409,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5277,6 +5296,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5975,6 +5997,16 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.62.1: + optional: true + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + optional: true + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 883c092..0b30143 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -94,6 +94,22 @@ 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 | +|-------------------------------|----------------| +| `preview ` | none (needs a render URL) | + +Renders a **local** flow config JSON file to a PNG with headless Chromium — no API call, no `--app`. Accepts +either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config (`screens` / +`locales` / `theme`); both normalize to `{flow, remoteConfigs}` before injection. + +Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: first screen in the +config), `--device` (default: `iphone-14`), `--out` (default: temp file), `--timeout` (default: 30000 ms). +The PNG path is printed, and returned as `{device, path, screen}` under `--json`. + +Requires Playwright's chromium: `npx playwright install chromium`. + ## 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/preview.ts b/src/commands/preview.ts new file mode 100644 index 0000000..00fd806 --- /dev/null +++ b/src/commands/preview.ts @@ -0,0 +1,77 @@ +import {Args, Command, Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' +import {resolve} from 'node:path' + +import {printResponse} from '../lib/output.js' +import {firstScreenId, normalizePreviewConfig} from '../lib/preview-config.js' +import { + DEFAULT_DEVICE_ID, + defaultScreenshotPath, + RENDER_URL_ENV_VAR, + renderPreview, + resolveRenderUrl, +} from '../lib/preview-render.js' + +export interface PreviewResult { + device: string + path: string + screen?: string +} + +export default class Preview extends Command { + static args = { + config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}), + } +static description = 'Render a local flow config to a PNG screenshot with a headless browser' +static enableJsonFlag = true +static examples = [ + '<%= config.bin %> preview ./paywall.json', + '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --out ./preview.png', + ] +static flags = { + device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}), + out: Flags.string({description: 'Where to write the PNG (default: a temp file)'}), + 'render-url': Flags.string({description: `Render page base URL (defaults to $${RENDER_URL_ENV_VAR})`}), + screen: Flags.string({description: 'Screen ID to render (default: first screen in the config)'}), + timeout: Flags.integer({default: 30_000, description: 'Per-step timeout in milliseconds', min: 1000}), + } + + async run(): Promise { + const {args, flags} = await this.parse(Preview) + + 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}: ${error instanceof Error ? error.message : String(error)}`, { + exit: 2, + }) + } + + let payload + let renderUrl + try { + payload = normalizePreviewConfig(raw) + renderUrl = resolveRenderUrl(flags['render-url']) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), {exit: 2}) + } + + const screen = flags.screen ?? firstScreenId(payload.flow) + const outPath = flags.out ? resolve(flags.out) : await defaultScreenshotPath() + const path = await renderPreview({ + device: flags.device, + outPath, + payload, + renderUrl, + screen, + timeoutMs: flags.timeout, + }) + + const result: PreviewResult = {device: flags.device, path, screen} + printResponse(result as unknown as Record, this.log.bind(this)) + + return result + } +} diff --git a/src/lib/preview-config.ts b/src/lib/preview-config.ts new file mode 100644 index 0000000..1910417 --- /dev/null +++ b/src/lib/preview-config.ts @@ -0,0 +1,72 @@ +import {mkdtemp, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +/** Payload the render page expects: the builder config plus its remote configs. */ +export interface PreviewPayload { + flow: Record + remoteConfigs: unknown[] +} + +const BUILDER_CONFIG_KEYS = ['screens', 'locales', 'theme'] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isBuilderConfig(value: Record): boolean { + return BUILDER_CONFIG_KEYS.some((key) => key in value) +} + +/** + * Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare + * builder config, and returns the shape the render page injects. + */ +export function normalizePreviewConfig(raw: unknown): PreviewPayload { + if (!isRecord(raw)) { + throw new Error('Config file must contain a JSON object.') + } + + if (isRecord(raw.config)) { + const remoteConfigs = raw.remote_configs + return { + flow: raw.config, + remoteConfigs: Array.isArray(remoteConfigs) ? remoteConfigs : [], + } + } + + if (isBuilderConfig(raw)) { + return {flow: raw, remoteConfigs: []} + } + + throw new Error( + 'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a builder config with `screens`, `locales` or `theme`.', + ) +} + +/** First screen id declared by the config, used when `--screen` is omitted. */ +export function firstScreenId(flow: Record): string | undefined { + const {screens} = flow + if (Array.isArray(screens)) { + for (const screen of screens) { + if (isRecord(screen) && typeof screen.id === 'string') return screen.id + } + + return undefined + } + + if (isRecord(screens)) { + const [first] = Object.keys(screens) + return first + } + + return undefined +} + +/** Writes the payload where a browser file input can pick it up. */ +export async function writePayloadFile(payload: PreviewPayload): Promise { + const dir = await mkdtemp(join(tmpdir(), 'adapty-preview-')) + const path = join(dir, 'flow-config.json') + await writeFile(path, JSON.stringify(payload), 'utf8') + return path +} diff --git a/src/lib/preview-render.ts b/src/lib/preview-render.ts new file mode 100644 index 0000000..4c0f0e3 --- /dev/null +++ b/src/lib/preview-render.ts @@ -0,0 +1,124 @@ +import type {Browser, Page} from 'playwright' + +import {mkdir, mkdtemp} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' + +import {type PreviewPayload, writePayloadFile} from './preview-config.js' + +export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' +export const DEFAULT_DEVICE_ID = 'iphone-14' +export const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' +export const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' + +/** Configs shorter than this can travel in the URL fragment instead of a file input. */ +const MAX_FRAGMENT_LENGTH = 8000 +const SETTLE_MS = 300 + +export interface RenderTarget { + device: string + screen?: string +} + +export interface RenderPreviewOptions extends RenderTarget { + outPath: string + payload: PreviewPayload + renderUrl: string + timeoutMs: number +} + +/** + * The render page is being built in parallel, so its URL is never hard-coded: it comes from + * the flag or from ADAPTY_PREVIEW_RENDER_URL. + */ +export function resolveRenderUrl(flagValue?: string): string { + const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] + if (!url) { + throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) + } + + return url +} + +export function buildRenderUrl(renderUrl: string, target: RenderTarget, fragmentConfig?: string): string { + const url = new URL(renderUrl) + if (target.screen) url.searchParams.set('screen', target.screen) + if (target.device) url.searchParams.set('device', target.device) + if (fragmentConfig) url.hash = `config=${encodeURIComponent(fragmentConfig)}` + return url.toString() +} + +export async function defaultScreenshotPath(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'adapty-preview-')) + return join(dir, 'preview.png') +} + +async function launchChromium(): Promise { + let chromium + try { + ;({chromium} = await import('playwright')) + } catch { + throw new Error('Preview needs Playwright. Install it with `npm i playwright` and `npx playwright install chromium`.') + } + + try { + return await chromium.launch({headless: true}) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Could not launch headless Chromium. Run \`npx playwright install chromium\`.\n${detail}`) + } +} + +interface InjectOptions extends RenderPreviewOptions { + target: RenderTarget +} + +async function injectConfig(page: Page, opts: InjectOptions): Promise { + const payloadPath = await writePayloadFile(opts.payload) + const input = page.locator(CONFIG_INPUT_SELECTOR) + try { + await input.waitFor({state: 'attached', timeout: opts.timeoutMs}) + } catch (error) { + const serialized = JSON.stringify(opts.payload) + if (serialized.length > MAX_FRAGMENT_LENGTH) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Render page never exposed ${CONFIG_INPUT_SELECTOR}, and the config is too large for the URL fragment fallback.\n${detail}`, + ) + } + + await page.goto(buildRenderUrl(opts.renderUrl, opts.target, serialized), { + timeout: opts.timeoutMs, + waitUntil: 'load', + }) + // Adding a fragment to the already-loaded page is a same-document navigation, so the + // render page would never re-read it without an explicit reload. + await page.reload({timeout: opts.timeoutMs, waitUntil: 'load'}) + return + } + + await input.setInputFiles(payloadPath) +} + +/** Drives the public render page headlessly and writes one screen to `outPath`. */ +export async function renderPreview(opts: RenderPreviewOptions): Promise { + const target: RenderTarget = {device: opts.device, screen: opts.screen} + await mkdir(dirname(opts.outPath), {recursive: true}) + + const browser = await launchChromium() + try { + const page = await browser.newPage() + page.setDefaultTimeout(opts.timeoutMs) + await page.goto(buildRenderUrl(opts.renderUrl, target), {timeout: opts.timeoutMs, waitUntil: 'load'}) + await injectConfig(page, {...opts, target}) + + const content = page.locator(SCREEN_CONTENT_SELECTOR).first() + await content.waitFor({state: 'visible', timeout: opts.timeoutMs}) + await page.waitForLoadState('networkidle', {timeout: opts.timeoutMs}) + await page.waitForTimeout(SETTLE_MS) + await content.screenshot({path: opts.outPath, type: 'png'}) + return opts.outPath + } finally { + await browser.close() + } +} diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts new file mode 100644 index 0000000..c6cff69 --- /dev/null +++ b/test/lib/preview.test.ts @@ -0,0 +1,72 @@ +import {expect} from 'chai' + +import {firstScreenId, normalizePreviewConfig} from '../../src/lib/preview-config.js' +import {buildRenderUrl, resolveRenderUrl} from '../../src/lib/preview-render.js' + +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: {en: {}}, theme: {}} + expect(normalizePreviewConfig(flow)).to.deep.equal({flow, remoteConfigs: []}) + }) + + it('rejects unrecognized JSON', () => { + expect(() => normalizePreviewConfig({hello: 'world'})).to.throw('Unrecognized config file') + expect(() => normalizePreviewConfig([1, 2])).to.throw('must contain a JSON object') + }) + + it('finds the first screen id in arrays and records', () => { + expect(firstScreenId({screens: [{id: 'welcome'}, {id: 'offer'}]})).to.equal('welcome') + expect(firstScreenId({screens: {offer: {}, welcome: {}}})).to.equal('offer') + expect(firstScreenId({})).to.equal(undefined) + }) +}) + +describe('render url', () => { + afterEach(() => { + delete process.env.ADAPTY_PREVIEW_RENDER_URL + }) + + it('prefers the flag over the env var', () => { + process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://env.example/render' + expect(resolveRenderUrl('https://flag.example/render')).to.equal('https://flag.example/render') + expect(resolveRenderUrl()).to.equal('https://env.example/render') + }) + + it('errors when neither is set', () => { + expect(() => resolveRenderUrl()).to.throw('ADAPTY_PREVIEW_RENDER_URL') + }) + + it('adds screen and device query params', () => { + expect(buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'welcome'})).to.equal( + 'https://app.example/preview?screen=welcome&device=iphone-14', + ) + }) + + it('omits the screen param when unknown', () => { + expect(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'})).to.equal( + 'https://app.example/preview?device=iphone-14', + ) + }) + + it('carries small configs in the fragment fallback', () => { + const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, '{"flow":{}}')) + expect(decodeURIComponent(url.hash)).to.equal('#config={"flow":{}}') + }) +}) +}) From aa85b331ff25ddf2721893f5bbb75d84ecb40e6f Mon Sep 17 00:00:00 2001 From: mlnchk Date: Tue, 18 Aug 2026 13:16:52 +0500 Subject: [PATCH 02/12] refactor(preview): drop the fragment-size cap on the config fallback --- src/lib/preview-render.ts | 16 ++++------------ test/lib/preview.test.ts | 8 +++++++- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lib/preview-render.ts b/src/lib/preview-render.ts index 4c0f0e3..d64eb19 100644 --- a/src/lib/preview-render.ts +++ b/src/lib/preview-render.ts @@ -11,8 +11,6 @@ export const DEFAULT_DEVICE_ID = 'iphone-14' export const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' export const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' -/** Configs shorter than this can travel in the URL fragment instead of a file input. */ -const MAX_FRAGMENT_LENGTH = 8000 const SETTLE_MS = 300 export interface RenderTarget { @@ -78,16 +76,10 @@ async function injectConfig(page: Page, opts: InjectOptions): Promise { const input = page.locator(CONFIG_INPUT_SELECTOR) try { await input.waitFor({state: 'attached', timeout: opts.timeoutMs}) - } catch (error) { - const serialized = JSON.stringify(opts.payload) - if (serialized.length > MAX_FRAGMENT_LENGTH) { - const detail = error instanceof Error ? error.message : String(error) - throw new Error( - `Render page never exposed ${CONFIG_INPUT_SELECTOR}, and the config is too large for the URL fragment fallback.\n${detail}`, - ) - } - - await page.goto(buildRenderUrl(opts.renderUrl, opts.target, serialized), { + } catch { + // No file input on the page: hand the whole config over in the URL fragment instead, at + // whatever size it happens to be. + await page.goto(buildRenderUrl(opts.renderUrl, opts.target, JSON.stringify(opts.payload)), { timeout: opts.timeoutMs, waitUntil: 'load', }) diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index c6cff69..b7d05ed 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -64,9 +64,15 @@ describe('render url', () => { ) }) - it('carries small configs in the fragment fallback', () => { + it('carries the config in the fragment fallback', () => { const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, '{"flow":{}}')) expect(decodeURIComponent(url.hash)).to.equal('#config={"flow":{}}') }) + + it('does not cap the fragment size', () => { + const serialized = JSON.stringify({flow: {padding: 'x'.repeat(20_000)}, remoteConfigs: []}) + const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, serialized)) + expect(decodeURIComponent(url.hash)).to.equal(`#config=${serialized}`) + }) }) }) From ddcb8e3d3c9c597b958a0e3ebd478ad0f70cdbca Mon Sep 17 00:00:00 2001 From: mlnchk Date: Tue, 18 Aug 2026 14:15:26 +0500 Subject: [PATCH 03/12] feat(preview): gzip+base64url the config fragment fallback --- src/lib/preview-render.ts | 18 +++- test/fixtures/flow-config.json | 146 +++++++++++++++++++++++++++++++++ test/lib/preview.test.ts | 40 +++++++-- 3 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/flow-config.json diff --git a/src/lib/preview-render.ts b/src/lib/preview-render.ts index d64eb19..4de24b4 100644 --- a/src/lib/preview-render.ts +++ b/src/lib/preview-render.ts @@ -3,6 +3,7 @@ import type {Browser, Page} from 'playwright' import {mkdir, mkdtemp} from 'node:fs/promises' import {tmpdir} from 'node:os' import {dirname, join} from 'node:path' +import {gzipSync} from 'node:zlib' import {type PreviewPayload, writePayloadFile} from './preview-config.js' @@ -10,6 +11,8 @@ export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' export const DEFAULT_DEVICE_ID = 'iphone-14' export const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' export const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' +/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ +export const FRAGMENT_GZIP_PREFIX = 'gz:' const SETTLE_MS = 300 @@ -38,11 +41,20 @@ export function resolveRenderUrl(flagValue?: string): string { return url } -export function buildRenderUrl(renderUrl: string, target: RenderTarget, fragmentConfig?: string): string { +/** + * Wire format shared with the render page: `gz:`. The page also accepts + * plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. + */ +export function encodeConfigFragment(payload: PreviewPayload): string { + const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) + return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` +} + +export function buildRenderUrl(renderUrl: string, target: RenderTarget, payload?: PreviewPayload): string { const url = new URL(renderUrl) if (target.screen) url.searchParams.set('screen', target.screen) if (target.device) url.searchParams.set('device', target.device) - if (fragmentConfig) url.hash = `config=${encodeURIComponent(fragmentConfig)}` + if (payload) url.hash = `config=${encodeConfigFragment(payload)}` return url.toString() } @@ -79,7 +91,7 @@ async function injectConfig(page: Page, opts: InjectOptions): Promise { } catch { // No file input on the page: hand the whole config over in the URL fragment instead, at // whatever size it happens to be. - await page.goto(buildRenderUrl(opts.renderUrl, opts.target, JSON.stringify(opts.payload)), { + await page.goto(buildRenderUrl(opts.renderUrl, opts.target, opts.payload), { timeout: opts.timeoutMs, waitUntil: 'load', }) diff --git a/test/fixtures/flow-config.json b/test/fixtures/flow-config.json new file mode 100644 index 0000000..c7fb1d0 --- /dev/null +++ b/test/fixtures/flow-config.json @@ -0,0 +1,146 @@ +{ + "config": { + "id": "9f1b7c4e-6d2a-4f58-8b3d-1a0c5e7d9f21", + "version": 4, + "default_locale": "en", + "locales": ["en", "de"], + "theme": { + "colors": { + "accent": "#5B4DF5", + "background": "#FFFFFF", + "surface": "#F4F4F8", + "text_primary": "#111114", + "text_secondary": "#6B6B76" + }, + "typography": { + "body": {"font": "SFProText", "size": 15, "weight": 400}, + "caption": {"font": "SFProText", "size": 12, "weight": 400}, + "title": {"font": "SFProDisplay", "size": 28, "weight": 700} + }, + "radius": {"button": 14, "card": 20} + }, + "screens": [ + { + "id": "welcome", + "type": "onboarding", + "background": {"type": "image", "asset_id": "hero_gradient"}, + "elements": [ + {"id": "welcome_title", "type": "text", "content_key": "welcome.title", "style": "title"}, + {"id": "welcome_subtitle", "type": "text", "content_key": "welcome.subtitle", "style": "body"}, + { + "id": "welcome_cta", + "type": "button", + "content_key": "welcome.cta", + "action": {"type": "navigate", "screen_id": "features"} + } + ] + }, + { + "id": "features", + "type": "onboarding", + "background": {"type": "color", "token": "background"}, + "elements": [ + {"id": "features_title", "type": "text", "content_key": "features.title", "style": "title"}, + { + "id": "features_list", + "type": "list", + "items": [ + {"icon": "sparkles", "content_key": "features.item_1"}, + {"icon": "chart", "content_key": "features.item_2"}, + {"icon": "lock", "content_key": "features.item_3"} + ] + }, + { + "id": "features_cta", + "type": "button", + "content_key": "features.cta", + "action": {"type": "navigate", "screen_id": "offer"} + } + ] + }, + { + "id": "offer", + "type": "paywall", + "background": {"type": "color", "token": "surface"}, + "elements": [ + {"id": "offer_title", "type": "text", "content_key": "offer.title", "style": "title"}, + { + "id": "offer_products", + "type": "product_list", + "layout": "vertical", + "products": [ + {"product_id": "premium_yearly", "badge_key": "offer.badge_best_value", "selected": true}, + {"product_id": "premium_monthly", "badge_key": null, "selected": false} + ] + }, + {"id": "offer_purchase", "type": "button", "content_key": "offer.purchase", "action": {"type": "purchase"}}, + {"id": "offer_restore", "type": "link", "content_key": "offer.restore", "action": {"type": "restore"}}, + {"id": "offer_terms", "type": "text", "content_key": "offer.terms", "style": "caption"} + ] + }, + { + "id": "offer_discount", + "type": "paywall", + "shown_when": {"type": "close_attempt", "screen_id": "offer"}, + "background": {"type": "color", "token": "surface"}, + "elements": [ + {"id": "discount_title", "type": "text", "content_key": "discount.title", "style": "title"}, + { + "id": "discount_products", + "type": "product_list", + "layout": "vertical", + "products": [{"product_id": "premium_yearly_intro", "badge_key": "discount.badge", "selected": true}] + }, + {"id": "discount_purchase", "type": "button", "content_key": "discount.purchase", "action": {"type": "purchase"}}, + {"id": "discount_close", "type": "link", "content_key": "discount.close", "action": {"type": "close"}} + ] + } + ], + "localizations": { + "en": { + "welcome.title": "Track everything that matters", + "welcome.subtitle": "Habits, workouts and mood in one place.", + "welcome.cta": "Get started", + "features.title": "Why go Premium", + "features.item_1": "Unlimited habits and reminders", + "features.item_2": "Full history and trends", + "features.item_3": "Private, encrypted backups", + "features.cta": "See plans", + "offer.title": "Unlock Premium", + "offer.badge_best_value": "Best value", + "offer.purchase": "Start free trial", + "offer.restore": "Restore purchase", + "offer.terms": "7 days free, then billed yearly. Cancel anytime.", + "discount.title": "One last thing", + "discount.badge": "50% off first year", + "discount.purchase": "Claim discount", + "discount.close": "No thanks" + }, + "de": { + "welcome.title": "Behalte alles im Blick", + "welcome.subtitle": "Gewohnheiten, Workouts und Stimmung an einem Ort.", + "welcome.cta": "Los geht's", + "features.title": "Warum Premium", + "features.item_1": "Unbegrenzte Gewohnheiten und Erinnerungen", + "features.item_2": "Vollständiger Verlauf und Trends", + "features.item_3": "Private, verschlüsselte Backups", + "features.cta": "Tarife ansehen", + "offer.title": "Premium freischalten", + "offer.badge_best_value": "Bester Preis", + "offer.purchase": "Kostenlos testen", + "offer.restore": "Kauf wiederherstellen", + "offer.terms": "7 Tage kostenlos, danach jährliche Abrechnung. Jederzeit kündbar.", + "discount.title": "Noch eine Sache", + "discount.badge": "50 % Rabatt im ersten Jahr", + "discount.purchase": "Rabatt sichern", + "discount.close": "Nein danke" + } + } + }, + "remote_configs": [ + {"locale": "en", "data": {"experiment": "onboarding_v4", "show_discount": true}}, + {"locale": "de", "data": {"experiment": "onboarding_v4", "show_discount": false}} + ], + "status": "draft", + "updated_at": "2026-08-14T09:12:44Z" +} diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index b7d05ed..ea1019a 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -1,7 +1,20 @@ import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {gunzipSync} from 'node:zlib' import {firstScreenId, normalizePreviewConfig} from '../../src/lib/preview-config.js' -import {buildRenderUrl, resolveRenderUrl} from '../../src/lib/preview-render.js' +import {buildRenderUrl, FRAGMENT_GZIP_PREFIX, resolveRenderUrl} from '../../src/lib/preview-render.js' + +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) +const FIXTURE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown + +function decodeConfigFragment(hash: string): unknown { + const encoded = new URLSearchParams(hash.slice(1)).get('config') + if (!encoded?.startsWith(FRAGMENT_GZIP_PREFIX)) throw new Error(`Not a gzipped fragment: ${encoded}`) + const gzipped = Buffer.from(encoded.slice(FRAGMENT_GZIP_PREFIX.length), 'base64url') + return JSON.parse(gunzipSync(gzipped).toString('utf8')) as unknown +} describe('preview', () => { describe('config normalization', () => { @@ -64,15 +77,28 @@ describe('render url', () => { ) }) - it('carries the config in the fragment fallback', () => { - const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, '{"flow":{}}')) - expect(decodeURIComponent(url.hash)).to.equal('#config={"flow":{}}') + it('carries a real config through the gzipped fragment fallback', () => { + const payload = normalizePreviewConfig(FIXTURE) + const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'offer'}, payload)) + + expect(url.hash.startsWith(`#config=${FRAGMENT_GZIP_PREFIX}`)).to.equal(true) + expect(url.hash.slice(`#config=${FRAGMENT_GZIP_PREFIX}`.length)).to.match(/^[\w-]+$/) + expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) + }) + + it('compresses the fragment well below the raw JSON size', () => { + const payload = normalizePreviewConfig(FIXTURE) + const raw = JSON.stringify(payload) + const {hash} = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) + + expect(hash.length).to.be.lessThan(raw.length) }) it('does not cap the fragment size', () => { - const serialized = JSON.stringify({flow: {padding: 'x'.repeat(20_000)}, remoteConfigs: []}) - const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, serialized)) - expect(decodeURIComponent(url.hash)).to.equal(`#config=${serialized}`) + const payload = {flow: {padding: 'x'.repeat(200_000), screens: []}, remoteConfigs: []} + const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) + + expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) }) }) }) From 72d5cbb599b905a01114657a17e2f80c32a73072 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Tue, 18 Aug 2026 17:58:22 +0500 Subject: [PATCH 04/12] refactor(preview): drop Playwright dependency for a prep-only command plus reference script --- README.md | 27 ++++ package.json | 8 +- pnpm-lock.yaml | 32 ----- scripts/preview-with-playwright.mjs | 115 +++++++++++++++++ skills/adapty-cli/references/cli-commands.md | 21 ++- src/commands/preview.ts | 51 ++++---- src/lib/preview-config.ts | 12 +- src/lib/preview-reference.ts | 20 +++ src/lib/preview-render.ts | 128 ------------------- src/lib/preview-url.ts | 43 +++++++ test/commands/preview.test.ts | 42 ++++++ test/lib/preview.test.ts | 24 +++- 12 files changed, 319 insertions(+), 204 deletions(-) create mode 100755 scripts/preview-with-playwright.mjs create mode 100644 src/lib/preview-reference.ts delete mode 100644 src/lib/preview-render.ts create mode 100644 src/lib/preview-url.ts create mode 100644 test/commands/preview.test.ts diff --git a/README.md b/README.md index 89e57d2..dd277b0 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 preview ` does not render anything itself — **Playwright is not bundled**. It takes a +local flow config (a dashboard-api envelope `{config, remote_configs, ...}` or a bare builder config), +normalizes it to `{flow, remoteConfigs}`, and prints three handles: + +| Handle | Use | +| ------------------ | -------------------------------------------------------------------------- | +| `renderUrl` | `?screen=&device=#config=gz:` — open it with any browser or computer-use tool and screenshot the `[data-screen-content]` element | +| `payloadPath` | The normalized payload JSON, for the file-input path (`[data-testid="preview-config-input"]`) when a config is too large for a URL | +| `referenceCommand` | Ready-to-run `npx` command for the reference Playwright script shipped with this package | + +```sh +export ADAPTY_PREVIEW_RENDER_URL=https://.../preview # required, no default +adapty preview ./paywall.json --screen offer --device iphone-14 --json + +# then either run the printed reference command: +npx --yes --package=playwright node /scripts/preview-with-playwright.mjs \ + --url "" --out preview.png +# ...or, for a huge config, feed the payload file through the page's file input: +npx --yes --package=playwright node /scripts/preview-with-playwright.mjs \ + --url "" --config "" --out preview.png +``` + +Playwright is resolved at run time by `npx` (or from your own project), so it is neither a dependency nor a +download for anyone who never previews. Browsers still need `npx playwright install chromium` once. + ## Environment Variables | Variable | Description | diff --git a/package.json b/package.json index b605aed..6574ea8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "files": [ "./bin", "./dist", - "./oclif.manifest.json" + "./oclif.manifest.json", + "./scripts/preview-with-playwright.mjs" ], "homepage": "https://adapty.io/docs/developer-cli", "keywords": [ @@ -145,8 +146,5 @@ "test": "mocha --forbid-only \"test/**/*.test.ts\"", "version": "oclif readme && git add README.md" }, - "types": "dist/index.d.ts", - "optionalDependencies": { - "playwright": "^1.62.1" - } + "types": "dist/index.d.ts" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a68631e..aa4d7c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,10 +69,6 @@ importers: typescript: specifier: ^5 version: 5.9.3 - optionalDependencies: - playwright: - specifier: ^1.62.1 - version: 1.62.1 packages: @@ -1721,11 +1717,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2409,16 +2400,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} - engines: {node: '>=20'} - hasBin: true - - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} - engines: {node: '>=20'} - hasBin: true - pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5296,9 +5277,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -5997,16 +5975,6 @@ snapshots: picomatch@4.0.3: {} - playwright-core@1.62.1: - optional: true - - playwright@1.62.1: - dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 - optional: true - pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} diff --git a/scripts/preview-with-playwright.mjs b/scripts/preview-with-playwright.mjs new file mode 100755 index 0000000..0041f6c --- /dev/null +++ b/scripts/preview-with-playwright.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Reference preview renderer for `adapty preview`. + * + * The CLI does not bundle Playwright: it only prepares a render URL and a payload file. Run this + * script with Playwright supplied at run time: + * + * npx --yes --package=playwright node --url "" --out preview.png + * + * For very large configs, hand over the payload file instead of the URL fragment: + * + * npx --yes --package=playwright node --url "" --config --out preview.png + * + * An agent that already has a browser or computer-use tool can skip this script entirely: open + * `renderUrl` and screenshot the `[data-screen-content]` element. + */ + +import {createRequire} from 'node:module' +import {delimiter, join, resolve} from 'node:path' + +const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' +const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' +const SETTLE_MS = 300 +const TIMEOUT_MS = 30_000 + +function parseArgs(argv) { + const args = {out: 'preview.png'} + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i] + if (flag === '--url' || flag === '--config' || flag === '--out') { + const value = argv[i + 1] + if (!value) fail(`${flag} needs a value`) + args[flag.slice(2)] = value + i += 1 + } else { + fail(`Unknown argument ${flag}`) + } + } + + if (!args.url) fail('--url is required') + return args +} + +function fail(message) { + console.error(`${message}\nUsage: node preview-with-playwright.mjs --url [--config ] [--out ]`) + process.exit(2) +} + +/** Playwright is a run-time dependency of the caller: this script, npx, or the current project. */ +async function loadChromium() { + try { + // eslint-disable-next-line import/no-unresolved -- supplied at run time, never a CLI dependency + return (await import('playwright')).chromium + } catch { + // Not resolvable from this file; fall back to the caller's project and to any node_modules + // that npx put on PATH. + const roots = [ + process.cwd(), + ...(process.env.PATH ?? '') + .split(delimiter) + .filter((dir) => dir.endsWith(join('node_modules', '.bin'))) + .map((dir) => join(dir, '..')), + ] + + for (const root of roots) { + try { + const require = createRequire(join(root, 'noop.js')) + return require(root.endsWith('node_modules') ? join(root, 'playwright') : 'playwright').chromium + } catch { + continue + } + } + + console.error( + 'Could not resolve Playwright. Run this script through `npx --yes --package=playwright node ...`, or `npm i -D playwright` first.', + ) + process.exit(1) + } +} + +const args = parseArgs(process.argv.slice(2)) +const chromium = await loadChromium() + +let browser +try { + browser = await chromium.launch({headless: true}) +} catch (error) { + console.error(`Could not launch headless Chromium. Run \`npx playwright install chromium\`.\n${error.message}`) + process.exit(1) +} + +try { + const page = await browser.newPage() + page.setDefaultTimeout(TIMEOUT_MS) + + if (args.config) { + // File-input path: drop the gz: fragment and hand the payload over as a file, which has no + // practical size limit. + const target = new URL(args.url) + target.hash = '' + await page.goto(target.toString(), {waitUntil: 'load'}) + await page.locator(CONFIG_INPUT_SELECTOR).setInputFiles(resolve(args.config)) + } else { + await page.goto(args.url, {waitUntil: 'load'}) + } + + const content = page.locator(SCREEN_CONTENT_SELECTOR).first() + await content.waitFor({state: 'visible'}) + await page.waitForLoadState('networkidle') + await page.waitForTimeout(SETTLE_MS) + await content.screenshot({path: resolve(args.out), type: 'png'}) + console.log(resolve(args.out)) +} finally { + await browser.close() +} diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 0b30143..62db6a4 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -100,15 +100,24 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v |-------------------------------|----------------| | `preview ` | none (needs a render URL) | -Renders a **local** flow config JSON file to a PNG with headless Chromium — no API call, no `--app`. Accepts -either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config (`screens` / -`locales` / `theme`); both normalize to `{flow, remoteConfigs}` before injection. +Prep-only: takes a **local** flow config JSON file, normalizes it, and prints the handles you need to render +it. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. + +Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config (`screens` +/ `locales` / `theme`); both normalize to `{flow, remoteConfigs}`. Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: first screen in the -config), `--device` (default: `iphone-14`), `--out` (default: temp file), `--timeout` (default: 30000 ms). -The PNG path is printed, and returned as `{device, path, screen}` under `--json`. +config), `--device` (default: `iphone-14`), `--payload-out` (default: temp file). + +Output (`--json`): `{renderUrl, payloadPath, referenceCommand}`. -Requires Playwright's chromium: `npx playwright install chromium`. +- `renderUrl` — `?screen=&device=#config=gz:`. Tool-agnostic: open it + with your own browser/computer-use tool and screenshot the `[data-screen-content]` element. +- `payloadPath` — the normalized payload file, for the page's `[data-testid="preview-config-input"]` file + input when the config is too large to sit in a URL. +- `referenceCommand` — the exact `npx --yes --package=playwright node /scripts/preview-with-playwright.mjs + --url "" --out "preview.png"` invocation. Add `--config ` to use the file input + instead of the fragment. Chromium itself: `npx playwright install chromium` once. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/preview.ts b/src/commands/preview.ts index 00fd806..dbcc7f8 100644 --- a/src/commands/preview.ts +++ b/src/commands/preview.ts @@ -2,38 +2,31 @@ import {Args, Command, Flags} from '@oclif/core' import {readFile} from 'node:fs/promises' import {resolve} from 'node:path' -import {printResponse} from '../lib/output.js' -import {firstScreenId, normalizePreviewConfig} from '../lib/preview-config.js' -import { - DEFAULT_DEVICE_ID, - defaultScreenshotPath, - RENDER_URL_ENV_VAR, - renderPreview, - resolveRenderUrl, -} from '../lib/preview-render.js' +import {firstScreenId, normalizePreviewConfig, writePayloadFile} from '../lib/preview-config.js' +import {buildReferenceCommand} from '../lib/preview-reference.js' +import {buildRenderUrl, DEFAULT_DEVICE_ID, RENDER_URL_ENV_VAR, resolveRenderUrl} from '../lib/preview-url.js' export interface PreviewResult { - device: string - path: string - screen?: string + payloadPath: string + referenceCommand: string + renderUrl: string } export default class Preview extends Command { static args = { config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}), } -static description = 'Render a local flow config to a PNG screenshot with a headless browser' +static description = 'Prepare a render URL and payload for a local flow config, then screenshot it with your own browser tool or the shipped reference script' static enableJsonFlag = true static examples = [ '<%= config.bin %> preview ./paywall.json', - '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --out ./preview.png', + '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --json', ] static flags = { device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}), - out: Flags.string({description: 'Where to write the PNG (default: a temp file)'}), + 'payload-out': Flags.string({description: 'Where to write the normalized payload JSON (default: a temp file)'}), 'render-url': Flags.string({description: `Render page base URL (defaults to $${RENDER_URL_ENV_VAR})`}), screen: Flags.string({description: 'Screen ID to render (default: first screen in the config)'}), - timeout: Flags.integer({default: 30_000, description: 'Per-step timeout in milliseconds', min: 1000}), } async run(): Promise { @@ -50,27 +43,29 @@ static flags = { } let payload - let renderUrl + let renderBaseUrl try { payload = normalizePreviewConfig(raw) - renderUrl = resolveRenderUrl(flags['render-url']) + renderBaseUrl = resolveRenderUrl(flags['render-url']) } catch (error) { this.error(error instanceof Error ? error.message : String(error), {exit: 2}) } const screen = flags.screen ?? firstScreenId(payload.flow) - const outPath = flags.out ? resolve(flags.out) : await defaultScreenshotPath() - const path = await renderPreview({ - device: flags.device, - outPath, - payload, + const renderUrl = buildRenderUrl(renderBaseUrl, {device: flags.device, screen}, payload) + const payloadPath = await writePayloadFile(payload, flags['payload-out'] ? resolve(flags['payload-out']) : undefined) + const result: PreviewResult = { + payloadPath, + referenceCommand: buildReferenceCommand({renderUrl}), renderUrl, - screen, - timeoutMs: flags.timeout, - }) + } - const result: PreviewResult = {device: flags.device, path, screen} - printResponse(result as unknown as Record, this.log.bind(this)) + this.log(`Render URL: ${result.renderUrl}`) + this.log(`Payload file: ${result.payloadPath}`) + this.log(`Reference command: ${result.referenceCommand}`) + this.log('') + this.log('Open the render URL with any browser tool and screenshot [data-screen-content], or run the') + this.log('reference command. For a config too large for a URL, add --config to it.') return result } diff --git a/src/lib/preview-config.ts b/src/lib/preview-config.ts index 1910417..41d4e8a 100644 --- a/src/lib/preview-config.ts +++ b/src/lib/preview-config.ts @@ -1,6 +1,6 @@ -import {mkdtemp, writeFile} from 'node:fs/promises' +import {mkdir, mkdtemp, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' -import {join} from 'node:path' +import {dirname, join} from 'node:path' /** Payload the render page expects: the builder config plus its remote configs. */ export interface PreviewPayload { @@ -64,7 +64,13 @@ export function firstScreenId(flow: Record): string | undefined } /** Writes the payload where a browser file input can pick it up. */ -export async function writePayloadFile(payload: PreviewPayload): Promise { +export async function writePayloadFile(payload: PreviewPayload, outPath?: string): Promise { + if (outPath) { + await mkdir(dirname(outPath), {recursive: true}) + await writeFile(outPath, JSON.stringify(payload), 'utf8') + return outPath + } + const dir = await mkdtemp(join(tmpdir(), 'adapty-preview-')) const path = join(dir, 'flow-config.json') await writeFile(path, JSON.stringify(payload), 'utf8') diff --git a/src/lib/preview-reference.ts b/src/lib/preview-reference.ts new file mode 100644 index 0000000..baa27e6 --- /dev/null +++ b/src/lib/preview-reference.ts @@ -0,0 +1,20 @@ +import {fileURLToPath} from 'node:url' + +/** + * Self-contained Playwright script shipped alongside the CLI. Playwright itself is supplied by + * whoever runs it (npx or the caller's project), so the CLI never depends on it. + */ +export function referenceScriptPath(): string { + return fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) +} + +export interface ReferenceCommandOptions { + outPath?: string + renderUrl: string +} + +export function buildReferenceCommand(opts: ReferenceCommandOptions): string { + const script = referenceScriptPath() + const out = opts.outPath ?? 'preview.png' + return `npx --yes --package=playwright node "${script}" --url "${opts.renderUrl}" --out "${out}"` +} diff --git a/src/lib/preview-render.ts b/src/lib/preview-render.ts deleted file mode 100644 index 4de24b4..0000000 --- a/src/lib/preview-render.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type {Browser, Page} from 'playwright' - -import {mkdir, mkdtemp} from 'node:fs/promises' -import {tmpdir} from 'node:os' -import {dirname, join} from 'node:path' -import {gzipSync} from 'node:zlib' - -import {type PreviewPayload, writePayloadFile} from './preview-config.js' - -export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' -export const DEFAULT_DEVICE_ID = 'iphone-14' -export const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' -export const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' -/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ -export const FRAGMENT_GZIP_PREFIX = 'gz:' - -const SETTLE_MS = 300 - -export interface RenderTarget { - device: string - screen?: string -} - -export interface RenderPreviewOptions extends RenderTarget { - outPath: string - payload: PreviewPayload - renderUrl: string - timeoutMs: number -} - -/** - * The render page is being built in parallel, so its URL is never hard-coded: it comes from - * the flag or from ADAPTY_PREVIEW_RENDER_URL. - */ -export function resolveRenderUrl(flagValue?: string): string { - const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] - if (!url) { - throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) - } - - return url -} - -/** - * Wire format shared with the render page: `gz:`. The page also accepts - * plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. - */ -export function encodeConfigFragment(payload: PreviewPayload): string { - const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) - return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` -} - -export function buildRenderUrl(renderUrl: string, target: RenderTarget, payload?: PreviewPayload): string { - const url = new URL(renderUrl) - if (target.screen) url.searchParams.set('screen', target.screen) - if (target.device) url.searchParams.set('device', target.device) - if (payload) url.hash = `config=${encodeConfigFragment(payload)}` - return url.toString() -} - -export async function defaultScreenshotPath(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'adapty-preview-')) - return join(dir, 'preview.png') -} - -async function launchChromium(): Promise { - let chromium - try { - ;({chromium} = await import('playwright')) - } catch { - throw new Error('Preview needs Playwright. Install it with `npm i playwright` and `npx playwright install chromium`.') - } - - try { - return await chromium.launch({headless: true}) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Could not launch headless Chromium. Run \`npx playwright install chromium\`.\n${detail}`) - } -} - -interface InjectOptions extends RenderPreviewOptions { - target: RenderTarget -} - -async function injectConfig(page: Page, opts: InjectOptions): Promise { - const payloadPath = await writePayloadFile(opts.payload) - const input = page.locator(CONFIG_INPUT_SELECTOR) - try { - await input.waitFor({state: 'attached', timeout: opts.timeoutMs}) - } catch { - // No file input on the page: hand the whole config over in the URL fragment instead, at - // whatever size it happens to be. - await page.goto(buildRenderUrl(opts.renderUrl, opts.target, opts.payload), { - timeout: opts.timeoutMs, - waitUntil: 'load', - }) - // Adding a fragment to the already-loaded page is a same-document navigation, so the - // render page would never re-read it without an explicit reload. - await page.reload({timeout: opts.timeoutMs, waitUntil: 'load'}) - return - } - - await input.setInputFiles(payloadPath) -} - -/** Drives the public render page headlessly and writes one screen to `outPath`. */ -export async function renderPreview(opts: RenderPreviewOptions): Promise { - const target: RenderTarget = {device: opts.device, screen: opts.screen} - await mkdir(dirname(opts.outPath), {recursive: true}) - - const browser = await launchChromium() - try { - const page = await browser.newPage() - page.setDefaultTimeout(opts.timeoutMs) - await page.goto(buildRenderUrl(opts.renderUrl, target), {timeout: opts.timeoutMs, waitUntil: 'load'}) - await injectConfig(page, {...opts, target}) - - const content = page.locator(SCREEN_CONTENT_SELECTOR).first() - await content.waitFor({state: 'visible', timeout: opts.timeoutMs}) - await page.waitForLoadState('networkidle', {timeout: opts.timeoutMs}) - await page.waitForTimeout(SETTLE_MS) - await content.screenshot({path: opts.outPath, type: 'png'}) - return opts.outPath - } finally { - await browser.close() - } -} diff --git a/src/lib/preview-url.ts b/src/lib/preview-url.ts new file mode 100644 index 0000000..2190090 --- /dev/null +++ b/src/lib/preview-url.ts @@ -0,0 +1,43 @@ +import {gzipSync} from 'node:zlib' + +import type {PreviewPayload} from './preview-config.js' + +export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' +export const DEFAULT_DEVICE_ID = 'iphone-14' +/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ +export const FRAGMENT_GZIP_PREFIX = 'gz:' + +export interface RenderTarget { + device: string + screen?: string +} + +/** + * The render page is being built in parallel, so its URL is never hard-coded: it comes from + * the flag or from ADAPTY_PREVIEW_RENDER_URL. + */ +export function resolveRenderUrl(flagValue?: string): string { + const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] + if (!url) { + throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) + } + + return url +} + +/** + * Wire format shared with the render page: `gz:`. The page also accepts + * plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. + */ +export function encodeConfigFragment(payload: PreviewPayload): string { + const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) + return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` +} + +export function buildRenderUrl(renderUrl: string, target: RenderTarget, payload?: PreviewPayload): string { + const url = new URL(renderUrl) + if (target.screen) url.searchParams.set('screen', target.screen) + if (target.device) url.searchParams.set('device', target.device) + if (payload) url.hash = `config=${encodeConfigFragment(payload)}` + return url.toString() +} diff --git a/test/commands/preview.test.ts b/test/commands/preview.test.ts new file mode 100644 index 0000000..56b7012 --- /dev/null +++ b/test/commands/preview.test.ts @@ -0,0 +1,42 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {mkdtempSync, readFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import type {PreviewResult} from '../../src/commands/preview.js' + +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) + +describe('preview command', () => { + afterEach(() => { + delete process.env.ADAPTY_PREVIEW_RENDER_URL + }) + + it('prepares a render URL, a payload file and a reference command', async () => { + process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + const {result} = await runCommand(['preview', FIXTURE_PATH, '--json']) + if (!result) throw new Error('preview returned no result') + + expect(result.renderUrl.startsWith('https://app.example/preview?screen=welcome&device=iphone-14#config=gz:')).to.equal( + true, + ) + expect(result.referenceCommand).to.contain(result.renderUrl) + expect(JSON.parse(readFileSync(result.payloadPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) + }) + + it('writes the payload where --payload-out asks', async () => { + process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + const outPath = join(mkdtempSync(join(tmpdir(), 'adapty-preview-test-')), 'payload.json') + const {result} = await runCommand(['preview', FIXTURE_PATH, '--payload-out', outPath, '--json']) + + expect(result?.payloadPath).to.equal(outPath) + expect(JSON.parse(readFileSync(outPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) + }) + + it('fails without a render URL', async () => { + const {error} = await runCommand(['preview', FIXTURE_PATH]) + expect(error?.message).to.contain('ADAPTY_PREVIEW_RENDER_URL') + }) +}) diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index ea1019a..81ede8e 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -1,10 +1,12 @@ import {expect} from 'chai' -import {readFileSync} from 'node:fs' +import {existsSync, readFileSync} from 'node:fs' +import {join} from 'node:path' import {fileURLToPath} from 'node:url' import {gunzipSync} from 'node:zlib' import {firstScreenId, normalizePreviewConfig} from '../../src/lib/preview-config.js' -import {buildRenderUrl, FRAGMENT_GZIP_PREFIX, resolveRenderUrl} from '../../src/lib/preview-render.js' +import {buildReferenceCommand, referenceScriptPath} from '../../src/lib/preview-reference.js' +import {buildRenderUrl, FRAGMENT_GZIP_PREFIX, resolveRenderUrl} from '../../src/lib/preview-url.js' const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) const FIXTURE = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as unknown @@ -101,4 +103,22 @@ describe('render url', () => { expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) }) }) + +describe('reference script', () => { + it('ships the reference Playwright script', () => { + const path = referenceScriptPath() + expect(path.endsWith(join('scripts', 'preview-with-playwright.mjs'))).to.equal(true) + expect(existsSync(path)).to.equal(true) + }) + + it('points the reference command at that script and the render URL', () => { + const renderUrl = 'https://app.example/preview?device=iphone-14#config=gz:abc' + const command = buildReferenceCommand({renderUrl}) + + expect(command).to.contain(`node "${referenceScriptPath()}"`) + expect(command).to.contain(`--url "${renderUrl}"`) + expect(command).to.contain('--out "preview.png"') + expect(command).to.contain('--package=playwright') + }) +}) }) From 48048d3e77f1163ebf081d519bd17fa9cf245bd8 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Tue, 18 Aug 2026 18:47:35 +0500 Subject: [PATCH 05/12] =?UTF-8?q?refactor(preview):=20address=20simplicity?= =?UTF-8?q?=20review=20=E2=80=94=20one=20lib=20module,=20snake=5Fcase=20ou?= =?UTF-8?q?tput,=20leaner=20reference=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 6 + README.md | 28 +--- scripts/preview-with-playwright.mjs | 50 +++--- skills/adapty-cli/references/cli-commands.md | 19 +-- src/commands/preview.ts | 74 ++++++--- src/lib/preview-config.ts | 78 ---------- src/lib/preview-reference.ts | 20 --- src/lib/preview-url.ts | 43 ------ src/lib/preview.ts | 100 ++++++++++++ test/commands/preview.test.ts | 40 +++-- test/fixtures/flow-config.json | 126 +++------------ test/lib/preview.test.ts | 152 +++++++------------ 12 files changed, 307 insertions(+), 429 deletions(-) delete mode 100644 src/lib/preview-config.ts delete mode 100644 src/lib/preview-reference.ts delete mode 100644 src/lib/preview-url.ts create mode 100644 src/lib/preview.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6a3bb89..cecf4a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ src/ asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, # negative-keywords, search-terms, ads, product-pages, creatives, automations, metrics, # competitors + preview.ts # local flow config → render URL for the public paywall render page lib/ api-client.ts # HTTP client (fetch-based, bearer auth) config.ts # ~/.config/adapty/config.json read/write @@ -40,6 +41,9 @@ src/ 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 / gz: fragment building +scripts/ + preview-with-playwright.mjs # reference renderer shipped to consumers; Playwright is NOT a CLI dependency ``` ## Conventions @@ -52,6 +56,8 @@ 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_PREVIEW_RENDER_URL` env (or `--render-url`) sets the paywall render page base URL; `preview` never + drives a browser itself - 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 dd277b0..e9579f3 100644 --- a/README.md +++ b/README.md @@ -258,30 +258,10 @@ and the outcome shows up in `adapty asa automations runs`. ## Paywall Preview -`adapty preview ` does not render anything itself — **Playwright is not bundled**. It takes a -local flow config (a dashboard-api envelope `{config, remote_configs, ...}` or a bare builder config), -normalizes it to `{flow, remoteConfigs}`, and prints three handles: - -| Handle | Use | -| ------------------ | -------------------------------------------------------------------------- | -| `renderUrl` | `?screen=&device=#config=gz:` — open it with any browser or computer-use tool and screenshot the `[data-screen-content]` element | -| `payloadPath` | The normalized payload JSON, for the file-input path (`[data-testid="preview-config-input"]`) when a config is too large for a URL | -| `referenceCommand` | Ready-to-run `npx` command for the reference Playwright script shipped with this package | - -```sh -export ADAPTY_PREVIEW_RENDER_URL=https://.../preview # required, no default -adapty preview ./paywall.json --screen offer --device iphone-14 --json - -# then either run the printed reference command: -npx --yes --package=playwright node /scripts/preview-with-playwright.mjs \ - --url "" --out preview.png -# ...or, for a huge config, feed the payload file through the page's file input: -npx --yes --package=playwright node /scripts/preview-with-playwright.mjs \ - --url "" --config "" --out preview.png -``` - -Playwright is resolved at run time by `npx` (or from your own project), so it is neither a dependency nor a -download for anyone who never previews. Browsers still need `npx playwright install chromium` once. +`adapty preview ` turns a local flow config into a render URL you screenshot yourself — +**Playwright is not bundled**. See +[skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for the +handles it prints, the reference `npx` script, and the oversized-config path. ## Environment Variables diff --git a/scripts/preview-with-playwright.mjs b/scripts/preview-with-playwright.mjs index 0041f6c..99621f7 100755 --- a/scripts/preview-with-playwright.mjs +++ b/scripts/preview-with-playwright.mjs @@ -17,35 +17,13 @@ import {createRequire} from 'node:module' import {delimiter, join, resolve} from 'node:path' +import {parseArgs} from 'node:util' const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' const SETTLE_MS = 300 const TIMEOUT_MS = 30_000 -function parseArgs(argv) { - const args = {out: 'preview.png'} - for (let i = 0; i < argv.length; i += 1) { - const flag = argv[i] - if (flag === '--url' || flag === '--config' || flag === '--out') { - const value = argv[i + 1] - if (!value) fail(`${flag} needs a value`) - args[flag.slice(2)] = value - i += 1 - } else { - fail(`Unknown argument ${flag}`) - } - } - - if (!args.url) fail('--url is required') - return args -} - -function fail(message) { - console.error(`${message}\nUsage: node preview-with-playwright.mjs --url [--config ] [--out ]`) - process.exit(2) -} - /** Playwright is a run-time dependency of the caller: this script, npx, or the current project. */ async function loadChromium() { try { @@ -64,8 +42,7 @@ async function loadChromium() { for (const root of roots) { try { - const require = createRequire(join(root, 'noop.js')) - return require(root.endsWith('node_modules') ? join(root, 'playwright') : 'playwright').chromium + return createRequire(join(root, 'noop.js'))('playwright').chromium } catch { continue } @@ -78,7 +55,28 @@ async function loadChromium() { } } -const args = parseArgs(process.argv.slice(2)) +const USAGE = 'Usage: node preview-with-playwright.mjs --url [--config ] [--out ]' + +function fail(message) { + console.error(`${message}\n${USAGE}`) + process.exit(2) +} + +let args +try { + ;({values: args} = parseArgs({ + options: { + config: {type: 'string'}, + out: {default: 'preview.png', type: 'string'}, + url: {type: 'string'}, + }, + })) +} catch (error) { + fail(error.message) +} + +if (!args.url) fail('--url is required') + const chromium = await loadChromium() let browser diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 62db6a4..66010aa 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -104,20 +104,21 @@ Prep-only: takes a **local** flow config JSON file, normalizes it, and prints th it. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config (`screens` -/ `locales` / `theme`); both normalize to `{flow, remoteConfigs}`. +/ `locales` / `theme`); both normalize to `{flow, remoteConfigs}` (camelCase: that payload is a wire format +shared with the render page). Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: first screen in the -config), `--device` (default: `iphone-14`), `--payload-out` (default: temp file). +config), `--device` (default: `iphone-14`), `--payload-out` (off by default). -Output (`--json`): `{renderUrl, payloadPath, referenceCommand}`. +Output (`--json`): `{render_url, reference_command, payload_path?}`. -- `renderUrl` — `?screen=&device=#config=gz:`. Tool-agnostic: open it +- `render_url` — `?screen=&device=#config=gz:`. Tool-agnostic: open it with your own browser/computer-use tool and screenshot the `[data-screen-content]` element. -- `payloadPath` — the normalized payload file, for the page's `[data-testid="preview-config-input"]` file - input when the config is too large to sit in a URL. -- `referenceCommand` — the exact `npx --yes --package=playwright node /scripts/preview-with-playwright.mjs - --url "" --out "preview.png"` invocation. Add `--config ` to use the file input - instead of the fragment. Chromium itself: `npx playwright install chromium` once. +- `reference_command` — the exact `npx --yes --package=playwright node /scripts/preview-with-playwright.mjs + --url "" --out "preview.png"` invocation. Chromium itself: `npx playwright install chromium` once. +- `payload_path` — present only with `--payload-out `. For a config too large to sit in a URL, re-run + with `--payload-out` and the printed `reference_command` will include `--config `, which feeds the + page's `[data-testid="preview-config-input"]` file input instead of the fragment. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/preview.ts b/src/commands/preview.ts index dbcc7f8..5a0ae24 100644 --- a/src/commands/preview.ts +++ b/src/commands/preview.ts @@ -1,22 +1,39 @@ import {Args, Command, Flags} from '@oclif/core' import {readFile} from 'node:fs/promises' import {resolve} from 'node:path' +import {fileURLToPath} from 'node:url' -import {firstScreenId, normalizePreviewConfig, writePayloadFile} from '../lib/preview-config.js' -import {buildReferenceCommand} from '../lib/preview-reference.js' -import {buildRenderUrl, DEFAULT_DEVICE_ID, RENDER_URL_ENV_VAR, resolveRenderUrl} from '../lib/preview-url.js' +import {printResponse} from '../lib/output.js' +import { + buildRenderUrl, + DEFAULT_DEVICE_ID, + firstScreenId, + normalizePreviewConfig, + type PreviewPayload, + RENDER_URL_ENV_VAR, + resolveRenderUrl, + writePayloadFile, +} from '../lib/preview.js' export interface PreviewResult { - payloadPath: string - referenceCommand: string - renderUrl: string + payload_path?: string + reference_command: string + render_url: string +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function referenceScriptPath(): string { + return fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) } export default class Preview extends Command { static args = { config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}), } -static description = 'Prepare a render URL and payload for a local flow config, then screenshot it with your own browser tool or the shipped reference script' +static description = 'Prepare a render URL for a local flow config, then screenshot it with your own browser tool or the shipped reference script' static enableJsonFlag = true static examples = [ '<%= config.bin %> preview ./paywall.json', @@ -24,7 +41,9 @@ static examples = [ ] static flags = { device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}), - 'payload-out': Flags.string({description: 'Where to write the normalized payload JSON (default: a temp file)'}), + 'payload-out': Flags.string({ + description: 'Also write the normalized payload JSON here, for configs too large to sit in a URL', + }), 'render-url': Flags.string({description: `Render page base URL (defaults to $${RENDER_URL_ENV_VAR})`}), screen: Flags.string({description: 'Screen ID to render (default: first screen in the config)'}), } @@ -37,35 +56,46 @@ static flags = { try { raw = JSON.parse(await readFile(configPath, 'utf8')) } catch (error) { - this.error(`Could not read config file ${configPath}: ${error instanceof Error ? error.message : String(error)}`, { - exit: 2, - }) + this.error(`Could not read config file ${configPath}: ${describeError(error)}`, {exit: 2}) } - let payload - let renderBaseUrl + let payload: PreviewPayload + let renderBaseUrl: string try { payload = normalizePreviewConfig(raw) renderBaseUrl = resolveRenderUrl(flags['render-url']) } catch (error) { - this.error(error instanceof Error ? error.message : String(error), {exit: 2}) + this.error(describeError(error), {exit: 2}) } const screen = flags.screen ?? firstScreenId(payload.flow) const renderUrl = buildRenderUrl(renderBaseUrl, {device: flags.device, screen}, payload) - const payloadPath = await writePayloadFile(payload, flags['payload-out'] ? resolve(flags['payload-out']) : undefined) + + let payloadPath: string | undefined + if (flags['payload-out']) { + payloadPath = resolve(flags['payload-out']) + await writePayloadFile(payload, payloadPath) + } + + const config = payloadPath ? ` --config "${payloadPath}"` : '' + // Key order is the print order: the URL is the primary handle. + /* eslint-disable perfectionist/sort-objects */ const result: PreviewResult = { - payloadPath, - referenceCommand: buildReferenceCommand({renderUrl}), - renderUrl, + render_url: renderUrl, + reference_command: `npx --yes --package=playwright node "${referenceScriptPath()}" --url "${renderUrl}"${config} --out "preview.png"`, + payload_path: payloadPath, } + /* eslint-enable perfectionist/sort-objects */ - this.log(`Render URL: ${result.renderUrl}`) - this.log(`Payload file: ${result.payloadPath}`) - this.log(`Reference command: ${result.referenceCommand}`) + printResponse(result as unknown as Record, this.log.bind(this)) this.log('') + if (!screen) this.log('No screen id found in the config; the render page will pick its own default.') this.log('Open the render URL with any browser tool and screenshot [data-screen-content], or run the') - this.log('reference command. For a config too large for a URL, add --config to it.') + this.log('reference command.') + if (!payloadPath) { + this.log('If the config is too large for a URL, re-run with --payload-out and add') + this.log('--config to the reference command to use the page file input instead.') + } return result } diff --git a/src/lib/preview-config.ts b/src/lib/preview-config.ts deleted file mode 100644 index 41d4e8a..0000000 --- a/src/lib/preview-config.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {mkdir, mkdtemp, writeFile} from 'node:fs/promises' -import {tmpdir} from 'node:os' -import {dirname, join} from 'node:path' - -/** Payload the render page expects: the builder config plus its remote configs. */ -export interface PreviewPayload { - flow: Record - remoteConfigs: unknown[] -} - -const BUILDER_CONFIG_KEYS = ['screens', 'locales', 'theme'] as const - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isBuilderConfig(value: Record): boolean { - return BUILDER_CONFIG_KEYS.some((key) => key in value) -} - -/** - * Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare - * builder config, and returns the shape the render page injects. - */ -export function normalizePreviewConfig(raw: unknown): PreviewPayload { - if (!isRecord(raw)) { - throw new Error('Config file must contain a JSON object.') - } - - if (isRecord(raw.config)) { - const remoteConfigs = raw.remote_configs - return { - flow: raw.config, - remoteConfigs: Array.isArray(remoteConfigs) ? remoteConfigs : [], - } - } - - if (isBuilderConfig(raw)) { - return {flow: raw, remoteConfigs: []} - } - - throw new Error( - 'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a builder config with `screens`, `locales` or `theme`.', - ) -} - -/** First screen id declared by the config, used when `--screen` is omitted. */ -export function firstScreenId(flow: Record): string | undefined { - const {screens} = flow - if (Array.isArray(screens)) { - for (const screen of screens) { - if (isRecord(screen) && typeof screen.id === 'string') return screen.id - } - - return undefined - } - - if (isRecord(screens)) { - const [first] = Object.keys(screens) - return first - } - - return undefined -} - -/** Writes the payload where a browser file input can pick it up. */ -export async function writePayloadFile(payload: PreviewPayload, outPath?: string): Promise { - if (outPath) { - await mkdir(dirname(outPath), {recursive: true}) - await writeFile(outPath, JSON.stringify(payload), 'utf8') - return outPath - } - - const dir = await mkdtemp(join(tmpdir(), 'adapty-preview-')) - const path = join(dir, 'flow-config.json') - await writeFile(path, JSON.stringify(payload), 'utf8') - return path -} diff --git a/src/lib/preview-reference.ts b/src/lib/preview-reference.ts deleted file mode 100644 index baa27e6..0000000 --- a/src/lib/preview-reference.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {fileURLToPath} from 'node:url' - -/** - * Self-contained Playwright script shipped alongside the CLI. Playwright itself is supplied by - * whoever runs it (npx or the caller's project), so the CLI never depends on it. - */ -export function referenceScriptPath(): string { - return fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) -} - -export interface ReferenceCommandOptions { - outPath?: string - renderUrl: string -} - -export function buildReferenceCommand(opts: ReferenceCommandOptions): string { - const script = referenceScriptPath() - const out = opts.outPath ?? 'preview.png' - return `npx --yes --package=playwright node "${script}" --url "${opts.renderUrl}" --out "${out}"` -} diff --git a/src/lib/preview-url.ts b/src/lib/preview-url.ts deleted file mode 100644 index 2190090..0000000 --- a/src/lib/preview-url.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {gzipSync} from 'node:zlib' - -import type {PreviewPayload} from './preview-config.js' - -export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' -export const DEFAULT_DEVICE_ID = 'iphone-14' -/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ -export const FRAGMENT_GZIP_PREFIX = 'gz:' - -export interface RenderTarget { - device: string - screen?: string -} - -/** - * The render page is being built in parallel, so its URL is never hard-coded: it comes from - * the flag or from ADAPTY_PREVIEW_RENDER_URL. - */ -export function resolveRenderUrl(flagValue?: string): string { - const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] - if (!url) { - throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) - } - - return url -} - -/** - * Wire format shared with the render page: `gz:`. The page also accepts - * plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. - */ -export function encodeConfigFragment(payload: PreviewPayload): string { - const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) - return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` -} - -export function buildRenderUrl(renderUrl: string, target: RenderTarget, payload?: PreviewPayload): string { - const url = new URL(renderUrl) - if (target.screen) url.searchParams.set('screen', target.screen) - if (target.device) url.searchParams.set('device', target.device) - if (payload) url.hash = `config=${encodeConfigFragment(payload)}` - return url.toString() -} diff --git a/src/lib/preview.ts b/src/lib/preview.ts new file mode 100644 index 0000000..714c9dd --- /dev/null +++ b/src/lib/preview.ts @@ -0,0 +1,100 @@ +import {writeFile} from 'node:fs/promises' +import {gzipSync} from 'node:zlib' + +export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' +export const DEFAULT_DEVICE_ID = 'iphone-14' + +/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ +const FRAGMENT_GZIP_PREFIX = 'gz:' +const BUILDER_CONFIG_KEYS = ['screens', 'locales', 'theme'] as const + +/** 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) +} + +function isBuilderConfig(value: Record): boolean { + return BUILDER_CONFIG_KEYS.some((key) => key in value) +} + +/** + * Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare + * builder config, and returns the shape the render page injects. + */ +export function normalizePreviewConfig(raw: unknown): PreviewPayload { + if (!isRecord(raw)) { + throw new Error('Config file must contain a JSON object.') + } + + if (isRecord(raw.config)) { + const remoteConfigs = raw.remote_configs + return { + flow: raw.config, + remoteConfigs: Array.isArray(remoteConfigs) ? remoteConfigs : [], + } + } + + if (isBuilderConfig(raw)) { + return {flow: raw, remoteConfigs: []} + } + + throw new Error( + 'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a builder config with `screens`, `locales` or `theme`.', + ) +} + +/** First screen id declared by the config, used when `--screen` is omitted. */ +export function firstScreenId(flow: Record): string | undefined { + const {screens} = flow + if (!Array.isArray(screens)) return undefined + + for (const screen of screens) { + if (isRecord(screen) && typeof screen.id === 'string') return screen.id + } + + return undefined +} + +/** + * The render page is being built in parallel, so its URL is never hard-coded: it comes from + * the flag or from ADAPTY_PREVIEW_RENDER_URL. + */ +export function resolveRenderUrl(flagValue?: string): string { + const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] + if (!url) { + throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) + } + + return url +} + +/** + * Fragment wire format shared with the render page: `gz:`. The page also + * accepts plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. + */ +function encodeConfigFragment(payload: PreviewPayload): string { + const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) + return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` +} + +export function buildRenderUrl( + baseUrl: string, + target: {device: string; screen?: string}, + payload: PreviewPayload, +): string { + const url = new URL(baseUrl) + if (target.screen) url.searchParams.set('screen', target.screen) + url.searchParams.set('device', target.device) + url.hash = `config=${encodeConfigFragment(payload)}` + return url.toString() +} + +/** Writes the payload for the render page's file input, the escape hatch for oversized configs. */ +export async function writePayloadFile(payload: PreviewPayload, outPath: string): Promise { + await writeFile(outPath, JSON.stringify(payload), 'utf8') +} diff --git a/test/commands/preview.test.ts b/test/commands/preview.test.ts index 56b7012..dc7dae0 100644 --- a/test/commands/preview.test.ts +++ b/test/commands/preview.test.ts @@ -1,6 +1,7 @@ import {runCommand} from '@oclif/test' import {expect} from 'chai' -import {mkdtempSync, readFileSync} from 'node:fs' +import {execFileSync} from 'node:child_process' +import {existsSync, mkdtempSync, readFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' import {fileURLToPath} from 'node:url' @@ -8,35 +9,56 @@ import {fileURLToPath} from 'node:url' import type {PreviewResult} from '../../src/commands/preview.js' const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) +const SCRIPT_PATH = fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) describe('preview command', () => { + beforeEach(() => { + process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + }) + afterEach(() => { delete process.env.ADAPTY_PREVIEW_RENDER_URL }) - it('prepares a render URL, a payload file and a reference command', async () => { - process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + it('prints a render URL and a command for the shipped reference script', async () => { const {result} = await runCommand(['preview', FIXTURE_PATH, '--json']) if (!result) throw new Error('preview returned no result') - expect(result.renderUrl.startsWith('https://app.example/preview?screen=welcome&device=iphone-14#config=gz:')).to.equal( + expect(result.render_url.startsWith('https://app.example/preview?screen=welcome&device=iphone-14#config=gz:')).to.equal( true, ) - expect(result.referenceCommand).to.contain(result.renderUrl) - expect(JSON.parse(readFileSync(result.payloadPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) + expect(result.payload_path).to.equal(undefined) + expect(existsSync(SCRIPT_PATH)).to.equal(true) + expect(result.reference_command).to.contain(`node "${SCRIPT_PATH}"`) + expect(result.reference_command).to.contain(`--url "${result.render_url}"`) + expect(result.reference_command).to.not.contain('--config') }) - it('writes the payload where --payload-out asks', async () => { - process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + it('writes the payload and wires --config into the reference command with --payload-out', async () => { const outPath = join(mkdtempSync(join(tmpdir(), 'adapty-preview-test-')), 'payload.json') const {result} = await runCommand(['preview', FIXTURE_PATH, '--payload-out', outPath, '--json']) - expect(result?.payloadPath).to.equal(outPath) + expect(result?.payload_path).to.equal(outPath) + expect(result?.reference_command).to.contain(`--config "${outPath}"`) expect(JSON.parse(readFileSync(outPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) }) it('fails without a render URL', async () => { + delete process.env.ADAPTY_PREVIEW_RENDER_URL const {error} = await runCommand(['preview', FIXTURE_PATH]) expect(error?.message).to.contain('ADAPTY_PREVIEW_RENDER_URL') }) + + it('the reference script rejects bad arguments with usage', () => { + for (const argv of [[], ['--bogus', 'x']]) { + try { + execFileSync(process.execPath, [SCRIPT_PATH, ...argv], {encoding: 'utf8', stdio: 'pipe'}) + throw new Error(`expected the script to fail for ${JSON.stringify(argv)}`) + } catch (error) { + const {status, stderr} = error as {status?: number; stderr?: string} + expect(status).to.equal(2) + expect(stderr).to.contain('Usage: node preview-with-playwright.mjs --url') + } + } + }) }) diff --git a/test/fixtures/flow-config.json b/test/fixtures/flow-config.json index c7fb1d0..777fb44 100644 --- a/test/fixtures/flow-config.json +++ b/test/fixtures/flow-config.json @@ -1,146 +1,66 @@ { "config": { "id": "9f1b7c4e-6d2a-4f58-8b3d-1a0c5e7d9f21", - "version": 4, "default_locale": "en", - "locales": ["en", "de"], + "locales": ["en"], "theme": { - "colors": { - "accent": "#5B4DF5", - "background": "#FFFFFF", - "surface": "#F4F4F8", - "text_primary": "#111114", - "text_secondary": "#6B6B76" - }, - "typography": { - "body": {"font": "SFProText", "size": 15, "weight": 400}, - "caption": {"font": "SFProText", "size": 12, "weight": 400}, - "title": {"font": "SFProDisplay", "size": 28, "weight": 700} - }, - "radius": {"button": 14, "card": 20} + "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", - "background": {"type": "image", "asset_id": "hero_gradient"}, "elements": [ - {"id": "welcome_title", "type": "text", "content_key": "welcome.title", "style": "title"}, - {"id": "welcome_subtitle", "type": "text", "content_key": "welcome.subtitle", "style": "body"}, + {"content_key": "welcome.title", "id": "welcome_title", "style": "title", "type": "text"}, { - "id": "welcome_cta", - "type": "button", + "action": {"screen_id": "offer", "type": "navigate"}, "content_key": "welcome.cta", - "action": {"type": "navigate", "screen_id": "features"} - } - ] - }, - { - "id": "features", - "type": "onboarding", - "background": {"type": "color", "token": "background"}, - "elements": [ - {"id": "features_title", "type": "text", "content_key": "features.title", "style": "title"}, - { - "id": "features_list", - "type": "list", - "items": [ - {"icon": "sparkles", "content_key": "features.item_1"}, - {"icon": "chart", "content_key": "features.item_2"}, - {"icon": "lock", "content_key": "features.item_3"} - ] - }, - { - "id": "features_cta", - "type": "button", - "content_key": "features.cta", - "action": {"type": "navigate", "screen_id": "offer"} + "id": "welcome_cta", + "type": "button" } ] }, { "id": "offer", "type": "paywall", - "background": {"type": "color", "token": "surface"}, "elements": [ - {"id": "offer_title", "type": "text", "content_key": "offer.title", "style": "title"}, + {"content_key": "offer.title", "id": "offer_title", "style": "title", "type": "text"}, { "id": "offer_products", - "type": "product_list", - "layout": "vertical", "products": [ - {"product_id": "premium_yearly", "badge_key": "offer.badge_best_value", "selected": true}, - {"product_id": "premium_monthly", "badge_key": null, "selected": false} - ] + {"badge_key": "offer.badge_best_value", "product_id": "premium_yearly", "selected": true}, + {"badge_key": null, "product_id": "premium_monthly", "selected": false} + ], + "type": "product_list" }, - {"id": "offer_purchase", "type": "button", "content_key": "offer.purchase", "action": {"type": "purchase"}}, - {"id": "offer_restore", "type": "link", "content_key": "offer.restore", "action": {"type": "restore"}}, - {"id": "offer_terms", "type": "text", "content_key": "offer.terms", "style": "caption"} + {"action": {"type": "purchase"}, "content_key": "offer.purchase", "id": "offer_purchase", "type": "button"} ] }, { "id": "offer_discount", "type": "paywall", - "shown_when": {"type": "close_attempt", "screen_id": "offer"}, - "background": {"type": "color", "token": "surface"}, + "shown_when": {"screen_id": "offer", "type": "close_attempt"}, "elements": [ - {"id": "discount_title", "type": "text", "content_key": "discount.title", "style": "title"}, - { - "id": "discount_products", - "type": "product_list", - "layout": "vertical", - "products": [{"product_id": "premium_yearly_intro", "badge_key": "discount.badge", "selected": true}] - }, - {"id": "discount_purchase", "type": "button", "content_key": "discount.purchase", "action": {"type": "purchase"}}, - {"id": "discount_close", "type": "link", "content_key": "discount.close", "action": {"type": "close"}} + {"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": { - "welcome.title": "Track everything that matters", - "welcome.subtitle": "Habits, workouts and mood in one place.", - "welcome.cta": "Get started", - "features.title": "Why go Premium", - "features.item_1": "Unlimited habits and reminders", - "features.item_2": "Full history and trends", - "features.item_3": "Private, encrypted backups", - "features.cta": "See plans", - "offer.title": "Unlock Premium", + "discount.close": "No thanks", + "discount.title": "One last thing", "offer.badge_best_value": "Best value", "offer.purchase": "Start free trial", - "offer.restore": "Restore purchase", - "offer.terms": "7 days free, then billed yearly. Cancel anytime.", - "discount.title": "One last thing", - "discount.badge": "50% off first year", - "discount.purchase": "Claim discount", - "discount.close": "No thanks" - }, - "de": { - "welcome.title": "Behalte alles im Blick", - "welcome.subtitle": "Gewohnheiten, Workouts und Stimmung an einem Ort.", - "welcome.cta": "Los geht's", - "features.title": "Warum Premium", - "features.item_1": "Unbegrenzte Gewohnheiten und Erinnerungen", - "features.item_2": "Vollständiger Verlauf und Trends", - "features.item_3": "Private, verschlüsselte Backups", - "features.cta": "Tarife ansehen", - "offer.title": "Premium freischalten", - "offer.badge_best_value": "Bester Preis", - "offer.purchase": "Kostenlos testen", - "offer.restore": "Kauf wiederherstellen", - "offer.terms": "7 Tage kostenlos, danach jährliche Abrechnung. Jederzeit kündbar.", - "discount.title": "Noch eine Sache", - "discount.badge": "50 % Rabatt im ersten Jahr", - "discount.purchase": "Rabatt sichern", - "discount.close": "Nein danke" + "offer.title": "Unlock Premium", + "welcome.cta": "Get started", + "welcome.title": "Track everything that matters" } } }, - "remote_configs": [ - {"locale": "en", "data": {"experiment": "onboarding_v4", "show_discount": true}}, - {"locale": "de", "data": {"experiment": "onboarding_v4", "show_discount": false}} - ], + "remote_configs": [{"data": {"experiment": "onboarding_v4"}, "locale": "en"}], "status": "draft", "updated_at": "2026-08-14T09:12:44Z" } diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index 81ede8e..fe3035e 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -1,124 +1,86 @@ import {expect} from 'chai' -import {existsSync, readFileSync} from 'node:fs' -import {join} from 'node:path' +import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {gunzipSync} from 'node:zlib' -import {firstScreenId, normalizePreviewConfig} from '../../src/lib/preview-config.js' -import {buildReferenceCommand, referenceScriptPath} from '../../src/lib/preview-reference.js' -import {buildRenderUrl, FRAGMENT_GZIP_PREFIX, resolveRenderUrl} from '../../src/lib/preview-url.js' +import {buildRenderUrl, firstScreenId, normalizePreviewConfig, resolveRenderUrl} 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. */ function decodeConfigFragment(hash: string): unknown { const encoded = new URLSearchParams(hash.slice(1)).get('config') - if (!encoded?.startsWith(FRAGMENT_GZIP_PREFIX)) throw new Error(`Not a gzipped fragment: ${encoded}`) - const gzipped = Buffer.from(encoded.slice(FRAGMENT_GZIP_PREFIX.length), 'base64url') - return JSON.parse(gunzipSync(gzipped).toString('utf8')) as unknown + if (!encoded?.startsWith('gz:')) throw new Error(`Not a gzipped fragment: ${encoded}`) + return JSON.parse(gunzipSync(Buffer.from(encoded.slice(3), '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', + 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'}]}) }) - 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: {en: {}}, theme: {}} - expect(normalizePreviewConfig(flow)).to.deep.equal({flow, remoteConfigs: []}) - }) - - it('rejects unrecognized JSON', () => { - expect(() => normalizePreviewConfig({hello: 'world'})).to.throw('Unrecognized config file') - expect(() => normalizePreviewConfig([1, 2])).to.throw('must contain a JSON object') - }) - - it('finds the first screen id in arrays and records', () => { - expect(firstScreenId({screens: [{id: 'welcome'}, {id: 'offer'}]})).to.equal('welcome') - expect(firstScreenId({screens: {offer: {}, welcome: {}}})).to.equal('offer') - expect(firstScreenId({})).to.equal(undefined) - }) -}) - -describe('render url', () => { - afterEach(() => { - delete process.env.ADAPTY_PREVIEW_RENDER_URL - }) - - it('prefers the flag over the env var', () => { - process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://env.example/render' - expect(resolveRenderUrl('https://flag.example/render')).to.equal('https://flag.example/render') - expect(resolveRenderUrl()).to.equal('https://env.example/render') - }) - - it('errors when neither is set', () => { - expect(() => resolveRenderUrl()).to.throw('ADAPTY_PREVIEW_RENDER_URL') - }) - - it('adds screen and device query params', () => { - expect(buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'welcome'})).to.equal( - 'https://app.example/preview?screen=welcome&device=iphone-14', - ) - }) + it('defaults missing remote_configs to an empty list', () => { + expect(normalizePreviewConfig({config: {screens: []}}).remoteConfigs).to.deep.equal([]) + }) - it('omits the screen param when unknown', () => { - expect(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'})).to.equal( - 'https://app.example/preview?device=iphone-14', - ) - }) + it('wraps a bare builder config', () => { + const flow = {locales: {en: {}}, theme: {}} + expect(normalizePreviewConfig(flow)).to.deep.equal({flow, remoteConfigs: []}) + }) - it('carries a real config through the gzipped fragment fallback', () => { - const payload = normalizePreviewConfig(FIXTURE) - const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'offer'}, payload)) + it('rejects unrecognized JSON', () => { + expect(() => normalizePreviewConfig({hello: 'world'})).to.throw('Unrecognized config file') + expect(() => normalizePreviewConfig([1, 2])).to.throw('must contain a JSON object') + }) - expect(url.hash.startsWith(`#config=${FRAGMENT_GZIP_PREFIX}`)).to.equal(true) - expect(url.hash.slice(`#config=${FRAGMENT_GZIP_PREFIX}`.length)).to.match(/^[\w-]+$/) - expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) + it('finds the first screen id, and none when screens is not an array', () => { + expect(firstScreenId({screens: [{id: 'welcome'}, {id: 'offer'}]})).to.equal('welcome') + expect(firstScreenId({screens: {welcome: {}}})).to.equal(undefined) + expect(firstScreenId({})).to.equal(undefined) + }) }) - it('compresses the fragment well below the raw JSON size', () => { - const payload = normalizePreviewConfig(FIXTURE) - const raw = JSON.stringify(payload) - const {hash} = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) + describe('render url', () => { + afterEach(() => { + delete process.env.ADAPTY_PREVIEW_RENDER_URL + }) - expect(hash.length).to.be.lessThan(raw.length) - }) + it('prefers the flag over the env var', () => { + process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://env.example/render' + expect(resolveRenderUrl('https://flag.example/render')).to.equal('https://flag.example/render') + expect(resolveRenderUrl()).to.equal('https://env.example/render') + }) - it('does not cap the fragment size', () => { - const payload = {flow: {padding: 'x'.repeat(200_000), screens: []}, remoteConfigs: []} - const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) + it('errors when neither is set', () => { + expect(() => resolveRenderUrl()).to.throw('ADAPTY_PREVIEW_RENDER_URL') + }) - expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) - }) -}) + it('omits the screen param when unknown', () => { + const payload = normalizePreviewConfig(FIXTURE) + const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) -describe('reference script', () => { - it('ships the reference Playwright script', () => { - const path = referenceScriptPath() - expect(path.endsWith(join('scripts', 'preview-with-playwright.mjs'))).to.equal(true) - expect(existsSync(path)).to.equal(true) - }) + expect(url.searchParams.get('screen')).to.equal(null) + expect(url.searchParams.get('device')).to.equal('iphone-14') + }) - it('points the reference command at that script and the render URL', () => { - const renderUrl = 'https://app.example/preview?device=iphone-14#config=gz:abc' - const command = buildReferenceCommand({renderUrl}) + it('carries a real config through the gzipped fragment', () => { + const payload = normalizePreviewConfig(FIXTURE) + const url = new URL( + buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'offer'}, payload), + ) - expect(command).to.contain(`node "${referenceScriptPath()}"`) - expect(command).to.contain(`--url "${renderUrl}"`) - expect(command).to.contain('--out "preview.png"') - expect(command).to.contain('--package=playwright') + expect(url.searchParams.get('screen')).to.equal('offer') + expect(url.hash.slice('#config=gz:'.length)).to.match(/^[\w-]+$/) + expect(decodeConfigFragment(url.hash)).to.deep.equal(payload) + }) }) }) -}) From d612728303eb2d725f4b1721f2df28e28f0a35c2 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 12:38:18 +0500 Subject: [PATCH 06/12] feat(preview): align render URL with the flow-preview route (orientation, unprefixed gzip fragment, screens guard) --- CLAUDE.md | 2 +- README.md | 2 +- skills/adapty-cli/references/cli-commands.md | 19 ++++-- src/commands/preview.ts | 20 ++++-- src/lib/preview.ts | 70 ++++++++------------ test/commands/preview.test.ts | 26 +++++++- test/lib/preview.test.ts | 46 +++++++------ 7 files changed, 106 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cecf4a9..14322be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ src/ 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 / gz: fragment building + preview.ts # flow config normalization + render URL / gzip fragment building scripts/ preview-with-playwright.mjs # reference renderer shipped to consumers; Playwright is NOT a CLI dependency ``` diff --git a/README.md b/README.md index e9579f3..0032a74 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ handles it prints, the reference `npx` script, and the oversized-config path. | `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_PREVIEW_RENDER_URL` | Base URL of the public paywall render page used by `adapty preview` (no default) | +| `ADAPTY_PREVIEW_RENDER_URL` | Base URL of the render page used by `adapty preview` — the dashboard's `/flow-preview` route (no default) | 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 66010aa..dba3e5e 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -103,17 +103,22 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v Prep-only: takes a **local** flow config JSON file, normalizes it, and prints the handles you need to render it. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. -Accepts either a dashboard-api envelope (`{config, remote_configs, ...}`) or a bare builder config (`screens` -/ `locales` / `theme`); both normalize to `{flow, remoteConfigs}` (camelCase: that payload is a wire format -shared with the render page). +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. -Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: first screen in the -config), `--device` (default: `iphone-14`), `--payload-out` (off by default). +Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: the render page falls +back to the flow's first screen), `--device` (default: `iphone-14`), `--orientation` (`portrait` | +`landscape`, default `portrait`), `--payload-out` (off by default). Output (`--json`): `{render_url, reference_command, payload_path?}`. -- `render_url` — `?screen=&device=#config=gz:`. Tool-agnostic: open it - with your own browser/computer-use tool and screenshot the `[data-screen-content]` element. +- `render_url` — `?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. Tool-agnostic: open the URL with your own browser/computer-use tool and + screenshot the `[data-screen-content]` element. An unknown `device` renders an error message instead of a + screen, so pass one the builder knows. - `reference_command` — the exact `npx --yes --package=playwright node /scripts/preview-with-playwright.mjs --url "" --out "preview.png"` invocation. Chromium itself: `npx playwright install chromium` once. - `payload_path` — present only with `--payload-out `. For a config too large to sit in a URL, re-run diff --git a/src/commands/preview.ts b/src/commands/preview.ts index 5a0ae24..38a9877 100644 --- a/src/commands/preview.ts +++ b/src/commands/preview.ts @@ -7,8 +7,9 @@ import {printResponse} from '../lib/output.js' import { buildRenderUrl, DEFAULT_DEVICE_ID, - firstScreenId, + DEFAULT_ORIENTATION, normalizePreviewConfig, + ORIENTATIONS, type PreviewPayload, RENDER_URL_ENV_VAR, resolveRenderUrl, @@ -37,15 +38,20 @@ static description = 'Prepare a render URL for a local flow config, then screens static enableJsonFlag = true static examples = [ '<%= config.bin %> preview ./paywall.json', - '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --json', + '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --orientation landscape --json', ] 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], + }), 'payload-out': Flags.string({ description: 'Also write the normalized payload JSON here, for configs too large to sit in a URL', }), 'render-url': Flags.string({description: `Render page base URL (defaults to $${RENDER_URL_ENV_VAR})`}), - screen: Flags.string({description: 'Screen ID to render (default: first screen in the config)'}), + screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}), } async run(): Promise { @@ -68,8 +74,11 @@ static flags = { this.error(describeError(error), {exit: 2}) } - const screen = flags.screen ?? firstScreenId(payload.flow) - const renderUrl = buildRenderUrl(renderBaseUrl, {device: flags.device, screen}, payload) + const renderUrl = buildRenderUrl( + renderBaseUrl, + {device: flags.device, orientation: flags.orientation, screen: flags.screen}, + payload, + ) let payloadPath: string | undefined if (flags['payload-out']) { @@ -89,7 +98,6 @@ static flags = { printResponse(result as unknown as Record, this.log.bind(this)) this.log('') - if (!screen) this.log('No screen id found in the config; the render page will pick its own default.') this.log('Open the render URL with any browser tool and screenshot [data-screen-content], or run the') this.log('reference command.') if (!payloadPath) { diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 714c9dd..71d54e0 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -3,10 +3,9 @@ import {gzipSync} from 'node:zlib' export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' export const DEFAULT_DEVICE_ID = 'iphone-14' - -/** Marks a fragment payload as gzipped base64url rather than plain url-encoded JSON. */ -const FRAGMENT_GZIP_PREFIX = 'gz:' -const BUILDER_CONFIG_KEYS = ['screens', 'locales', 'theme'] as const +/** 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 { @@ -18,46 +17,29 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function isBuilderConfig(value: Record): boolean { - return BUILDER_CONFIG_KEYS.some((key) => key in 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.') } - if (isRecord(raw.config)) { - const remoteConfigs = raw.remote_configs - return { - flow: raw.config, - remoteConfigs: Array.isArray(remoteConfigs) ? remoteConfigs : [], - } - } - - if (isBuilderConfig(raw)) { - return {flow: raw, remoteConfigs: []} - } - - throw new Error( - 'Unrecognized config file. Expected a dashboard-api envelope with a `config` object, or a builder config with `screens`, `locales` or `theme`.', - ) -} - -/** First screen id declared by the config, used when `--screen` is omitted. */ -export function firstScreenId(flow: Record): string | undefined { - const {screens} = flow - if (!Array.isArray(screens)) return undefined - - for (const screen of screens) { - if (isRecord(screen) && typeof screen.id === 'string') return screen.id + 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.', + ) } - return undefined + const remoteConfigs = envelopeConfig && Array.isArray(raw.remote_configs) ? raw.remote_configs : [] + return {flow, remoteConfigs} } /** @@ -74,22 +56,26 @@ export function resolveRenderUrl(flagValue?: string): string { } /** - * Fragment wire format shared with the render page: `gz:`. The page also - * accepts plain url-encoded JSON, but the CLI always compresses so large configs fit in a URL. + * 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 { - const gzipped = gzipSync(Buffer.from(JSON.stringify(payload), 'utf8')) - return `${FRAGMENT_GZIP_PREFIX}${gzipped.toString('base64url')}` + 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( - baseUrl: string, - target: {device: string; screen?: string}, - payload: PreviewPayload, -): string { +export function buildRenderUrl(baseUrl: string, target: RenderTarget, payload: PreviewPayload): string { const url = new URL(baseUrl) 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/preview.test.ts b/test/commands/preview.test.ts index dc7dae0..abe6558 100644 --- a/test/commands/preview.test.ts +++ b/test/commands/preview.test.ts @@ -24,9 +24,9 @@ describe('preview command', () => { const {result} = await runCommand(['preview', FIXTURE_PATH, '--json']) if (!result) throw new Error('preview returned no result') - expect(result.render_url.startsWith('https://app.example/preview?screen=welcome&device=iphone-14#config=gz:')).to.equal( - true, - ) + expect( + result.render_url.startsWith('https://app.example/preview?device=iphone-14&orientation=portrait#config='), + ).to.equal(true) expect(result.payload_path).to.equal(undefined) expect(existsSync(SCRIPT_PATH)).to.equal(true) expect(result.reference_command).to.contain(`node "${SCRIPT_PATH}"`) @@ -43,6 +43,26 @@ describe('preview command', () => { expect(JSON.parse(readFileSync(outPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) }) + it('puts the requested screen and orientation in the URL', async () => { + const {result} = await runCommand([ + '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(['preview', FIXTURE_PATH, '--orientation', 'sideways']) + expect(error?.message).to.contain('sideways') + }) + it('fails without a render URL', async () => { delete process.env.ADAPTY_PREVIEW_RENDER_URL const {error} = await runCommand(['preview', FIXTURE_PATH]) diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index fe3035e..ce70f7e 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -3,16 +3,19 @@ import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {gunzipSync} from 'node:zlib' -import {buildRenderUrl, firstScreenId, normalizePreviewConfig, resolveRenderUrl} from '../../src/lib/preview.js' +import {buildRenderUrl, normalizePreviewConfig, resolveRenderUrl} 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. */ +/** + * 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 = new URLSearchParams(hash.slice(1)).get('config') - if (!encoded?.startsWith('gz:')) throw new Error(`Not a gzipped fragment: ${encoded}`) - return JSON.parse(gunzipSync(Buffer.from(encoded.slice(3), 'base64url')).toString('utf8')) as 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', () => { @@ -33,20 +36,17 @@ describe('preview', () => { }) it('wraps a bare builder config', () => { - const flow = {locales: {en: {}}, theme: {}} + const flow = {locales: [{code: 'en'}], screens: [{id: 'welcome'}], theme: {}} expect(normalizePreviewConfig(flow)).to.deep.equal({flow, remoteConfigs: []}) }) - it('rejects unrecognized JSON', () => { - expect(() => normalizePreviewConfig({hello: 'world'})).to.throw('Unrecognized config file') + 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') }) - - it('finds the first screen id, and none when screens is not an array', () => { - expect(firstScreenId({screens: [{id: 'welcome'}, {id: 'offer'}]})).to.equal('welcome') - expect(firstScreenId({screens: {welcome: {}}})).to.equal(undefined) - expect(firstScreenId({})).to.equal(undefined) - }) }) describe('render url', () => { @@ -64,22 +64,30 @@ describe('preview', () => { expect(() => resolveRenderUrl()).to.throw('ADAPTY_PREVIEW_RENDER_URL') }) - it('omits the screen param when unknown', () => { + it('omits the screen param when none was asked for, letting the page pick the first', () => { const payload = normalizePreviewConfig(FIXTURE) - const url = new URL(buildRenderUrl('https://app.example/preview', {device: 'iphone-14'}, payload)) + const url = new URL( + buildRenderUrl('https://app.example/preview', {device: 'iphone-14', orientation: 'portrait'}, payload), + ) 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', () => { + it('carries a real config through the gzipped fragment, unprefixed and padding-free', () => { const payload = normalizePreviewConfig(FIXTURE) const url = new URL( - buildRenderUrl('https://app.example/preview', {device: 'iphone-14', screen: 'offer'}, payload), + buildRenderUrl( + 'https://app.example/preview', + {device: 'ipad-pro', orientation: 'landscape', screen: 'offer'}, + payload, + ), ) expect(url.searchParams.get('screen')).to.equal('offer') - expect(url.hash.slice('#config=gz:'.length)).to.match(/^[\w-]+$/) + 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) }) }) From 923204e0d79934f239613eb869683785a15d1245 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 14:55:04 +0500 Subject: [PATCH 07/12] feat(preview): move preview under flows config, env-only render host, open on TTY --- CLAUDE.md | 9 +-- README.md | 5 +- skills/adapty-cli/references/cli-commands.md | 50 ++++++++++------- src/commands/{ => flows/config}/preview.ts | 56 ++++++++++--------- src/lib/preview.ts | 26 ++++----- ...{preview.test.ts => flows-preview.test.ts} | 33 ++++++----- test/lib/preview.test.ts | 39 +++++++------ 7 files changed, 122 insertions(+), 96 deletions(-) rename src/commands/{ => flows/config}/preview.ts (61%) rename test/commands/{preview.test.ts => flows-preview.test.ts} (70%) diff --git a/CLAUDE.md b/CLAUDE.md index 14322be..2509642 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,13 +22,13 @@ 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) segments/ # list, get access-levels/ # list, get, create, update asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords, # negative-keywords, search-terms, ads, product-pages, creatives, automations, metrics, # competitors - preview.ts # local flow config → render URL for the public paywall render page lib/ api-client.ts # HTTP client (fetch-based, bearer auth) config.ts # ~/.config/adapty/config.json read/write @@ -56,8 +56,9 @@ scripts/ - 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_PREVIEW_RENDER_URL` env (or `--render-url`) sets the paywall render page base URL; `preview` never - drives a browser itself +- `ADAPTY_APP_URL` env sets the dashboard base URL for `flows config preview` (default `https://app.adapty.io`); + the `/flow-preview` route is fixed, and the command never renders anything itself — Playwright is not a + dependency, `scripts/preview-with-playwright.mjs` is a reference the caller runs via npx - 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 0032a74..e02d691 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ and the outcome shows up in `adapty asa automations runs`. ## Paywall Preview -`adapty preview ` turns a local flow config into a render URL you screenshot yourself — +`adapty flows config preview ` turns a local flow config into a render URL — it opens in your +browser on a TTY, and prints the bare URL when piped — **Playwright is not bundled**. See [skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for the handles it prints, the reference `npx` script, and the oversized-config path. @@ -270,7 +271,7 @@ handles it prints, the reference `npx` script, and the oversized-config path. | `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_PREVIEW_RENDER_URL` | Base URL of the render page used by `adapty preview` — the dashboard's `/flow-preview` route (no default) | +| `ADAPTY_APP_URL` | Override dashboard base URL used by `flows config preview` (default: `https://app.adapty.io`; the `/flow-preview` route itself is fixed) | 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 dba3e5e..4d88511 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -96,34 +96,42 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v ## Preview -| Command | Required flags | -|-------------------------------|----------------| -| `preview ` | none (needs a render URL) | +| Command | Required flags | +|-------------------------------------------|----------------| +| `flows config preview ` | none | -Prep-only: takes a **local** flow config JSON file, normalizes it, and prints the handles you need to render -it. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. +Takes a **local** flow config JSON file, normalizes it, and builds a render URL that carries the whole config +in its fragment. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. 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. -Flags: `--render-url` (or `ADAPTY_PREVIEW_RENDER_URL`, required), `--screen` (default: the render page falls -back to the flow's first screen), `--device` (default: `iphone-14`), `--orientation` (`portrait` | -`landscape`, default `portrait`), `--payload-out` (off by default). - -Output (`--json`): `{render_url, reference_command, payload_path?}`. - -- `render_url` — `?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. Tool-agnostic: open the URL with your own browser/computer-use tool and - screenshot the `[data-screen-content]` element. An unknown `device` renders an error message instead of a - screen, so pass one the builder knows. -- `reference_command` — the exact `npx --yes --package=playwright node /scripts/preview-with-playwright.mjs - --url "" --out "preview.png"` invocation. Chromium itself: `npx playwright install chromium` once. -- `payload_path` — present only with `--payload-out `. For a config too large to sit in a URL, re-run - with `--payload-out` and the printed `reference_command` will include `--config `, which feeds the - page's `[data-testid="preview-config-input"]` file input instead of the fragment. +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. + +Flags: `--screen` (default: the render page falls back to the flow's first screen), `--device` (default: +`iphone-14`), `--orientation` (`portrait` | `landscape`, default `portrait`), `--payload-out` (off by default). + +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, so `... | pbcopy` and `$(...)` work. +- **`--json`** — `{render_url, reference_command, payload_path?}`, and never opens a browser. + +`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. Any browser/computer-use tool can open the URL and screenshot the +`[data-screen-content]` element. An unknown `device` renders an error message instead of a screen, so pass one +the builder knows. + +`reference_command` (in `--json`) is the exact `npx --yes --package=playwright node +/scripts/preview-with-playwright.mjs --url "" --out "preview.png"` invocation; Chromium +itself needs `npx playwright install chromium` once. `payload_path` appears only with `--payload-out `, +and then `reference_command` includes `--config `, which feeds the page's +`[data-testid="preview-config-input"]` file input instead of the fragment — the escape hatch for a config too +large to sit in a URL. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/preview.ts b/src/commands/flows/config/preview.ts similarity index 61% rename from src/commands/preview.ts rename to src/commands/flows/config/preview.ts index 38a9877..1259f80 100644 --- a/src/commands/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -2,19 +2,18 @@ import {Args, Command, Flags} from '@oclif/core' import {readFile} from 'node:fs/promises' import {resolve} from 'node:path' import {fileURLToPath} from 'node:url' +import open from 'open' -import {printResponse} from '../lib/output.js' import { + APP_URL_ENV_VAR, buildRenderUrl, DEFAULT_DEVICE_ID, DEFAULT_ORIENTATION, normalizePreviewConfig, ORIENTATIONS, type PreviewPayload, - RENDER_URL_ENV_VAR, - resolveRenderUrl, writePayloadFile, -} from '../lib/preview.js' +} from '../../../lib/preview.js' export interface PreviewResult { payload_path?: string @@ -27,18 +26,19 @@ function describeError(error: unknown): string { } function referenceScriptPath(): string { - return fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) + return fileURLToPath(new URL('../../../../scripts/preview-with-playwright.mjs', import.meta.url)) } -export default class Preview extends Command { +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 = 'Prepare a render URL for a local flow config, then screenshot it with your own browser tool or the shipped reference script' +static description = `Build a render URL for a local flow config and open it. Opens the browser on a TTY; when piped, prints the URL alone. The render host comes from $${APP_URL_ENV_VAR}.` static enableJsonFlag = true static examples = [ - '<%= config.bin %> preview ./paywall.json', - '<%= config.bin %> preview ./paywall.json --screen welcome --device ipad-pro --orientation landscape --json', + '<%= config.bin %> flows config preview ./config.json', + '<%= config.bin %> flows config preview ./config.json --screen welcome --device ipad-pro --orientation landscape', + '<%= config.bin %> flows config preview ./config.json --json', ] static flags = { device: Flags.string({default: DEFAULT_DEVICE_ID, description: 'Device frame to render in'}), @@ -50,12 +50,11 @@ static flags = { 'payload-out': Flags.string({ description: 'Also write the normalized payload JSON here, for configs too large to sit in a URL', }), - 'render-url': Flags.string({description: `Render page base URL (defaults to $${RENDER_URL_ENV_VAR})`}), screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}), } async run(): Promise { - const {args, flags} = await this.parse(Preview) + const {args, flags} = await this.parse(FlowsConfigPreview) const configPath = resolve(args.config_file) let raw: unknown @@ -66,20 +65,17 @@ static flags = { } let payload: PreviewPayload - let renderBaseUrl: string + let renderUrl: string try { payload = normalizePreviewConfig(raw) - renderBaseUrl = resolveRenderUrl(flags['render-url']) + renderUrl = buildRenderUrl( + {device: flags.device, orientation: flags.orientation, screen: flags.screen}, + payload, + ) } catch (error) { this.error(describeError(error), {exit: 2}) } - const renderUrl = buildRenderUrl( - renderBaseUrl, - {device: flags.device, orientation: flags.orientation, screen: flags.screen}, - payload, - ) - let payloadPath: string | undefined if (flags['payload-out']) { payloadPath = resolve(flags['payload-out']) @@ -96,13 +92,21 @@ static flags = { } /* eslint-enable perfectionist/sort-objects */ - printResponse(result as unknown as Record, this.log.bind(this)) - this.log('') - this.log('Open the render URL with any browser tool and screenshot [data-screen-content], or run the') - this.log('reference command.') - if (!payloadPath) { - this.log('If the config is too large for a URL, re-run with --payload-out and add') - this.log('--config to the reference command to use the page file input instead.') + if (this.jsonEnabled()) return result + + // The URL carries the whole gzipped config, so it is far too long to read: open it instead, and + // when stdout is piped emit nothing but the URL so it stays composable. + if (process.stdout.isTTY === true) { + const target = [flags.screen ?? 'first screen', flags.device, flags.orientation].join(', ') + try { + await open(renderUrl) + this.log(`Opened the preview in your browser (${target}).`) + this.log('Run with --json for the render URL, the screenshot command and the payload path.') + } catch { + this.log(renderUrl) + } + } else { + this.log(renderUrl) } return result diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 71d54e0..355192f 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -1,7 +1,10 @@ import {writeFile} from 'node:fs/promises' import {gzipSync} from 'node:zlib' -export const RENDER_URL_ENV_VAR = 'ADAPTY_PREVIEW_RENDER_URL' +export const APP_URL_ENV_VAR = 'ADAPTY_APP_URL' +export const DEFAULT_APP_URL = 'https://app.adapty.io' +/** The render route is fixed; only its host is configurable. */ +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 @@ -42,17 +45,14 @@ export function normalizePreviewConfig(raw: unknown): PreviewPayload { return {flow, remoteConfigs} } -/** - * The render page is being built in parallel, so its URL is never hard-coded: it comes from - * the flag or from ADAPTY_PREVIEW_RENDER_URL. - */ -export function resolveRenderUrl(flagValue?: string): string { - const url = flagValue ?? process.env[RENDER_URL_ENV_VAR] - if (!url) { - throw new Error(`No render page URL. Pass --render-url or set ${RENDER_URL_ENV_VAR}.`) +/** Render page location: the fixed preview route on the dashboard host, ADAPTY_APP_URL by default. */ +function renderPageUrl(): URL { + const base = process.env[APP_URL_ENV_VAR] ?? DEFAULT_APP_URL + try { + return new URL(PREVIEW_PATH, base) + } catch { + throw new Error(`Invalid ${APP_URL_ENV_VAR}: ${base}`) } - - return url } /** @@ -71,8 +71,8 @@ export interface RenderTarget { screen?: string } -export function buildRenderUrl(baseUrl: string, target: RenderTarget, payload: PreviewPayload): string { - const url = new URL(baseUrl) +export function buildRenderUrl(target: RenderTarget, payload: PreviewPayload): string { + const url = renderPageUrl() if (target.screen) url.searchParams.set('screen', target.screen) url.searchParams.set('device', target.device) url.searchParams.set('orientation', target.orientation) diff --git a/test/commands/preview.test.ts b/test/commands/flows-preview.test.ts similarity index 70% rename from test/commands/preview.test.ts rename to test/commands/flows-preview.test.ts index abe6558..e42e73d 100644 --- a/test/commands/preview.test.ts +++ b/test/commands/flows-preview.test.ts @@ -6,26 +6,26 @@ import {tmpdir} from 'node:os' import {join} from 'node:path' import {fileURLToPath} from 'node:url' -import type {PreviewResult} from '../../src/commands/preview.js' +import type {PreviewResult} from '../../src/commands/flows/config/preview.js' const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/flow-config.json', import.meta.url)) const SCRIPT_PATH = fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) -describe('preview command', () => { +describe('flows config preview', () => { beforeEach(() => { - process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://app.example/preview' + process.env.ADAPTY_APP_URL = 'https://app.example' }) afterEach(() => { - delete process.env.ADAPTY_PREVIEW_RENDER_URL + delete process.env.ADAPTY_APP_URL }) it('prints a render URL and a command for the shipped reference script', async () => { - const {result} = await runCommand(['preview', FIXTURE_PATH, '--json']) + 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/preview?device=iphone-14&orientation=portrait#config='), + result.render_url.startsWith('https://app.example/flow-preview?device=iphone-14&orientation=portrait#config='), ).to.equal(true) expect(result.payload_path).to.equal(undefined) expect(existsSync(SCRIPT_PATH)).to.equal(true) @@ -36,7 +36,13 @@ describe('preview command', () => { it('writes the payload and wires --config into the reference command with --payload-out', async () => { const outPath = join(mkdtempSync(join(tmpdir(), 'adapty-preview-test-')), 'payload.json') - const {result} = await runCommand(['preview', FIXTURE_PATH, '--payload-out', outPath, '--json']) + const {result} = await runCommand([ + 'flows:config:preview', + FIXTURE_PATH, + '--payload-out', + outPath, + '--json', + ]) expect(result?.payload_path).to.equal(outPath) expect(result?.reference_command).to.contain(`--config "${outPath}"`) @@ -45,7 +51,7 @@ describe('preview command', () => { it('puts the requested screen and orientation in the URL', async () => { const {result} = await runCommand([ - 'preview', + 'flows:config:preview', FIXTURE_PATH, '--screen', 'offer', @@ -59,14 +65,15 @@ describe('preview command', () => { }) it('rejects an orientation the render page does not accept', async () => { - const {error} = await runCommand(['preview', FIXTURE_PATH, '--orientation', 'sideways']) + const {error} = await runCommand(['flows:config:preview', FIXTURE_PATH, '--orientation', 'sideways']) expect(error?.message).to.contain('sideways') }) - it('fails without a render URL', async () => { - delete process.env.ADAPTY_PREVIEW_RENDER_URL - const {error} = await runCommand(['preview', FIXTURE_PATH]) - expect(error?.message).to.contain('ADAPTY_PREVIEW_RENDER_URL') + 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) }) it('the reference script rejects bad arguments with usage', () => { diff --git a/test/lib/preview.test.ts b/test/lib/preview.test.ts index ce70f7e..28c1a56 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {gunzipSync} from 'node:zlib' -import {buildRenderUrl, normalizePreviewConfig, resolveRenderUrl} from '../../src/lib/preview.js' +import {APP_URL_ENV_VAR, 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 @@ -51,24 +51,33 @@ describe('preview', () => { describe('render url', () => { afterEach(() => { - delete process.env.ADAPTY_PREVIEW_RENDER_URL + delete process.env[APP_URL_ENV_VAR] }) - it('prefers the flag over the env var', () => { - process.env.ADAPTY_PREVIEW_RENDER_URL = 'https://env.example/render' - expect(resolveRenderUrl('https://flag.example/render')).to.equal('https://flag.example/render') - expect(resolveRenderUrl()).to.equal('https://env.example/render') + 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('errors when neither is set', () => { - expect(() => resolveRenderUrl()).to.throw('ADAPTY_PREVIEW_RENDER_URL') + 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 payload = normalizePreviewConfig(FIXTURE) - const url = new URL( - buildRenderUrl('https://app.example/preview', {device: 'iphone-14', orientation: 'portrait'}, payload), - ) + 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') @@ -78,11 +87,7 @@ describe('preview', () => { it('carries a real config through the gzipped fragment, unprefixed and padding-free', () => { const payload = normalizePreviewConfig(FIXTURE) const url = new URL( - buildRenderUrl( - 'https://app.example/preview', - {device: 'ipad-pro', orientation: 'landscape', screen: 'offer'}, - payload, - ), + buildRenderUrl({device: 'ipad-pro', orientation: 'landscape', screen: 'offer'}, payload), ) expect(url.searchParams.get('screen')).to.equal('offer') From b0c61706a2ea7e71a9ca6401bbc1445a9d95cbf8 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 15:21:19 +0500 Subject: [PATCH 08/12] feat(auth): honor ADAPTY_APP_URL for the login verification link --- CLAUDE.md | 10 +++-- README.md | 2 +- skills/adapty-cli/references/cli-commands.md | 3 +- src/commands/auth/login.ts | 16 +++++++- src/commands/flows/config/preview.ts | 2 +- src/lib/app-url.ts | 35 +++++++++++++++++ src/lib/preview.ts | 18 ++------- test/commands/auth/login.test.ts | 15 +++++++ test/lib/app-url.test.ts | 41 ++++++++++++++++++++ test/lib/preview.test.ts | 3 +- 10 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 src/lib/app-url.ts create mode 100644 test/lib/app-url.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2509642..3d06df1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ 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) @@ -56,9 +57,12 @@ scripts/ - 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 for `flows config preview` (default `https://app.adapty.io`); - the `/flow-preview` route is fixed, and the command never renders anything itself — Playwright is not a - dependency, `scripts/preview-with-playwright.mjs` is a reference the caller runs via npx +- `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` never renders anything itself — Playwright is not a dependency; + `scripts/preview-with-playwright.mjs` is a reference script the caller runs via npx - 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 e02d691..a663736 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ handles it prints, the reference `npx` script, and the oversized-config path. | `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 used by `flows config preview` (default: `https://app.adapty.io`; the `/flow-preview` route itself is fixed) | +| `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 4d88511..3f9cf6b 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -109,7 +109,8 @@ normalize to `{flow, remoteConfigs}` (camelCase: that payload is a wire format s 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. +`/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`), `--payload-out` (off by default). 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 index 1259f80..0dfcf7f 100644 --- a/src/commands/flows/config/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -4,8 +4,8 @@ import {resolve} from 'node:path' import {fileURLToPath} from 'node:url' import open from 'open' +import {APP_URL_ENV_VAR} from '../../../lib/app-url.js' import { - APP_URL_ENV_VAR, buildRenderUrl, DEFAULT_DEVICE_ID, DEFAULT_ORIENTATION, 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 index 355192f..6af1952 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -1,9 +1,9 @@ import {writeFile} from 'node:fs/promises' import {gzipSync} from 'node:zlib' -export const APP_URL_ENV_VAR = 'ADAPTY_APP_URL' -export const DEFAULT_APP_URL = 'https://app.adapty.io' -/** The render route is fixed; only its host is configurable. */ +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. */ @@ -45,16 +45,6 @@ export function normalizePreviewConfig(raw: unknown): PreviewPayload { return {flow, remoteConfigs} } -/** Render page location: the fixed preview route on the dashboard host, ADAPTY_APP_URL by default. */ -function renderPageUrl(): URL { - const base = process.env[APP_URL_ENV_VAR] ?? DEFAULT_APP_URL - try { - return new URL(PREVIEW_PATH, base) - } catch { - throw new Error(`Invalid ${APP_URL_ENV_VAR}: ${base}`) - } -} - /** * 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 @@ -72,7 +62,7 @@ export interface RenderTarget { } export function buildRenderUrl(target: RenderTarget, payload: PreviewPayload): string { - const url = renderPageUrl() + 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) 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/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 index 28c1a56..39df2db 100644 --- a/test/lib/preview.test.ts +++ b/test/lib/preview.test.ts @@ -3,7 +3,8 @@ import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {gunzipSync} from 'node:zlib' -import {APP_URL_ENV_VAR, buildRenderUrl, normalizePreviewConfig} from '../../src/lib/preview.js' +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 From 858db49579306d95aa14495c33d6e265af5f18a0 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 20:07:20 +0500 Subject: [PATCH 09/12] fix(preview): stop emitting the config twice, drop the Playwright reference script --- CLAUDE.md | 11 +- README.md | 6 +- package.json | 3 +- scripts/preview-with-playwright.mjs | 113 ------------------- skills/adapty-cli/references/cli-commands.md | 23 ++-- src/commands/flows/config/preview.ts | 58 +++++----- src/lib/preview.ts | 8 +- test/commands/flows-preview.test.ts | 35 ++---- 8 files changed, 71 insertions(+), 186 deletions(-) delete mode 100755 scripts/preview-with-playwright.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 3d06df1..62b6990 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ src/ 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; - # preview — local config → render URL, opens on a TTY, prints bare URL when piped) + # 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, @@ -43,8 +44,6 @@ src/ 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 -scripts/ - preview-with-playwright.mjs # reference renderer shipped to consumers; Playwright is NOT a CLI dependency ``` ## Conventions @@ -61,8 +60,10 @@ scripts/ `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` never renders anything itself — Playwright is not a dependency; - `scripts/preview-with-playwright.mjs` is a reference script the caller runs via npx +- `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) +- `--payload-out` and the URL fragment are alternatives — with a payload file the fragment is omitted, since + the render page ignores the hash once it is handed a file (and repeating the config doubles agent output) - 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 a663736..277611e 100644 --- a/README.md +++ b/README.md @@ -259,10 +259,10 @@ and the outcome shows up in `adapty asa automations runs`. ## Paywall Preview `adapty flows config preview ` turns a local flow config into a render URL — it opens in your -browser on a TTY, and prints the bare URL when piped — -**Playwright is not bundled**. See +browser on a TTY, and prints the bare URL when piped. Screenshotting is the caller's job; the CLI only builds +the URL. See [skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for the -handles it prints, the reference `npx` script, and the oversized-config path. +flags and the oversized-config path. ## Environment Variables diff --git a/package.json b/package.json index 6574ea8..ddb578b 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "files": [ "./bin", "./dist", - "./oclif.manifest.json", - "./scripts/preview-with-playwright.mjs" + "./oclif.manifest.json" ], "homepage": "https://adapty.io/docs/developer-cli", "keywords": [ diff --git a/scripts/preview-with-playwright.mjs b/scripts/preview-with-playwright.mjs deleted file mode 100755 index 99621f7..0000000 --- a/scripts/preview-with-playwright.mjs +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -/** - * Reference preview renderer for `adapty preview`. - * - * The CLI does not bundle Playwright: it only prepares a render URL and a payload file. Run this - * script with Playwright supplied at run time: - * - * npx --yes --package=playwright node --url "" --out preview.png - * - * For very large configs, hand over the payload file instead of the URL fragment: - * - * npx --yes --package=playwright node --url "" --config --out preview.png - * - * An agent that already has a browser or computer-use tool can skip this script entirely: open - * `renderUrl` and screenshot the `[data-screen-content]` element. - */ - -import {createRequire} from 'node:module' -import {delimiter, join, resolve} from 'node:path' -import {parseArgs} from 'node:util' - -const CONFIG_INPUT_SELECTOR = '[data-testid="preview-config-input"]' -const SCREEN_CONTENT_SELECTOR = '[data-screen-content]' -const SETTLE_MS = 300 -const TIMEOUT_MS = 30_000 - -/** Playwright is a run-time dependency of the caller: this script, npx, or the current project. */ -async function loadChromium() { - try { - // eslint-disable-next-line import/no-unresolved -- supplied at run time, never a CLI dependency - return (await import('playwright')).chromium - } catch { - // Not resolvable from this file; fall back to the caller's project and to any node_modules - // that npx put on PATH. - const roots = [ - process.cwd(), - ...(process.env.PATH ?? '') - .split(delimiter) - .filter((dir) => dir.endsWith(join('node_modules', '.bin'))) - .map((dir) => join(dir, '..')), - ] - - for (const root of roots) { - try { - return createRequire(join(root, 'noop.js'))('playwright').chromium - } catch { - continue - } - } - - console.error( - 'Could not resolve Playwright. Run this script through `npx --yes --package=playwright node ...`, or `npm i -D playwright` first.', - ) - process.exit(1) - } -} - -const USAGE = 'Usage: node preview-with-playwright.mjs --url [--config ] [--out ]' - -function fail(message) { - console.error(`${message}\n${USAGE}`) - process.exit(2) -} - -let args -try { - ;({values: args} = parseArgs({ - options: { - config: {type: 'string'}, - out: {default: 'preview.png', type: 'string'}, - url: {type: 'string'}, - }, - })) -} catch (error) { - fail(error.message) -} - -if (!args.url) fail('--url is required') - -const chromium = await loadChromium() - -let browser -try { - browser = await chromium.launch({headless: true}) -} catch (error) { - console.error(`Could not launch headless Chromium. Run \`npx playwright install chromium\`.\n${error.message}`) - process.exit(1) -} - -try { - const page = await browser.newPage() - page.setDefaultTimeout(TIMEOUT_MS) - - if (args.config) { - // File-input path: drop the gz: fragment and hand the payload over as a file, which has no - // practical size limit. - const target = new URL(args.url) - target.hash = '' - await page.goto(target.toString(), {waitUntil: 'load'}) - await page.locator(CONFIG_INPUT_SELECTOR).setInputFiles(resolve(args.config)) - } else { - await page.goto(args.url, {waitUntil: 'load'}) - } - - const content = page.locator(SCREEN_CONTENT_SELECTOR).first() - await content.waitFor({state: 'visible'}) - await page.waitForLoadState('networkidle') - await page.waitForTimeout(SETTLE_MS) - await content.screenshot({path: resolve(args.out), type: 'png'}) - console.log(resolve(args.out)) -} finally { - await browser.close() -} diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 3f9cf6b..ceccad4 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -101,7 +101,9 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `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 fragment. **No API call, no `--app`, and no bundled browser** — the CLI does not depend on Playwright. +in its fragment. **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). @@ -119,20 +121,17 @@ 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, so `... | pbcopy` and `$(...)` work. -- **`--json`** — `{render_url, reference_command, payload_path?}`, and never opens a browser. +- **`--json`** — `{render_url, payload_path?}`, and never opens a browser. `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. Any browser/computer-use tool can open the URL and screenshot the -`[data-screen-content]` element. An unknown `device` renders an error message instead of a screen, so pass one -the builder knows. - -`reference_command` (in `--json`) is the exact `npx --yes --package=playwright node -/scripts/preview-with-playwright.mjs --url "" --out "preview.png"` invocation; Chromium -itself needs `npx playwright install chromium` once. `payload_path` appears only with `--payload-out `, -and then `reference_command` includes `--config `, which feeds the page's -`[data-testid="preview-config-input"]` file input instead of the fragment — the escape hatch for a config too -large to sit in a URL. +plain shape to mark it apart from. An unknown `device` renders an error message instead of a screen, so pass +one the builder knows. + +**Oversized configs:** `--payload-out ` writes the normalized payload and **drops the fragment** from +the URL, because the render page ignores the hash once it is handed a file. Feed that file to the page's +`[data-testid="preview-config-input"]` input. The two are alternatives by design: the config is never emitted +twice, so `--json` output stays small whichever mode you use. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/flows/config/preview.ts b/src/commands/flows/config/preview.ts index 0dfcf7f..5fc4f14 100644 --- a/src/commands/flows/config/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -1,7 +1,6 @@ import {Args, Command, Flags} from '@oclif/core' import {readFile} from 'node:fs/promises' import {resolve} from 'node:path' -import {fileURLToPath} from 'node:url' import open from 'open' import {APP_URL_ENV_VAR} from '../../../lib/app-url.js' @@ -17,7 +16,6 @@ import { export interface PreviewResult { payload_path?: string - reference_command: string render_url: string } @@ -25,10 +23,6 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function referenceScriptPath(): string { - return fileURLToPath(new URL('../../../../scripts/preview-with-playwright.mjs', import.meta.url)) -} - export default class FlowsConfigPreview extends Command { static args = { config_file: Args.string({description: 'Path to a local flow config JSON file', required: true}), @@ -48,7 +42,8 @@ static flags = { options: [...ORIENTATIONS], }), 'payload-out': Flags.string({ - description: 'Also write the normalized payload JSON here, for configs too large to sit in a URL', + description: + 'Write the normalized payload JSON here and leave it out of the URL, for configs too large to sit in one', }), screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}), } @@ -65,13 +60,8 @@ static flags = { } let payload: PreviewPayload - let renderUrl: string try { payload = normalizePreviewConfig(raw) - renderUrl = buildRenderUrl( - {device: flags.device, orientation: flags.orientation, screen: flags.screen}, - payload, - ) } catch (error) { this.error(describeError(error), {exit: 2}) } @@ -82,30 +72,46 @@ static flags = { await writePayloadFile(payload, payloadPath) } - const config = payloadPath ? ` --config "${payloadPath}"` : '' + let renderUrl: string + try { + // The fragment and the payload file are alternatives: emitting both would repeat the whole + // config in output an agent has to read. + renderUrl = buildRenderUrl( + {device: flags.device, orientation: flags.orientation, screen: flags.screen}, + payloadPath ? undefined : payload, + ) + } catch (error) { + this.error(describeError(error), {exit: 2}) + } + // Key order is the print order: the URL is the primary handle. /* eslint-disable perfectionist/sort-objects */ const result: PreviewResult = { render_url: renderUrl, - reference_command: `npx --yes --package=playwright node "${referenceScriptPath()}" --url "${renderUrl}"${config} --out "preview.png"`, payload_path: payloadPath, } /* eslint-enable perfectionist/sort-objects */ if (this.jsonEnabled()) return result - // The URL carries the whole gzipped config, so it is far too long to read: open it instead, and - // when stdout is piped emit nothing but the URL so it stays composable. - if (process.stdout.isTTY === true) { - const target = [flags.screen ?? 'first screen', flags.device, flags.orientation].join(', ') - try { - await open(renderUrl) - this.log(`Opened the preview in your browser (${target}).`) - this.log('Run with --json for the render URL, the screenshot command and the payload path.') - } catch { - this.log(renderUrl) - } - } else { + // Piped output stays a bare URL so it composes; on a TTY the URL is far too long to read, so + // open it instead. With a payload file there is nothing to open — the config is not in the URL. + if (process.stdout.isTTY !== true) { + this.log(renderUrl) + return result + } + + if (payloadPath) { + this.log(`Payload written to ${payloadPath}`) + this.log(`Render URL (feed the payload to the page's file input): ${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) } diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 6af1952..070c152 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -61,12 +61,16 @@ export interface RenderTarget { screen?: string } -export function buildRenderUrl(target: RenderTarget, payload: PreviewPayload): string { +/** + * Omit `payload` when the config travels as a file instead: the page ignores the hash once it is + * handed a file, so carrying the fragment as well would only bloat the output. + */ +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)}` + if (payload) url.hash = `config=${encodeConfigFragment(payload)}` return url.toString() } diff --git a/test/commands/flows-preview.test.ts b/test/commands/flows-preview.test.ts index e42e73d..034957d 100644 --- a/test/commands/flows-preview.test.ts +++ b/test/commands/flows-preview.test.ts @@ -1,7 +1,6 @@ import {runCommand} from '@oclif/test' import {expect} from 'chai' -import {execFileSync} from 'node:child_process' -import {existsSync, mkdtempSync, readFileSync} from 'node:fs' +import {mkdtempSync, readFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' import {fileURLToPath} from 'node:url' @@ -9,7 +8,6 @@ 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)) -const SCRIPT_PATH = fileURLToPath(new URL('../../scripts/preview-with-playwright.mjs', import.meta.url)) describe('flows config preview', () => { beforeEach(() => { @@ -20,7 +18,7 @@ describe('flows config preview', () => { delete process.env.ADAPTY_APP_URL }) - it('prints a render URL and a command for the shipped reference script', async () => { + 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') @@ -28,13 +26,9 @@ describe('flows config preview', () => { result.render_url.startsWith('https://app.example/flow-preview?device=iphone-14&orientation=portrait#config='), ).to.equal(true) expect(result.payload_path).to.equal(undefined) - expect(existsSync(SCRIPT_PATH)).to.equal(true) - expect(result.reference_command).to.contain(`node "${SCRIPT_PATH}"`) - expect(result.reference_command).to.contain(`--url "${result.render_url}"`) - expect(result.reference_command).to.not.contain('--config') }) - it('writes the payload and wires --config into the reference command with --payload-out', async () => { + it('writes the payload and drops the fragment with --payload-out', async () => { const outPath = join(mkdtempSync(join(tmpdir(), 'adapty-preview-test-')), 'payload.json') const {result} = await runCommand([ 'flows:config:preview', @@ -45,7 +39,7 @@ describe('flows config preview', () => { ]) expect(result?.payload_path).to.equal(outPath) - expect(result?.reference_command).to.contain(`--config "${outPath}"`) + expect(result?.render_url).to.not.contain('#config=') expect(JSON.parse(readFileSync(outPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) }) @@ -69,23 +63,18 @@ describe('flows config preview', () => { 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 * 2) + }) + 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) }) - - it('the reference script rejects bad arguments with usage', () => { - for (const argv of [[], ['--bogus', 'x']]) { - try { - execFileSync(process.execPath, [SCRIPT_PATH, ...argv], {encoding: 'utf8', stdio: 'pipe'}) - throw new Error(`expected the script to fail for ${JSON.stringify(argv)}`) - } catch (error) { - const {status, stderr} = error as {status?: number; stderr?: string} - expect(status).to.equal(2) - expect(stderr).to.contain('Usage: node preview-with-playwright.mjs --url') - } - } - }) }) From 0e3445947442ecd658d584f597e2c67efb6af0c2 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 21:43:19 +0500 Subject: [PATCH 10/12] refactor(preview): drop --payload-out, the config always rides in the URL --- CLAUDE.md | 5 +-- README.md | 2 +- skills/adapty-cli/references/cli-commands.md | 23 +++++++----- src/commands/flows/config/preview.ts | 38 +++----------------- src/lib/preview.ts | 14 ++------ test/commands/flows-preview.test.ts | 21 +---------- 6 files changed, 25 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62b6990..4f5d9a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,9 @@ src/ 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) -- `--payload-out` and the URL fragment are alternatives — with a payload file the fragment is omitted, since - the render page ignores the hash once it is handed a file (and repeating the config doubles agent output) +- `flows config preview` always carries the config in the URL fragment; there is no file hand-off flag. A + 113K-char URL for a 668KB config is fine for a browser, so callers pipe it (`--url "$(adapty ...)"`) rather + than 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 277611e..4b2f6db 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ and the outcome shows up in `adapty asa automations runs`. browser on a TTY, and prints the bare URL when piped. Screenshotting is the caller's job; the CLI only builds the URL. See [skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for the -flags and the oversized-config path. +flags and how to pass the URL straight to a screenshot tool. ## Environment Variables diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index ceccad4..830edf0 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -101,8 +101,8 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `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 fragment. **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 +in its gzipped fragment. **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 @@ -115,23 +115,28 @@ Render page location is **env-only**: `ADAPTY_APP_URL` (default `https://app.ada 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`), `--payload-out` (off by 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, so `... | pbcopy` and `$(...)` work. -- **`--json`** — `{render_url, payload_path?}`, and never opens a browser. +- **Piped or redirected** — prints the bare URL and nothing else. +- **`--json`** — `{render_url}`, and never opens a browser. `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. -**Oversized configs:** `--payload-out ` writes the normalized payload and **drops the fragment** from -the URL, because the render page ignores the hash once it is handed a file. Feed that file to the page's -`[data-testid="preview-config-input"]` input. The two are alternatives by design: the config is never emitted -twice, so `--json` output stays small whichever mode you use. +**Keep the URL out of your context.** A real 668KB config makes a ~113K-character URL, which a browser handles +fine but you should not read. Pass it straight to whatever captures the screenshot instead of printing it: + +```sh +node capture.mjs --url "$(adapty flows config preview flow.json --screen scr_abc)" --out shot.png +``` + +Command substitution carries it fine (the shell allows ~1MB of arguments, so the ceiling is a config around +6MB — configs compress roughly 6x). There is no file-based hand-off flag: the config always rides in the URL. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/flows/config/preview.ts b/src/commands/flows/config/preview.ts index 5fc4f14..c8575a4 100644 --- a/src/commands/flows/config/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -11,11 +11,9 @@ import { normalizePreviewConfig, ORIENTATIONS, type PreviewPayload, - writePayloadFile, } from '../../../lib/preview.js' export interface PreviewResult { - payload_path?: string render_url: string } @@ -41,10 +39,6 @@ static flags = { description: 'Device orientation to render in', options: [...ORIENTATIONS], }), - 'payload-out': Flags.string({ - description: - 'Write the normalized payload JSON here and leave it out of the URL, for configs too large to sit in one', - }), screen: Flags.string({description: "Screen ID to render (default: the flow's first screen)"}), } @@ -66,47 +60,23 @@ static flags = { this.error(describeError(error), {exit: 2}) } - let payloadPath: string | undefined - if (flags['payload-out']) { - payloadPath = resolve(flags['payload-out']) - await writePayloadFile(payload, payloadPath) - } - let renderUrl: string try { - // The fragment and the payload file are alternatives: emitting both would repeat the whole - // config in output an agent has to read. - renderUrl = buildRenderUrl( - {device: flags.device, orientation: flags.orientation, screen: flags.screen}, - payloadPath ? undefined : payload, - ) + renderUrl = buildRenderUrl({device: flags.device, orientation: flags.orientation, screen: flags.screen}, payload) } catch (error) { this.error(describeError(error), {exit: 2}) } - // Key order is the print order: the URL is the primary handle. - /* eslint-disable perfectionist/sort-objects */ - const result: PreviewResult = { - render_url: renderUrl, - payload_path: payloadPath, - } - /* eslint-enable perfectionist/sort-objects */ - + const result: PreviewResult = {render_url: renderUrl} if (this.jsonEnabled()) return result - // Piped output stays a bare URL so it composes; on a TTY the URL is far too long to read, so - // open it instead. With a payload file there is nothing to open — the config is not in the URL. + // 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 } - if (payloadPath) { - this.log(`Payload written to ${payloadPath}`) - this.log(`Render URL (feed the payload to the page's file input): ${renderUrl}`) - return result - } - const target = [flags.screen ?? 'first screen', flags.device, flags.orientation].join(', ') try { await open(renderUrl) diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 070c152..3c257f8 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -1,4 +1,3 @@ -import {writeFile} from 'node:fs/promises' import {gzipSync} from 'node:zlib' import {appUrl} from './app-url.js' @@ -61,20 +60,11 @@ export interface RenderTarget { screen?: string } -/** - * Omit `payload` when the config travels as a file instead: the page ignores the hash once it is - * handed a file, so carrying the fragment as well would only bloat the output. - */ -export function buildRenderUrl(target: RenderTarget, payload?: PreviewPayload): 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) - if (payload) url.hash = `config=${encodeConfigFragment(payload)}` + url.hash = `config=${encodeConfigFragment(payload)}` return url.toString() } - -/** Writes the payload for the render page's file input, the escape hatch for oversized configs. */ -export async function writePayloadFile(payload: PreviewPayload, outPath: string): Promise { - await writeFile(outPath, JSON.stringify(payload), 'utf8') -} diff --git a/test/commands/flows-preview.test.ts b/test/commands/flows-preview.test.ts index 034957d..8d52bc2 100644 --- a/test/commands/flows-preview.test.ts +++ b/test/commands/flows-preview.test.ts @@ -1,8 +1,5 @@ import {runCommand} from '@oclif/test' import {expect} from 'chai' -import {mkdtempSync, readFileSync} from 'node:fs' -import {tmpdir} from 'node:os' -import {join} from 'node:path' import {fileURLToPath} from 'node:url' import type {PreviewResult} from '../../src/commands/flows/config/preview.js' @@ -25,22 +22,6 @@ describe('flows config preview', () => { expect( result.render_url.startsWith('https://app.example/flow-preview?device=iphone-14&orientation=portrait#config='), ).to.equal(true) - expect(result.payload_path).to.equal(undefined) - }) - - it('writes the payload and drops the fragment with --payload-out', async () => { - const outPath = join(mkdtempSync(join(tmpdir(), 'adapty-preview-test-')), 'payload.json') - const {result} = await runCommand([ - 'flows:config:preview', - FIXTURE_PATH, - '--payload-out', - outPath, - '--json', - ]) - - expect(result?.payload_path).to.equal(outPath) - expect(result?.render_url).to.not.contain('#config=') - expect(JSON.parse(readFileSync(outPath, 'utf8'))).to.have.keys(['flow', 'remoteConfigs']) }) it('puts the requested screen and orientation in the URL', async () => { @@ -68,7 +49,7 @@ describe('flows config preview', () => { 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 * 2) + 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 () => { From 905d1b42cfa1177930b13ec1c0c44e11629eb249 Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 21:51:25 +0500 Subject: [PATCH 11/12] docs(preview): warn that the URL is huge and should be piped, not printed --- README.md | 25 ++++++++++++++++---- skills/adapty-cli/references/cli-commands.md | 19 +++++++++++---- src/commands/flows/config/preview.ts | 5 ++-- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4b2f6db..41b7ac2 100644 --- a/README.md +++ b/README.md @@ -258,11 +258,26 @@ and the outcome shows up in `adapty asa automations runs`. ## Paywall Preview -`adapty flows config preview ` turns a local flow config into a render URL — it opens in your -browser on a TTY, and prints the bare URL when piped. Screenshotting is the caller's job; the CLI only builds -the URL. See -[skills/adapty-cli/references/cli-commands.md](skills/adapty-cli/references/cli-commands.md#preview) for the -flags and how to pass the URL straight to a screenshot tool. +`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. + +> **That URL is huge.** The whole config travels in its gzipped fragment: a 668KB flow yields ~113,000 +> characters. Browsers handle it fine, but 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 diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 830edf0..5869e2c 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -123,20 +123,31 @@ Output depends on where stdout goes, because the URL is far too long to read: - **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 enormous line: ~113,000 characters +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.** A real 668KB config makes a ~113K-character URL, which a browser handles -fine but you should not read. Pass it straight to whatever captures the screenshot instead of printing it: +**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 ``` -Command substitution carries it fine (the shell allows ~1MB of arguments, so the ceiling is a config around -6MB — configs compress roughly 6x). There is no file-based hand-off flag: the config always rides in the URL. +There is no file-based hand-off flag: the config always rides in the URL. ## Apple Search Ads (`asa` topic) diff --git a/src/commands/flows/config/preview.ts b/src/commands/flows/config/preview.ts index c8575a4..e7dcd1c 100644 --- a/src/commands/flows/config/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -25,12 +25,13 @@ 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. Opens the browser on a TTY; when piped, prints the URL alone. The render host comes from $${APP_URL_ENV_VAR}.` +static description = `Build a render URL for a local flow config and open it. Opens the browser on a TTY; when piped or with --json, prints the URL alone. The whole config rides in that URL's fragment, so it is huge — ~113K characters for a 668KB flow. Pipe it into whatever takes the screenshot ("| node capture.mjs"), or pass it as an argument (--url with command substitution) for tools that need a flag. 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', - '<%= config.bin %> flows config preview ./config.json --json', + '# 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'}), From eecb3d8f44ddc204c8881145d2a5c003644114af Mon Sep 17 00:00:00 2001 From: mlnchk Date: Wed, 19 Aug 2026 22:11:14 +0500 Subject: [PATCH 12/12] docs(preview): note the ~32KB practical config limit for fragment previews --- CLAUDE.md | 7 ++++--- README.md | 10 +++++++--- skills/adapty-cli/references/cli-commands.md | 13 ++++++++----- src/commands/flows/config/preview.ts | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f5d9a3..d59d8d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,9 +62,10 @@ src/ 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. A - 113K-char URL for a 668KB config is fine for a browser, so callers pipe it (`--url "$(adapty ...)"`) rather - than read it +- `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 41b7ac2..bea8316 100644 --- a/README.md +++ b/README.md @@ -262,9 +262,13 @@ and the outcome shows up in `adapty asa automations runs`. browser; piped or with `--json` it prints the URL alone. Screenshotting is the caller's job — the CLI only builds the URL. -> **That URL is huge.** The whole config travels in its gzipped fragment: a 668KB flow yields ~113,000 -> characters. Browsers handle it fine, but nobody should read it — **agents especially should never let it into -> their context.** Pipe it into whatever captures the screenshot: +> **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 diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 5869e2c..0e59c14 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -101,7 +101,9 @@ Read-only. Response shape: `{id, title, description}`. Filters are not exposed v | `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. **No API call and no `--app`.** The CLI does not screenshot anything — it owns the +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. @@ -123,10 +125,11 @@ Output depends on where stdout goes, because the URL is far too long to read: - **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 enormous line: ~113,000 characters -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. +⚠️ **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 diff --git a/src/commands/flows/config/preview.ts b/src/commands/flows/config/preview.ts index e7dcd1c..35cf46a 100644 --- a/src/commands/flows/config/preview.ts +++ b/src/commands/flows/config/preview.ts @@ -25,7 +25,7 @@ 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. Opens the browser on a TTY; when piped or with --json, prints the URL alone. The whole config rides in that URL's fragment, so it is huge — ~113K characters for a 668KB flow. Pipe it into whatever takes the screenshot ("| node capture.mjs"), or pass it as an argument (--url with command substitution) for tools that need a flag. Never print or read it: agents burn context for zero information. The render host comes from $${APP_URL_ENV_VAR}.` +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',