From 3eca64812554582287188e018453eeacf2350c44 Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Wed, 11 Feb 2026 12:39:07 +0000 Subject: [PATCH] Create a testing library for ui-extensions (proof of concept) --- .changeset/config.json | 2 +- .github/workflows/ci.yml | 16 + .prettierignore | 2 + .prettierrc | 8 + AGENTS.md | 12 + CONTRIBUTING.md | 22 + .../admin-testing-example/package-lock.json | 57 +++ package.json | 5 +- packages/ui-extensions-tester/AGENTS.md | 16 + packages/ui-extensions-tester/README.md | 411 ++++++++++++++++ packages/ui-extensions-tester/loom.config.ts | 29 ++ packages/ui-extensions-tester/package.json | 60 +++ .../ui-extensions-tester/src/api-version.ts | 23 + .../src/checkout/index.ts | 29 ++ .../src/fetch-polyfills.ts | 222 +++++++++ packages/ui-extensions-tester/src/index.ts | 456 ++++++++++++++++++ .../ui-extensions-tester/src/mocks/signals.ts | 16 + .../src/mocks/target-apis.ts | 25 + .../ui-extensions-tester/src/navigation.ts | 72 +++ packages/ui-extensions-tester/src/targets.ts | 42 ++ .../src/tests/fetch.test.ts | 127 +++++ .../src/tests/fixtures/test-module.ts | 1 + .../src/tests/getExtension.test.ts | 157 ++++++ .../ui-extensions-tester/src/tests/helpers.ts | 140 ++++++ .../src/tests/navigation.test.ts | 130 +++++ packages/ui-extensions-tester/tsconfig.json | 27 ++ .../tsconfig.typecheck.json | 11 + tsconfig.json | 3 + 28 files changed, 2119 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 examples/testing/admin-testing-example/package-lock.json create mode 100644 packages/ui-extensions-tester/AGENTS.md create mode 100644 packages/ui-extensions-tester/README.md create mode 100644 packages/ui-extensions-tester/loom.config.ts create mode 100644 packages/ui-extensions-tester/package.json create mode 100644 packages/ui-extensions-tester/src/api-version.ts create mode 100644 packages/ui-extensions-tester/src/checkout/index.ts create mode 100644 packages/ui-extensions-tester/src/fetch-polyfills.ts create mode 100644 packages/ui-extensions-tester/src/index.ts create mode 100644 packages/ui-extensions-tester/src/mocks/signals.ts create mode 100644 packages/ui-extensions-tester/src/mocks/target-apis.ts create mode 100644 packages/ui-extensions-tester/src/navigation.ts create mode 100644 packages/ui-extensions-tester/src/targets.ts create mode 100644 packages/ui-extensions-tester/src/tests/fetch.test.ts create mode 100644 packages/ui-extensions-tester/src/tests/fixtures/test-module.ts create mode 100644 packages/ui-extensions-tester/src/tests/getExtension.test.ts create mode 100644 packages/ui-extensions-tester/src/tests/helpers.ts create mode 100644 packages/ui-extensions-tester/src/tests/navigation.test.ts create mode 100644 packages/ui-extensions-tester/tsconfig.json create mode 100644 packages/ui-extensions-tester/tsconfig.typecheck.json diff --git a/.changeset/config.json b/.changeset/config.json index 9ef61d7e9a..76c765f5f3 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,7 +6,7 @@ ], "commit": false, "fixed": [], - "linked": [], + "linked": [["@shopify/ui-extensions", "@shopify/ui-extensions-tester"]], "access": "restricted", "baseBranch": "unstable", "updateInternalDependencies": "patch", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df5b6171a..6f54795fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,22 @@ jobs: - name: Lint run: yarn lint + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/workflows/actions/prepare + + - name: Build packages + run: yarn build + + - name: Typecheck ui-extensions-tester (including tests) + run: npx tsc --project packages/ui-extensions-tester/tsconfig.typecheck.json + + - name: Test ui-extensions-tester + run: npx loom test --no-watch packages/ui-extensions-tester/ + test-build: runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index 62f4f66fb8..e8e3c96f28 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,6 @@ node_modules +.shopify +**/.shopify packages/*/build packages/web-pixels-extension/src/schemas/pixel-events.jtd.json packages/ui-extensions/src/surfaces/checkout/**/*.d.ts diff --git a/.prettierrc b/.prettierrc index 03d85fb91e..daf192e09e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -12,6 +12,14 @@ "options": { "printWidth": 50 } + }, + { + "files": [ + "packages/ui-extensions-tester/**/README.md" + ], + "options": { + "printWidth": 50 + } } ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..a20fca7135 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +After every change, you must: + +1. **Run `yarn build`** — rebuild any changed packages. +2. **Run relevant tests** — ensure all affected test suites pass. Read ./CONTRIBUTING.md for how to run tests. +3. **Check for type errors** — run the TypeScript compiler to verify there are no type errors. +4. **Check for lint and formatting errors** — run the linter and formatter to ensure code quality. + +## ui-extensions-tester + +When working on `ui-extensions-tester`, ALWAYS read [packages/ui-extensions-tester/AGENTS.md](packages/ui-extensions-tester/AGENTS.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d20e8a61fc..8075d59fa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,3 +7,25 @@ These packages act as the public API Shopify is exposing for UI Extensions in ou Though we are not accepting contributions, we’d still love to hear from you! If you have ideas for new components or APIs, please [raise an issue on this repo](https://github.com/Shopify/ui-extensions/issues/new/choose). We will also happily accept pull requests for fixing typos in the documentation. If you do raise an issue or PR on this repo, please read [the code of conduct](./CODE_OF_CONDUCT.md), which all contributors must adhere to. Shopifolk looking to contribute fixes and new features to our UI extension APIs can follow the [versions and deploys guide](./documentation/versions-and-deploys.md). + +## Development + +Build all libraries: + +``` +yarn build +``` + +## Development on ui-extensions-tester + +Run all tests: + +``` +yarn test -- packages/ui-extensions-tester/ +``` + +For integration, run all example test suites: + +``` +yarn test:example-suites +``` diff --git a/examples/testing/admin-testing-example/package-lock.json b/examples/testing/admin-testing-example/package-lock.json new file mode 100644 index 0000000000..7443414539 --- /dev/null +++ b/examples/testing/admin-testing-example/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "admin-testing-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "../../../packages/ui-extensions": { + "name": "@shopify/ui-extensions", + "version": "2026.4.0-rc.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ts-morph": "^25.0.1" + }, + "devDependencies": { + "@faker-js/faker": "^8.4.1", + "@preact/signals": "^2.3.x", + "@quilted/react-testing": "^0.6.11", + "@remote-ui/async-subscription": "^2.1.16", + "@shopify/generate-docs": "https://registry.npmjs.org/@shopify/generate-docs/-/generate-docs-0.19.8.tgz", + "preact": "^10.10.x", + "typescript": "^4.9.0" + }, + "peerDependencies": { + "@preact/signals": "*", + "preact": "*" + }, + "peerDependenciesMeta": { + "@preact/signals": { + "optional": true + }, + "preact": { + "optional": true + } + } + }, + "../../../packages/ui-extensions-tester": { + "name": "@shopify/ui-extensions-tester", + "version": "2026.4.0-rc.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@shopify/ui-extensions": "2026.4.0-rc.1" + }, + "devDependencies": { + "typescript": "^4.9.0" + }, + "peerDependencies": { + "preact": "^10.0.0" + }, + "peerDependenciesMeta": { + "preact": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index cc6523efcf..29ba52d182 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "run:ts": "babel-node --extensions .ts,.tsx,.mjs,.js,.json", "run:ts:watch": "nodemon --ext .ts,.tsx,.mjs,.json,.graphql node_modules/.bin/babel-node --extensions .ts,.tsx,.mjs,.js,.json", "type-check": "loom type-check", - "changeset:exit-pre-mode": "test -f .changeset/pre.json && jq -e '.mode == \"pre\"' .changeset/pre.json > /dev/null && yarn changeset pre exit || true" + "changeset:exit-pre-mode": "test -f .changeset/pre.json && jq -e '.mode == \"pre\"' .changeset/pre.json > /dev/null && yarn changeset pre exit || true", + "test": "loom test --no-watch", + "test:watch": "loom test", + "test:example-suites": "for dir in examples/testing/*/; do (cd \"$dir\" && npm install && npm run typecheck && npm test) || exit 1; done" }, "devDependencies": { "@babel/node": "^7.8.7", diff --git a/packages/ui-extensions-tester/AGENTS.md b/packages/ui-extensions-tester/AGENTS.md new file mode 100644 index 0000000000..9e41d86ee1 --- /dev/null +++ b/packages/ui-extensions-tester/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + +Implement every feature with TDD. See `./tests`. + +After every change, run the relevant test suites in `examples/testing/*` to verify that example tests still pass. + +Keep documentation up to date in both the package README (`packages/ui-extensions-tester/README.md`) and each surface-specific README: + +- `packages/ui-extensions-tester/src/admin/README.md` +- `packages/ui-extensions-tester/src/checkout/README.md` +- `packages/ui-extensions-tester/src/customer-account/README.md` +- `packages/ui-extensions-tester/src/point-of-sale/README.md` + +Any change to exports, function signatures, or behavior must be reflected in the relevant docs. + +Code blocks in README files are formatted at print width 50 via the root `.prettierrc` override. Run `npx prettier --write` on any changed README. diff --git a/packages/ui-extensions-tester/README.md b/packages/ui-extensions-tester/README.md new file mode 100644 index 0000000000..f6bd169ea2 --- /dev/null +++ b/packages/ui-extensions-tester/README.md @@ -0,0 +1,411 @@ +# 🧪 @shopify/ui-extensions-tester + +Write unit tests for [Shopify UI extensions](../ui-extensions) to ensure correctness and prevent regressions. + +This testing library provides strongly typed mocks of the extension API--like the `shopify` global--so you can verify the correctness of your extension without needing a real Shopify host. + +## 📋 Requirements + +- **API version `2025-10` or later** in your `shopify.extension.toml` +- **Node.js v20.20.0** or later +- **a mock DOM** such as [`environment: 'jsdom'`](https://vitest.dev/config/environment.html) in [`vitest`](https://vitest.dev/) +- **Test isolation** — extensions rely on the `shopify` global, so each test file must run in its own environment. We recommend [`vitest`](https://vitest.dev/) in [isolate mode](https://vitest.dev/config/isolate.html#isolate) (enabled by default). + +## 📋 Recommendations + +- **TypeScript** — we recommend TypeScript to enforce API compliance against mock objects +- **@testing-library/preact** — if your extension uses [Preact](https://preactjs.com/), we recommend installing [`@testing-library/preact`](https://preactjs.com/guide/v10/preact-testing-library/) for its `fireEvent` and `waitFor` helpers + +## 📦 Installation + +Install the tester as a dev dependency alongside your preferred test runner: + +```bash +npm install --save-dev @shopify/ui-extensions-tester vitest +``` + +If your extension renders with Preact, also install `@testing-library/preact`: + +```bash +npm install --save-dev @testing-library/preact +``` + +## 🏗️ Adding to an existing extension + +If your extension was seeded from an older template using shopify app generate extension, follow these steps. + +
+Expand for details + +The template gives you a project structure like this: + +``` +my-app/ +├── extensions/ +│ └── my-extension/ +│ ├── src/ +│ │ └── Checkout.jsx +│ ├── package.json +│ ├── shopify.d.ts +│ ├── shopify.extension.toml +│ └── tsconfig.json +├── package.json +└── shopify.app.toml +``` + +You need to add a few things: + +### 1. Add dependencies to the root `package.json` + +Your extension's own `package.json` (inside `extensions/my-extension/`) already lists `@shopify/ui-extensions` for Shopify CLI. However, tests and typechecking run from the **root** project directory, so you also need `@shopify/ui-extensions` in the root `package.json` — otherwise TypeScript and vitest won't be able to resolve it. + +```jsonc +{ + "scripts": { + // ...existing scripts + "test": "vitest run", + "typecheck": "tsc --noEmit --project extensions/my-extension/tsconfig.json" + }, + "dependencies": { + "@shopify/ui-extensions": "latest" + }, + "devDependencies": { + "@shopify/ui-extensions-tester": "latest", + "@testing-library/preact": "^3.2.0", + "typescript": "^5.0.0", + "vitest": "^3.0.0" + } +} +``` + +### 2. Create `vitest.config.ts` at the project root + +```ts +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + esbuild: { + jsx: 'automatic', + jsxImportSource: 'preact', + }, + test: { + environment: 'jsdom', + }, +}); +``` + +### 3. Update the extension's `tsconfig.json` + +Add the `tests` directory to the `include` array so your test files are typechecked: + +```jsonc +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "target": "ES2020", + "strict": true, + "checkJs": true, + "allowJs": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "./src", + "./tests", + "./shopify.d.ts" + ] +} +``` + +### 4. Create a `tests/` directory inside your extension + +``` +extensions/ +└── my-extension/ + ├── src/ + │ └── Checkout.jsx + └── tests/ + └── Checkout.test.ts ← your tests go here +``` + +### 5. Add a triple-slash reference in each test file + +Add a [triple-slash directive](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html) for your target so TypeScript knows about Shopify element types (`s-button`, `s-text`, etc.): + +```ts +/// +``` + +The path must match the target you pass to `getExtension()`. + +
+ +## 🏊‍♀️ Getting started + +Every test file follows the same pattern: create an extension harness, call `extension.setUp()` before each test, call `extension.tearDown()` after. + +```ts +import {getExtension} from '@shopify/ui-extensions-tester'; +import {beforeEach, afterEach} from 'vitest'; + +const extension = getExtension( + 'purchase.checkout.block.render', +); + +beforeEach(() => { + extension.setUp(); +}); + +afterEach(() => { + extension.tearDown(); +}); +``` + +`setUp()` creates a complete mock `shopify` global compliant with the target. `tearDown()` clears the DOM and removes the global, among other surface-specific things. + +### 🔍 Rendering and querying elements + +Call `extension.render()` to import and execute your extension's callback, then query the DOM with standard DOM APIs like [`document.body.querySelector()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) and [`document.body.querySelectorAll()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll): + +```ts +test('it handles an empty cart', async () => { + await extension.render(); + + const text = + document.body.querySelector('s-text')!; + expect(text.textContent).toEqual( + 'No items in cart', + ); +}); +``` + +### 🎨 Mocking shopify API values + +The test setup will create a `shopify` global with sensible defaults for the target. You can mutate global property values on `extension.shopify` before rendering. + +```ts +test('it handles an empty order', async () => { + extension.shopify.order.value = undefined; + + await extension.render(); + + const text = + document.body.querySelector('s-text')!; + expect(text.textContent).toEqual( + 'Order not found', + ); +}); +``` + +### 🖱️ Triggering events + +To simulate how a user would interact with your UI extension, you can call [`dispatchEvent()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) or use `fireEvent` from `@testing-library/preact`. When an event triggers an async state change (like a Preact re-render), wrap follow-up assertions in `await waitFor()` to wait for the DOM to settle: + +```ts +import { + fireEvent, + waitFor, +} from '@testing-library/preact'; + +test('it handles date field changes', async () => { + await extension.render(); + + const dateField = document.body.querySelector( + 's-date-field', + )!; + dateField.value = '1990-05-20'; + fireEvent.change(dateField); + + const button = + document.body.querySelector('s-button')!; + fireEvent.click(button); + + await waitFor(() => { + const banner = + document.body.querySelector('s-banner')!; + expect(banner.textContent).toEqual('Saved'); + }); +}); +``` + +### 🔒 Safely mocking mutation functions + +When mocking without strict typing, like with [`vitest` mocks](https://vitest.dev/api/vi.html#mocking-functions-and-objects), you can use a surface-specific `createResult()` helper to return type-safe values: + +```ts +import {createResult} from '@shopify/ui-extensions-tester/checkout'; + +const applyMetafieldChange = vi + .fn() + .mockResolvedValue( + createResult('applyMetafieldChange', { + type: 'error', + message: + 'Could not apply metafield changes', + }), + ); +extension.shopify.applyMetafieldChange = + applyMetafieldChange; +``` + +The first argument is the mutation API name. The second is an optional result override — omit it to get sensible defaults (like `{type: 'success'}`). + +### ⚡ Testing extension code that relies on signals + +Extensions typically subscribe to signal-like objects such as [`shopify.lines.value`](https://shopify.dev/docs/api/checkout-ui-extensions/latest/apis/cart-lines#standardapi-propertydetail-lines). +The mock Shopify API **does not implement working signals** so you'll need to test each state change separately. + +For example, let's say your extension has a button that updates the quantity of an item in the cart. Test the button first: + +```ts +import {createCartLine} from '@shopify/ui-extensions-tester/checkout'; + +test('increments cart line quantity on click', async () => { + const line = createCartLine(); + extension.shopify.lines.value = [line]; + const applyCartLinesChange = vi.spyOn( + extension.shopify, + 'applyCartLinesChange', + ); + + await extension.render(); + + const button = + document.body.querySelector('s-button')!; + fireEvent.click(button); + + // Make sure clicking the button updates the quantity: + await waitFor(() => { + expect( + applyCartLinesChange, + ).toHaveBeenCalledWith({ + type: 'updateCartLine', + id: line.id, + quantity: line.quantity + 1, + }); + }); +}); +``` + +Next, simulate the state change for updated quantities that would have happened on the checkout host: + +```ts +test('it renders the cart line quantity', async () => { + extension.shopify.lines.value = [ + createCartLine({quantity: 2}), + ]; + + await extension.render(); + + const text = + document.body.querySelector('s-text')!; + expect(text.textContent).toContain('2'); +}); +``` + +### 🌐 Working with translations + +The default `shopify.i18n.translate()` mock returns key names as-is to make assertions easier. + +For example, if you render `shopify.i18n.translate('headings.orderNotFound')` in extension code, you can test by looking for the rendered key name: + +```ts +test('it renders a banner when the order does not exist', async () => { + extension.shopify.order.value = undefined; + + await extension.render(); + + const banner = + document.body.querySelector('s-banner')!; + // Check for the translation key, not the actual translation: + expect(banner.getAttribute('heading')).toEqual( + 'headings.orderNotFound', + ); +}); +``` + +## ☯️ Surface-specific guides + +Each surface exports some helpers: + +- ⚙️ [Admin](./src/admin/README.md) +- 🛒 [Checkout](./src/checkout/README.md) +- 🛂 [Customer Account](./src/customer-account/README.md) +- 🛍️ [Point of Sale](./src/point-of-sale/README.md) + +## 📖 API reference + +Exports from `@shopify/ui-extensions-tester`: + +### `getExtension(target, options?)` + +Returns an extension test harness for the given target. It reads `shopify.extension.toml`, finds the module for the given target, and provides helpers to mock the environment and render the extension. It locates `shopify.extension.toml` by walking up from the calling test file's directory, and falls back to searching `extensions/` under the current working directory. + +| Option | Type | Default | Description | +| ----------------- | -------- | ----------------------------- | ---------------------------------------------------------- | +| `configSearchDir` | `string` | calling test file's directory | Directory to start searching for `shopify.extension.toml`. | + +By default `getExtension` walks up from the test file's directory to find `shopify.extension.toml`. + +**Returns** an `Extension` object with the following members: + +#### `extension.setUp()` + +Sets up an extension environment for testing. Creates a mock `shopify` global with some defaults. + +#### `extension.tearDown()` + +Tears down the extension environment. Resets the `shopify` global and clears `document.body`. + +#### `extension.render()` + +Imports and executes the extension module's default export, rendering the extension into `document.body`. Returns a `Promise`. + +#### `extension.shopify` + +A mock `shopify` global, typed correctly for the target under test. You can mutate any property. + +#### `extension.fetch` + +A mock `fetch()` function installed as `globalThis.fetch` during `setUp()` and removed during `tearDown()`. + +Override it with a mock to control responses: + +```ts +extension.fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ok: true})), + ); +``` + +Assigning to `extension.fetch` also updates `globalThis.fetch`, so extension code that calls `fetch()` directly will use the mock. + +#### `extension.navigation` + +A mock [`Navigation`](https://developer.mozilla.org/en-US/docs/Web/API/Navigation) object installed as `globalThis.navigation` during `setUp()` and removed during `tearDown()`. Typed using the `Navigation` interface from `@shopify/ui-extensions/customer-account`. + +Override its properties with mocks to control navigation behaviour: + +```ts +import {createNavigationHistoryEntry} from '@shopify/ui-extensions-tester'; + +extension.navigation.navigate = vi.fn(); +extension.navigation.currentEntry = + createNavigationHistoryEntry({ + url: '/cart', + state: {items: 3}, + }); +``` + +Assigning to `extension.navigation` also updates `globalThis.navigation`, so extension code that calls `navigation.navigate()` directly will use the mock. + +### `createNavigationHistoryEntry(options?)` + +Creates a [`NavigationHistoryEntry`](https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry) for mocking `navigation.currentEntry` or other navigation values. + +- `url` — URL of the history entry (default `''`) +- `key` — key of the history entry (default `''`) +- `state` — developer-defined state retrieved via `getState()` (default `undefined`). Each `getState()` call returns a structured clone, matching real browser behaviour. diff --git a/packages/ui-extensions-tester/loom.config.ts b/packages/ui-extensions-tester/loom.config.ts new file mode 100644 index 0000000000..91226ef9a1 --- /dev/null +++ b/packages/ui-extensions-tester/loom.config.ts @@ -0,0 +1,29 @@ +import {createPackage} from '@shopify/loom'; +import {readFileSync} from 'fs'; +import {resolve} from 'path'; + +import {rollupPlugins} from '@shopify/loom-plugin-build-library'; +import replace from '@rollup/plugin-replace'; +import {defaultProjectPlugin} from '../../config/loom'; + +const packageJSON = JSON.parse( + readFileSync(resolve(__dirname, './package.json')).toString(), +); + +export default createPackage((pkg) => { + pkg.entry({root: './src/index.ts'}); + pkg.entry({name: 'checkout', root: './src/checkout/index.ts'}); + pkg.use( + defaultProjectPlugin(), + rollupPlugins([ + replace({ + values: { + __TESTER_PACKAGE_VERSION__: JSON.stringify( + (packageJSON as any).version, + ), + }, + preventAssignment: true, + }), + ]), + ); +}); diff --git a/packages/ui-extensions-tester/package.json b/packages/ui-extensions-tester/package.json new file mode 100644 index 0000000000..c3dc28578f --- /dev/null +++ b/packages/ui-extensions-tester/package.json @@ -0,0 +1,60 @@ +{ + "name": "@shopify/ui-extensions-tester", + "version": "2026.4.0-rc.1", + "main": "index.js", + "module": "index.mjs", + "esnext": "index.esnext", + "types": "./build/ts/index.d.ts", + "exports": { + ".": { + "types": "./build/ts/index.d.ts", + "esnext": "./index.esnext", + "import": "./index.mjs", + "require": "./index.js" + }, + "./checkout": { + "types": "./build/ts/checkout/index.d.ts", + "esnext": "./checkout.esnext", + "import": "./checkout.mjs", + "require": "./checkout.js" + } + }, + "typesVersions": { + "*": { + "checkout": [ + "./build/ts/checkout/index.d.ts" + ] + } + }, + "license": "MIT", + "dependencies": { + "@shopify/ui-extensions": "2026.4.0-rc.1" + }, + "peerDependencies": { + "preact": "^10.0.0" + }, + "peerDependenciesMeta": { + "preact": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^4.9.0" + }, + "publishConfig": { + "access": "public", + "@shopify:registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/Shopify/ui-extensions.git", + "directory": "packages/ui-extensions-tester" + }, + "files": [ + "build", + "src", + "index.*", + "checkout.*", + "README.md" + ] +} diff --git a/packages/ui-extensions-tester/src/api-version.ts b/packages/ui-extensions-tester/src/api-version.ts new file mode 100644 index 0000000000..794d7bbcd6 --- /dev/null +++ b/packages/ui-extensions-tester/src/api-version.ts @@ -0,0 +1,23 @@ +/** + * The API version supported by this version of the library. + * + * At build time, `__TESTER_PACKAGE_VERSION__` is replaced by rollup with the + * raw NPM version string from package.json (e.g. `"2026.4.0-rc.1"`). + * + * When running from source (e.g. in tests), the placeholder is still + * present, so we fall back to reading package.json via require. + */ + +declare const __TESTER_PACKAGE_VERSION__: string; + +const npmVersion: string = + typeof __TESTER_PACKAGE_VERSION__ === 'undefined' + ? (require('../package.json') as {version: string}).version // eslint-disable-line @typescript-eslint/no-var-requires + : __TESTER_PACKAGE_VERSION__; + +function npmVersionToApiVersion(version: string): string { + const [year, minor] = version.split('.'); + return `${year}-${minor!.padStart(2, '0')}`; +} + +export const API_VERSION: string = npmVersionToApiVersion(npmVersion); diff --git a/packages/ui-extensions-tester/src/checkout/index.ts b/packages/ui-extensions-tester/src/checkout/index.ts new file mode 100644 index 0000000000..c638c93645 --- /dev/null +++ b/packages/ui-extensions-tester/src/checkout/index.ts @@ -0,0 +1,29 @@ +import type { + SubscribableSignalLike, + AppMetafieldEntry, +} from '@shopify/ui-extensions/checkout'; + +import type {AnyExtensionTarget, ApiForTarget} from '../targets'; +import {createSubscribableSignalLike} from '../mocks/signals'; + +/** + * Creates a mock `SubscribableSignalLike` for use + * as the `appMetafields` override in `mockGlobalShopify`. + */ +export function createAppMetafields( + entries: AppMetafieldEntry[], +): SubscribableSignalLike { + return createSubscribableSignalLike(entries); +} + +/** + * Creates a mock API for checkout extension targets. + */ +export function createMockCheckoutTargetApi( + _target: T, +): Partial> { + return { + appMetafields: createSubscribableSignalLike([]), + applyMetafieldChange: async () => ({type: 'success' as const}), + } as unknown as Partial>; +} diff --git a/packages/ui-extensions-tester/src/fetch-polyfills.ts b/packages/ui-extensions-tester/src/fetch-polyfills.ts new file mode 100644 index 0000000000..cbfe12c61d --- /dev/null +++ b/packages/ui-extensions-tester/src/fetch-polyfills.ts @@ -0,0 +1,222 @@ +/** + * Minimal polyfills for the standard `Request` and `Response` classes. + * + * Jest 27's test environments (both `jsdom` and `node`) do not forward the + * fetch-API globals that Node 18+ provides. These polyfills are installed + * on `globalThis` during `setUp()` so that both the tester's own code and + * user test code can use `new Request()` / `new Response()`. + */ + +function notImplemented(name: string): never { + throw new Error( + `${name}() is not implemented by the @shopify/ui-extensions-tester polyfill.`, + ); +} + +class BodyBase { + readonly bodyUsed: boolean = false; + protected _body: string; + + constructor(body?: BodyInit | null) { + this._body = body == null ? '' : String(body); + } + + get body(): Response['body'] { + return null; + } + + async arrayBuffer(): Promise { + const encoder = new TextEncoder(); + return encoder.encode(this._body).buffer as ArrayBuffer; + } + + async blob(): Promise { + return notImplemented('blob'); + } + + // The return type uses `any` to bridge TS 4.x (where Uint8Array is not + // generic) and TS 5.7+ (where Body.bytes() returns Promise>). + async bytes(): Promise { + const encoder = new TextEncoder(); + return encoder.encode(this._body); + } + + async formData(): Promise { + return notImplemented('formData'); + } + + async json(): Promise { + return JSON.parse(this._body); + } + + async text(): Promise { + return this._body; + } +} + +class ResponsePolyfill extends BodyBase implements Response { + static error(): Response { + return new ResponsePolyfill(null, {status: 0, statusText: ''}); + } + + static redirect(url: string | URL, status = 302): Response { + const res = new ResponsePolyfill(null, {status, statusText: ''}); + (res as {url: string}).url = String(url); + (res as {redirected: boolean}).redirected = true; + return res; + } + + static json(data: unknown, init?: ResponseInit): Response { + const body = JSON.stringify(data); + const headers = new Headers(init?.headers); + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + return new ResponsePolyfill(body, {...init, headers}); + } + + readonly headers: Headers; + readonly ok: boolean; + readonly redirected: boolean = false; + readonly status: number; + readonly statusText: string; + readonly type: ResponseType = 'basic'; + readonly url: string = ''; + + constructor(body?: BodyInit | null, init?: ResponseInit) { + super(body); + this.status = init?.status ?? 200; + this.statusText = init?.statusText ?? ''; + this.ok = this.status >= 200 && this.status < 300; + this.headers = new Headers(init?.headers); + } + + clone(): Response { + return new ResponsePolyfill(this._body, { + status: this.status, + statusText: this.statusText, + headers: this.headers, + }); + } +} + +class RequestPolyfill extends BodyBase implements Request { + readonly cache: RequestCache = 'default'; + readonly credentials: RequestCredentials = 'same-origin'; + readonly destination: RequestDestination = '' as RequestDestination; + readonly headers: Headers; + readonly integrity: string = ''; + readonly keepalive: boolean = false; + readonly method: string; + readonly mode: RequestMode = 'cors'; + readonly redirect: RequestRedirect = 'follow'; + readonly referrer: string = 'about:client'; + readonly referrerPolicy: ReferrerPolicy = ''; + readonly signal: AbortSignal = + typeof AbortController === 'undefined' + ? (undefined as any) + : new AbortController().signal; + + readonly url: string; + + constructor(input: RequestInfo | URL, init?: RequestInit) { + super(init?.body); + if (typeof input === 'string') { + this.url = input; + } else if (input instanceof URL) { + this.url = input.href; + } else { + // input is a Request-like object + this.url = input.url; + } + + this.method = (init?.method ?? 'GET').toUpperCase(); + this.headers = new Headers(init?.headers); + + if (init?.cache !== undefined) + (this as {cache: RequestCache}).cache = init.cache; + if (init?.credentials !== undefined) + (this as {credentials: RequestCredentials}).credentials = + init.credentials; + if (init?.integrity !== undefined) + (this as {integrity: string}).integrity = init.integrity; + if (init?.keepalive !== undefined) + (this as {keepalive: boolean}).keepalive = init.keepalive; + if (init?.mode !== undefined) + (this as {mode: RequestMode}).mode = init.mode; + if (init?.redirect !== undefined) + (this as {redirect: RequestRedirect}).redirect = init.redirect; + if (init?.referrer !== undefined) + (this as {referrer: string}).referrer = init.referrer; + if (init?.referrerPolicy !== undefined) + (this as {referrerPolicy: ReferrerPolicy}).referrerPolicy = + init.referrerPolicy; + if (init?.signal !== undefined && init.signal !== null) + (this as {signal: AbortSignal}).signal = init.signal; + } + + clone(): Request { + return new RequestPolyfill(this.url, { + method: this.method, + headers: this.headers, + body: this._body || undefined, + cache: this.cache, + credentials: this.credentials, + integrity: this.integrity, + keepalive: this.keepalive, + mode: this.mode, + redirect: this.redirect, + referrer: this.referrer, + referrerPolicy: this.referrerPolicy, + }); + } +} + +const POLYFILLED = Symbol.for('ui-extensions-tester:fetch-polyfilled'); + +interface PolyfillRecord { + Response?: boolean; + Request?: boolean; +} + +/** + * Installs `Response` and `Request` on `globalThis` when they are missing. + * Call from `setUp()`. + */ +export function installFetchPolyfills(): void { + const record: PolyfillRecord = (globalThis as any)[POLYFILLED] ?? {}; + + if (typeof globalThis.Response === 'undefined') { + (globalThis as any).Response = ResponsePolyfill; + record.Response = true; + } else { + record.Response = false; + } + + if (typeof globalThis.Request === 'undefined') { + (globalThis as any).Request = RequestPolyfill; + record.Request = true; + } else { + record.Request = false; + } + + (globalThis as any)[POLYFILLED] = record; +} + +/** + * Removes only the polyfills that `installFetchPolyfills` added. + * Call from `tearDown()`. + */ +export function uninstallFetchPolyfills(): void { + const record: PolyfillRecord = (globalThis as any)[POLYFILLED] ?? {}; + + if (record.Response) { + delete (globalThis as any).Response; + } + + if (record.Request) { + delete (globalThis as any).Request; + } + + delete (globalThis as any)[POLYFILLED]; +} diff --git a/packages/ui-extensions-tester/src/index.ts b/packages/ui-extensions-tester/src/index.ts new file mode 100644 index 0000000000..96f6117313 --- /dev/null +++ b/packages/ui-extensions-tester/src/index.ts @@ -0,0 +1,456 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type {AnyExtensionTarget, ApiForTarget} from './targets'; +import {isCheckoutTarget} from './targets'; +import {createMockTargetApi} from './mocks/target-apis'; +import {createMockNavigation, type Navigation} from './navigation'; +import {API_VERSION} from './api-version'; +import { + installFetchPolyfills, + uninstallFetchPolyfills, +} from './fetch-polyfills'; + +export type {AnyExtensionTarget, ApiForTarget} from './targets'; +export {createNavigationHistoryEntry} from './navigation'; +export type { + Navigation, + NavigationHistoryEntry, + NavigationNavigateOptions, +} from './navigation'; + +/** + * Makes all properties in the API deeply mutable so tests can + * override any value through the `extension.shopify` proxy: + * + * extension.shopify.cart.current.value = createPosCart({lineItems: [...]}); + * extension.shopify.i18n.translate = (key) => myTranslations[key]; + */ +type Mutable = T extends (...args: any[]) => any + ? T + : T extends object + ? {-readonly [K in keyof T]: Mutable} + : T; + +interface Extension { + /** + * Sets up an extension environment for testing. + * + * For example, it creates a mock `shopify` global with some defaults. + */ + setUp(): void; + + /** + * Tears down the extension environment. + * + * For example, it resets the `shopify` global and clears `document.body`. + */ + tearDown(): void; + + /** + * Imports and executes the extension module's default export, + * rendering the extension into `document.body`. + */ + render(): Promise; + + /** + * A mock `shopify` global, typed correctly for the target under test. + * + * You can mutate any property. Example: + * + * ```ts + * extension.shopify.cart.current.value = { lineItems: [...] }; + * ``` + */ + shopify: Mutable>; + + /** + * A mock `fetch()` function installed as `globalThis.fetch` during + * `setUp()`. + * + * Override it with a mock to control responses: + * + * ```ts + * extension.fetch = vi.fn().mockResolvedValue( + * new Response(JSON.stringify({ ok: true })), + * ); + * ``` + */ + fetch: typeof globalThis.fetch; + + /** + * A mock `navigation` object installed as `globalThis.navigation` + * during `setUp()`. + * + * Override its methods with mocks to control navigation behaviour: + * + * ```ts + * import { createNavigationHistoryEntry } from '@shopify/ui-extensions-tester'; + * extension.navigation.navigate = vi.fn(); + * extension.navigation.currentEntry = + * createNavigationHistoryEntry({ url: '/cart' }); + * ``` + */ + navigation: Navigation; +} + +/** + * Returns an extension test harness for the given target. + * + * It reads `shopify.extension.toml`, finds the module for the given target, + * and provides helpers to mock the environment and render the extension. + * + * It locates `shopify.extension.toml` by walking up from the calling + * test file's directory, and falls back to searching `extensions/` + * under the current working directory. + * + * @param target - The extension target to mock. + * @param options - Optional configuration. + * @param options.configSearchDir - Directory containing (or a parent of) + * `shopify.extension.toml`. Defaults to the calling test file's directory. + */ +export function getExtension( + target: T, + options?: {configSearchDir?: string}, +): Extension { + const configSearchDir = + options?.configSearchDir ?? path.dirname(getCallerFile()); + const tomlPath = findToml(configSearchDir); + const tomlDir = path.dirname(tomlPath); + const tomlContent = fs.readFileSync(tomlPath, 'utf-8'); + validateApiVersion(tomlContent); + const modulePath = parseTargetModule(tomlContent, target); + const resolvedModule = path.resolve(tomlDir, modulePath); + const checkout = isCheckoutTarget(target); + const networkAccess = checkout && parseNetworkAccess(tomlContent); + const apiAccess = checkout && parseApiAccess(tomlContent); + + let fetchImpl: typeof globalThis.fetch; + let previousFetch: typeof globalThis.fetch | undefined; + let navigationImpl = createMockNavigation(); + let previousNavigation: any; + + const ext = { + setUp(): void { + installFetchPolyfills(); + + fetchImpl = + checkout && !networkAccess && !apiAccess + ? async () => { + // Checkout is the only surface that currently enforces + // fetch capabilities. + throw new Error( + 'fetch() is not available. Add network_access = true or ' + + 'api_access = true to [extensions.capabilities] in shopify.extension.toml.', + ); + } + : async () => new Response(); + + previousFetch = (globalThis as any).fetch; + previousNavigation = (globalThis as any).navigation; + (globalThis as any).shopify = deepWritableProxy( + createMockTargetApi(target), + ); + (globalThis as any).fetch = fetchImpl; + (globalThis as any).navigation = navigationImpl; + }, + + get shopify(): any { + if (!(globalThis as any).shopify) { + throw new Error( + 'You must call extension.setUp() before accessing extension.shopify.', + ); + } + return (globalThis as any).shopify; + }, + + get fetch(): typeof globalThis.fetch { + return fetchImpl; + }, + + set fetch(fn: typeof globalThis.fetch) { + fetchImpl = fn; + (globalThis as any).fetch = fn; + }, + + get navigation() { + return navigationImpl; + }, + + set navigation(obj: any) { + navigationImpl = obj; + (globalThis as any).navigation = obj; + }, + + async render(): Promise { + const mod = await import(resolvedModule); + const renderFn = mod.default; + if (typeof renderFn !== 'function') { + throw new Error( + `Expected default export of ${modulePath} to be a function, got ${typeof renderFn}`, + ); + } + await renderFn(); + }, + + tearDown(): void { + // Dynamically import preact to unmount cleanly without requiring + // the test file to depend on preact directly. + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const {render} = require('preact'); + render(null, document.body); + } catch { + // Fallback if preact isn't available + document.body.innerHTML = ''; + } + delete (globalThis as any).shopify; + if (previousFetch === undefined) { + delete (globalThis as any).fetch; + } else { + (globalThis as any).fetch = previousFetch; + } + if (previousNavigation === undefined) { + delete (globalThis as any).navigation; + } else { + (globalThis as any).navigation = previousNavigation; + } + uninstallFetchPolyfills(); + }, + }; + + return ext as Extension; +} + +function validateApiVersion(toml: string): void { + const match = toml.match(/^\s*api_version\s*=\s*"([^"]+)"/m); + const tomlVersion = match?.[1]; + if (tomlVersion !== API_VERSION) { + throw new Error( + `api_version "${tomlVersion ?? '(not found)'}" does not match ` + + `the version supported by @shopify/ui-extensions-tester ("${API_VERSION}"). ` + + `Update api_version in shopify.extension.toml or install the matching tester version.`, + ); + } +} + +function parseCapability(toml: string, key: string): boolean { + const lines = toml.split('\n'); + let inCapabilities = false; + const pattern = new RegExp(`^${key}\\s*=\\s*(.+)`); + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === '[extensions.capabilities]') { + inCapabilities = true; + continue; + } + + // A new section header ends the capabilities block + if (trimmed.startsWith('[')) { + inCapabilities = false; + continue; + } + + if (inCapabilities) { + const match = trimmed.match(pattern); + if (match) { + return match[1]!.trim() === 'true'; + } + } + } + + return false; +} + +function parseNetworkAccess(toml: string): boolean { + return parseCapability(toml, 'network_access'); +} + +function parseApiAccess(toml: string): boolean { + return parseCapability(toml, 'api_access'); +} + +// Resolve the path of this module from a stack trace at load time. +// This works in both CJS (__filename available) and ESM (where __filename +// is not defined) without relying on import.meta.url. +function getCurrentFilePath(): string { + const originalPrepare = Error.prepareStackTrace; + try { + let filePath = ''; + Error.prepareStackTrace = (_err, stack) => { + filePath = stack[0]?.getFileName() ?? ''; + }; + // eslint-disable-next-line @babel/no-unused-expressions + new Error().stack; + return filePath; + } finally { + Error.prepareStackTrace = originalPrepare; + } +} + +// Resolved at module load time so the stack frame reliably points to this +// file, even if a bundler inlines or merges modules later. +const thisPackageFilePath = getCurrentFilePath(); + +function getCallerFile(): string { + const originalPrepare = Error.prepareStackTrace; + try { + const err = new Error(); + let callerFile = ''; + + Error.prepareStackTrace = (_err, stack) => { + // stack[0] is getCallerFile, stack[1] is getExtension, stack[2] is the caller + for (let i = 2; i < stack.length; i++) { + const fileName = stack[i]!.getFileName(); + if (fileName && fileName !== thisPackageFilePath) { + callerFile = fileName; + break; + } + } + }; + + // Trigger stack trace preparation + // eslint-disable-next-line @babel/no-unused-expressions + err.stack; + + if (!callerFile) { + throw new Error('Could not determine caller file from stack trace'); + } + + // Handle file:// URLs (ESM) + if (callerFile.startsWith('file://')) { + return new URL(callerFile).pathname; + } + + return callerFile; + } finally { + Error.prepareStackTrace = originalPrepare; + } +} + +function findToml(startDir: string): string { + // First, walk up from the start directory looking for shopify.extension.toml + let dir = startDir; + const root = path.parse(dir).root; + + while (dir !== root) { + const candidate = path.join(dir, 'shopify.extension.toml'); + if (fs.existsSync(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + + // If not found walking up, search from cwd (project root) in extensions/*/ + const cwd = process.cwd(); + const extensionsDir = path.join(cwd, 'extensions'); + if (fs.existsSync(extensionsDir)) { + const entries = fs.readdirSync(extensionsDir, {withFileTypes: true}); + for (const entry of entries) { + if (entry.isDirectory()) { + const candidate = path.join( + extensionsDir, + entry.name, + 'shopify.extension.toml', + ); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } + + throw new Error( + `Could not find shopify.extension.toml in any parent directory of ${startDir} or in extensions/ under ${cwd}`, + ); +} + +function parseTargetModule(toml: string, target: string): string { + // Simple TOML parser for [[extensions.targeting]] sections. + // Looks for blocks with matching target and extracts the module. + const lines = toml.split('\n'); + let inTargeting = false; + let currentTarget = ''; + let currentModule = ''; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === '[[extensions.targeting]]') { + // Save previous block if it matched + if (inTargeting && currentTarget === target && currentModule) { + return currentModule; + } + inTargeting = true; + currentTarget = ''; + currentModule = ''; + continue; + } + + // A new section header ends the current targeting block + if (trimmed.startsWith('[') && trimmed !== '[[extensions.targeting]]') { + if (inTargeting && currentTarget === target && currentModule) { + return currentModule; + } + inTargeting = false; + continue; + } + + if (inTargeting) { + const targetMatch = trimmed.match(/^target\s*=\s*"(.+)"/); + if (targetMatch) { + currentTarget = targetMatch[1]!; + } + const moduleMatch = trimmed.match(/^module\s*=\s*"(.+)"/); + if (moduleMatch) { + currentModule = moduleMatch[1]!; + } + } + } + + // Check last block + if (inTargeting && currentTarget === target && currentModule) { + return currentModule; + } + + throw new Error( + `Could not find target "${target}" in shopify.extension.toml`, + ); +} + +/** + * Creates a recursive proxy that makes all properties writable. + * For nested objects, returns another proxy so that deep assignments + * like `extension.shopify.cart.current.value = ...` work. + */ +function deepWritableProxy(obj: any): any { + if (obj == null || typeof obj !== 'object') return obj; + + function assertExists(target: any, prop: string | symbol) { + if (typeof prop !== 'symbol' && prop !== 'toJSON' && !(prop in target)) { + throw new Error( + `Property "${String( + prop, + )}" does not exist on the shopify API for this target.`, + ); + } + } + + return new Proxy(obj, { + get(target: any, prop: string | symbol) { + assertExists(target, prop); + const val = target[prop]; + if (val != null && typeof val === 'object' && !Array.isArray(val)) { + return deepWritableProxy(val); + } + return val; + }, + set(target: any, prop: string | symbol, newValue: any) { + assertExists(target, prop); + target[prop] = newValue; + return true; + }, + }); +} diff --git a/packages/ui-extensions-tester/src/mocks/signals.ts b/packages/ui-extensions-tester/src/mocks/signals.ts new file mode 100644 index 0000000000..5da5fd6488 --- /dev/null +++ b/packages/ui-extensions-tester/src/mocks/signals.ts @@ -0,0 +1,16 @@ +import type {SubscribableSignalLike} from '@shopify/ui-extensions/checkout'; + +/** + * Creates a mock `SubscribableSignalLike` that wraps a static value. + * The `subscribe` callback is never invoked since test values are static. + */ +export function createSubscribableSignalLike( + value: T, +): SubscribableSignalLike { + return { + value, + current: value, + subscribe: () => () => {}, + destroy: async () => {}, + }; +} diff --git a/packages/ui-extensions-tester/src/mocks/target-apis.ts b/packages/ui-extensions-tester/src/mocks/target-apis.ts new file mode 100644 index 0000000000..0f1041af23 --- /dev/null +++ b/packages/ui-extensions-tester/src/mocks/target-apis.ts @@ -0,0 +1,25 @@ +import type {AnyExtensionTarget, ApiForTarget} from '../targets'; +import {isCheckoutTarget} from '../targets'; +import {createMockCheckoutTargetApi} from '../checkout'; + +/** + * Returns the default mock API values for a given target, or an empty + * object if no mock has been implemented yet. + */ +export function createMockTargetApi( + target: T, +): Partial> { + let api: object; + if (isCheckoutTarget(target)) { + api = createMockCheckoutTargetApi(target); + } else { + throw new Error( + `Unsupported target: "${target}". No mock factory is available for this target.`, + ); + } + + // `@shopify/ui-extensions/preact` calls `shopify.setSignals()` as a + // side-effect when imported. Include a no-op so the proxy doesn't + // reject the access. + return Object.assign(api, {setSignals() {}}); +} diff --git a/packages/ui-extensions-tester/src/navigation.ts b/packages/ui-extensions-tester/src/navigation.ts new file mode 100644 index 0000000000..769c9a3249 --- /dev/null +++ b/packages/ui-extensions-tester/src/navigation.ts @@ -0,0 +1,72 @@ +import type { + Navigation, + NavigationHistoryEntry, +} from '@shopify/ui-extensions/customer-account'; + +export type { + Navigation, + NavigationHistoryEntry, + NavigationNavigateOptions, +} from '@shopify/ui-extensions/customer-account'; + +/** + * Options for {@link createNavigationHistoryEntry}. + */ +export interface CreateNavigationResultOptions { + /** The URL of the navigation history entry. Defaults to `''`. */ + url?: string; + /** The `key` of the navigation history entry. Defaults to `''`. */ + key?: string; + /** + * Developer-defined state stored in the entry, retrieved via + * `getState()`. Each call to `getState()` returns a structured + * clone, matching the real browser behaviour. + */ + state?: unknown; +} + +/** + * Creates a `NavigationHistoryEntry` suitable for mocking navigation + * values such as `navigation.currentEntry`. + * + * ```ts + * extension.navigation.currentEntry = + * createNavigationHistoryEntry({ + * url: '/cart', + * state: { items: 3 }, + * }); + * ``` + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry + */ +export function createNavigationHistoryEntry( + options?: CreateNavigationResultOptions, +): NavigationHistoryEntry { + const {url = '', key = '', state} = options ?? {}; + return { + url, + key, + getState() { + return state === undefined + ? undefined + : JSON.parse(JSON.stringify(state)); + }, + }; +} + +/** + * Creates a mock `Navigation` object matching the interface from + * `@shopify/ui-extensions/customer-account`. The default `navigate()` + * is a no-op; `currentEntry` starts as a blank entry. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigation + */ +export function createMockNavigation(): Navigation { + return { + navigate: () => {}, + currentEntry: createNavigationHistoryEntry(), + updateCurrentEntry: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }; +} diff --git a/packages/ui-extensions-tester/src/targets.ts b/packages/ui-extensions-tester/src/targets.ts new file mode 100644 index 0000000000..e91c434a6c --- /dev/null +++ b/packages/ui-extensions-tester/src/targets.ts @@ -0,0 +1,42 @@ +import type { + ExtensionTargets as CheckoutExtensionTargets, + ExtensionTarget as CheckoutExtensionTarget, +} from '@shopify/ui-extensions/checkout'; +import type {ExtensionTargets as AdminExtensionTargets} from '@shopify/ui-extensions/admin'; +import type {ExtensionTargets as CustomerAccountExtensionTargets} from '@shopify/ui-extensions/customer-account'; +import type {ExtensionTargets as PointOfSaleExtensionTargets} from '@shopify/ui-extensions/point-of-sale'; + +/** + * Combined extension targets from all surfaces. + */ +export interface AllExtensionTargets + extends CheckoutExtensionTargets, + AdminExtensionTargets, + CustomerAccountExtensionTargets, + PointOfSaleExtensionTargets {} + +/** + * Any valid extension target name. + */ +export type AnyExtensionTarget = keyof AllExtensionTargets; + +/** + * Extracts the API type for a given extension target. + */ +export type ApiForTarget = + AllExtensionTargets[T] extends {api: infer A} + ? A + : AllExtensionTargets[T] extends (data: infer D) => unknown + ? D + : never; + +export function isCheckoutTarget( + target: string, +): target is CheckoutExtensionTarget { + return ( + target.startsWith('purchase.checkout') || + target.startsWith('purchase.thank-you') || + target.startsWith('purchase.cart-line-item') || + target.startsWith('Checkout::') + ); +} diff --git a/packages/ui-extensions-tester/src/tests/fetch.test.ts b/packages/ui-extensions-tester/src/tests/fetch.test.ts new file mode 100644 index 0000000000..1fb9e0eede --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/fetch.test.ts @@ -0,0 +1,127 @@ +import {getExtension} from '../index'; + +import {createTestSandbox, type TestSandbox} from './helpers'; + +describe('extension.fetch', () => { + let sandbox: TestSandbox; + + beforeEach(() => { + sandbox = createTestSandbox(); + }); + + afterEach(() => { + sandbox.destroy(); + }); + + it('is available on the extension harness for checkout targets', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(extension.fetch).toBeInstanceOf(Function); + expect(globalThis.fetch).toBe(extension.fetch); + }); + + it('restores the previous globalThis.fetch during tearDown', () => { + const previousFetch = async () => new Response(); + (globalThis as any).fetch = previousFetch; + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(globalThis.fetch).not.toBe(previousFetch); + extension.tearDown(); + expect(globalThis.fetch).toBe(previousFetch); + }); + + it('removes globalThis.fetch during tearDown when there was no previous fetch', () => { + delete (globalThis as any).fetch; + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + extension.tearDown(); + expect(globalThis.fetch).toBeUndefined(); + }); + + it('throws when called if network_access is not enabled in the toml', async () => { + sandbox.placeToml(); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + await expect(extension.fetch('https://example.com')).rejects.toThrow( + /network_access.*api_access/, + ); + }); + + it('does not throw when called if api_access is enabled', async () => { + sandbox.placeToml({apiAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + const response = await extension.fetch('https://example.com'); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + + it('does not throw when called if network_access is enabled', async () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + const response = await extension.fetch('https://example.com'); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + + it('syncs globalThis.fetch when extension.fetch is reassigned', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + const customFetch = async () => new Response(null, {status: 418}); + extension.fetch = customFetch as typeof globalThis.fetch; + expect(globalThis.fetch).toBe(customFetch); + }); + + it('does not remove a pre-existing Response global during tearDown', () => { + const originalResponse = (globalThis as any).Response; + const FakeResponse = function FakeResponse() {} as any; + (globalThis as any).Response = FakeResponse; + try { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + extension.tearDown(); + expect(globalThis.Response).toBe(FakeResponse); + } finally { + (globalThis as any).Response = originalResponse; + } + }); + + it('does not remove a pre-existing Request global during tearDown', () => { + const originalRequest = (globalThis as any).Request; + const FakeRequest = function FakeRequest() {} as any; + (globalThis as any).Request = FakeRequest; + try { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + extension.tearDown(); + expect(globalThis.Request).toBe(FakeRequest); + } finally { + (globalThis as any).Request = originalRequest; + } + }); +}); diff --git a/packages/ui-extensions-tester/src/tests/fixtures/test-module.ts b/packages/ui-extensions-tester/src/tests/fixtures/test-module.ts new file mode 100644 index 0000000000..feab160aa0 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/fixtures/test-module.ts @@ -0,0 +1 @@ +export default function render() {} diff --git a/packages/ui-extensions-tester/src/tests/getExtension.test.ts b/packages/ui-extensions-tester/src/tests/getExtension.test.ts new file mode 100644 index 0000000000..c8e6f053b0 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/getExtension.test.ts @@ -0,0 +1,157 @@ +import {getExtension} from '../index'; + +import {API_VERSION} from '../api-version'; +import {createTestSandbox, type TestSandbox} from './helpers'; + +describe('getExtension', () => { + let sandbox: TestSandbox; + + beforeEach(() => { + sandbox = createTestSandbox(); + }); + + afterEach(() => { + sandbox.destroy(); + }); + + it('finds shopify.extension.toml in the given directory', () => { + sandbox.placeToml({inDir: 'root'}); + expect(() => + getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }), + ).not.toThrow(); + }); + + it('finds shopify.extension.toml in a parent directory', () => { + sandbox.placeToml({inDir: 'root'}); + expect(() => + getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tomlDirs.subDir, + }), + ).not.toThrow(); + }); + + it('finds shopify.extension.toml two parents up', () => { + sandbox.placeToml({inDir: 'root'}); + expect(() => + getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tomlDirs.nestedSubDir, + }), + ).not.toThrow(); + }); + + it('throws if shopify.extension.toml cannot be found', () => { + expect(() => + getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }), + ).toThrow(/Could not find shopify\.extension\.toml/); + }); + + it('imports and executes the module specified by the toml', async () => { + sandbox.placeToml(); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + expect(await extension.render()).toBeUndefined(); + }); + + it('fails if the module cannot be imported', async () => { + sandbox.placeToml({module: './fixtures/nonexistent-module.js'}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + await expect(extension.render()).rejects.toThrow(/Cannot find module/); + }); + + it('throws if the TOML api_version does not match the library version', () => { + sandbox.placeToml({apiVersion: '2020-01'}); + expect(() => + getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }), + ).toThrow( + new RegExp(`api_version "2020-01" does not match.*"${API_VERSION}"`), + ); + }); + + describe('extension.shopify proxy', () => { + beforeEach(() => { + sandbox.placeToml(); + }); + + it('throws when accessing shopify before calling setUp', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + expect(() => { + // eslint-disable-next-line @babel/no-unused-expressions + extension.shopify; + }).toThrow(/setUp\(\)/); + }); + + it('throws when getting a property that does not exist', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + + expect(() => { + // @ts-expect-error - nonExistentProp does not exist on the API + // eslint-disable-next-line @babel/no-unused-expressions + extension.shopify.nonExistentProp; + }).toThrow(/nonExistentProp.*does not exist/); + }); + + it('throws when setting a property that does not exist', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + + expect(() => { + // @ts-expect-error - nonExistentProp does not exist on the API + extension.shopify.nonExistentProp = 'value'; + }).toThrow(/nonExistentProp.*does not exist/); + }); + + it('allows getting a property that exists', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + + expect(() => { + // eslint-disable-next-line @babel/no-unused-expressions + extension.shopify.appMetafields; + }).not.toThrow(); + }); + + it('allows setting a property that exists', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + + expect(() => { + extension.shopify.appMetafields = { + current: {value: [], subscribe: () => () => {}}, + } as any; + }).not.toThrow(); + }); + + it('throws on nested non-existent property access', () => { + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + + expect(() => { + // @ts-expect-error - bogus does not exist + // eslint-disable-next-line @babel/no-unused-expressions + extension.shopify.appMetafields.bogus; + }).toThrow(/bogus.*does not exist/); + }); + }); +}); diff --git a/packages/ui-extensions-tester/src/tests/helpers.ts b/packages/ui-extensions-tester/src/tests/helpers.ts new file mode 100644 index 0000000000..a7c2b805f8 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/helpers.ts @@ -0,0 +1,140 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import {API_VERSION} from '../api-version'; + +export const TOML_NAME = 'shopify.extension.toml'; + +/** + * Creates an isolated temp directory for a single test file. + * Each test file gets its own directory so tests can run in parallel + * without conflicting on the shared shopify.extension.toml file. + * + * The returned object provides `placeToml()` and `tearDown()` scoped + * to the temp directory. + */ +export function createTestSandbox() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ext-tester-')); + + // Copy the fixtures directory into the temp dir so module paths resolve + const srcFixtures = path.resolve(__dirname, 'fixtures'); + const destFixtures = path.join(tempDir, 'fixtures'); + fs.mkdirSync(destFixtures, {recursive: true}); + for (const entry of fs.readdirSync(srcFixtures)) { + fs.copyFileSync( + path.join(srcFixtures, entry), + path.join(destFixtures, entry), + ); + } + + /** + * Known directories where `placeToml()` may write a toml file, + * scoped to this sandbox's temp directory tree. + */ + const tomlDirs = { + root: tempDir, + subDir: path.join(tempDir, 'sub'), + nestedSubDir: path.join(tempDir, 'sub', 'deep'), + } as const; + + // Pre-create the subdirectories + fs.mkdirSync(tomlDirs.nestedSubDir, {recursive: true}); + + type TomlDirName = keyof typeof tomlDirs; + + function removeAllTomls() { + for (const dir of Object.values(tomlDirs)) { + try { + fs.unlinkSync(path.join(dir, TOML_NAME)); + } catch { + // ignore if file doesn't exist + } + } + } + + function placeToml( + params: MakeTomlOptions & {inDir?: TomlDirName} = {}, + ): void { + const {inDir = 'root', ...options} = params; + // Default module path is relative to the toml location → fixtures/ + if (!options.module) { + const tomlDir = tomlDirs[inDir]; + const relToFixtures = path.relative(tomlDir, destFixtures); + options.module = `./${relToFixtures}/test-module.ts`; + } + fs.writeFileSync(path.join(tomlDirs[inDir], TOML_NAME), makeToml(options)); + } + + function tearDown() { + removeAllTomls(); + delete (globalThis as any).shopify; + delete (globalThis as any).fetch; + } + + function destroy() { + tearDown(); + rmdirRecursive(tempDir); + } + + return {tempDir, tomlDirs, placeToml, tearDown, destroy}; +} + +export type TestSandbox = ReturnType; + +function rmdirRecursive(dir: string) { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + rmdirRecursive(full); + } else { + fs.unlinkSync(full); + } + } + fs.rmdirSync(dir); +} + +export interface MakeTomlOptions { + /** Module path relative to the toml location. */ + module?: string; + /** Extension target. Defaults to `"purchase.checkout.block.render"`. */ + target?: string; + /** Whether to include `network_access = true` under `[extensions.capabilities]`. */ + networkAccess?: boolean; + /** Whether to include `api_access = true` under `[extensions.capabilities]`. */ + apiAccess?: boolean; + /** Override the `api_version` in the TOML. Defaults to the library's API version. */ + apiVersion?: string; +} + +export function makeToml(options: MakeTomlOptions = {}): string { + const { + module: modulePath = './fixtures/test-module.ts', + target = 'purchase.checkout.block.render', + networkAccess, + apiAccess, + apiVersion = API_VERSION, + } = options; + + const capabilityLines: string[] = []; + if (networkAccess) capabilityLines.push('network_access = true'); + if (apiAccess) capabilityLines.push('api_access = true'); + + const capabilities = + capabilityLines.length > 0 + ? `\n[extensions.capabilities]\n${capabilityLines.join('\n')}\n` + : ''; + + return `\ +api_version = "${apiVersion}" + +[[extensions]] +name = "test-extension" +handle = "test-extension" +type = "ui_extension" + +[[extensions.targeting]] +module = "${modulePath}" +target = "${target}" +${capabilities}`; +} diff --git a/packages/ui-extensions-tester/src/tests/navigation.test.ts b/packages/ui-extensions-tester/src/tests/navigation.test.ts new file mode 100644 index 0000000000..94db57450b --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/navigation.test.ts @@ -0,0 +1,130 @@ +import {getExtension, createNavigationHistoryEntry} from '../index'; + +import {createTestSandbox, type TestSandbox} from './helpers'; + +describe('extension.navigation', () => { + let sandbox: TestSandbox; + + beforeEach(() => { + sandbox = createTestSandbox(); + }); + + afterEach(() => { + sandbox.destroy(); + }); + + it('is available on the extension harness after setUp', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(extension.navigation).toBeInstanceOf(Object); + expect((globalThis as any).navigation).toBe(extension.navigation); + }); + + it('restores the previous globalThis.navigation during tearDown', () => { + const previousNavigation = {navigate: () => {}}; + (globalThis as any).navigation = previousNavigation; + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect((globalThis as any).navigation).not.toBe(previousNavigation); + extension.tearDown(); + expect((globalThis as any).navigation).toBe(previousNavigation); + }); + + it('removes globalThis.navigation during tearDown when there was no previous navigation', () => { + delete (globalThis as any).navigation; + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + extension.tearDown(); + expect((globalThis as any).navigation).toBeUndefined(); + }); + + it('provides a default navigate() that is a no-op', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(() => extension.navigation.navigate('/some-url')).not.toThrow(); + }); + + it('provides a default currentEntry', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(extension.navigation.currentEntry).toBeDefined(); + expect(extension.navigation.currentEntry.url).toBe(''); + expect(extension.navigation.currentEntry.getState()).toBeUndefined(); + }); + + it('provides addEventListener and removeEventListener', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + const cb = () => {}; + expect(() => + extension.navigation.addEventListener('currententrychange', cb), + ).not.toThrow(); + expect(() => + extension.navigation.removeEventListener('currententrychange', cb), + ).not.toThrow(); + }); + + it('provides updateCurrentEntry', () => { + sandbox.placeToml({networkAccess: true}); + const extension = getExtension('purchase.checkout.block.render', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + expect(() => + extension.navigation.updateCurrentEntry({state: {foo: 1}}), + ).not.toThrow(); + }); +}); + +describe('createNavigationHistoryEntry', () => { + it('creates an entry with defaults', () => { + const entry = createNavigationHistoryEntry(); + expect(entry.url).toBe(''); + expect(entry.key).toBe(''); + expect(entry.getState()).toBeUndefined(); + }); + + it('creates an entry with a custom URL', () => { + const entry = createNavigationHistoryEntry({ + url: 'https://example.com/page', + }); + expect(entry.url).toBe('https://example.com/page'); + }); + + it('creates an entry with a custom key', () => { + const entry = createNavigationHistoryEntry({key: 'my-key'}); + expect(entry.key).toBe('my-key'); + }); + + it('creates an entry with developer-defined state', () => { + const state = {items: ['hat', 'scarf']}; + const entry = createNavigationHistoryEntry({state}); + expect(entry.getState()).toStrictEqual(state); + }); + + it('returns a clone from getState(), not the original reference', () => { + const state = {count: 1}; + const entry = createNavigationHistoryEntry({state}); + const returned = entry.getState(); + expect(returned).toStrictEqual(state); + expect(returned).not.toBe(state); + }); +}); diff --git a/packages/ui-extensions-tester/tsconfig.json b/packages/ui-extensions-tester/tsconfig.json new file mode 100644 index 0000000000..80d464610d --- /dev/null +++ b/packages/ui-extensions-tester/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../config/typescript/tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build/ts", + "stripInternal": true, + "lib": ["es2018", "esnext", "dom"], + "baseUrl": ".", + "paths": { + "@shopify/ui-extensions/checkout": [ + "../ui-extensions/build/ts/surfaces/checkout" + ], + "@shopify/ui-extensions/admin": [ + "../ui-extensions/build/ts/surfaces/admin" + ], + "@shopify/ui-extensions/customer-account": [ + "../ui-extensions/build/ts/surfaces/customer-account" + ], + "@shopify/ui-extensions/point-of-sale": [ + "../ui-extensions/build/ts/surfaces/point-of-sale" + ] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/tests/**"], + "references": [{"path": "../ui-extensions"}] +} diff --git a/packages/ui-extensions-tester/tsconfig.typecheck.json b/packages/ui-extensions-tester/tsconfig.typecheck.json new file mode 100644 index 0000000000..b954021f9d --- /dev/null +++ b/packages/ui-extensions-tester/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build/ts-test", + "noEmit": true, + "emitDeclarationOnly": false + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index fb70343a71..47f554d073 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ }, { "path": "./packages/ui-extensions" + }, + { + "path": "./packages/ui-extensions-tester" } ] }