local-storefront 0.2.0 → 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1384 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1412 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1612 -0
- package/docs/cli.md +917 -0
- package/docs/compatibility.md +502 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +1161 -0
- package/docs/data-reference.md +1272 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +267 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +251 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +18 -9
package/docs/testing.md
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# End-to-end testing against local-storefront
|
|
2
|
+
|
|
3
|
+
`npx local-storefront serve` gives a theme a storefront that needs no network, has no rate limit
|
|
4
|
+
and renders a page in a few milliseconds. This guide shows how to drive it from a test runner.
|
|
5
|
+
The examples use Playwright; nothing is specific to it.
|
|
6
|
+
|
|
7
|
+
## Starting the server
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx local-storefront serve --theme path/to/theme --port 9393 --static --quiet --strict
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Flag | Why in tests |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `--static` | Reads the theme and the data once. Fastest, and nothing changes under a running test. |
|
|
16
|
+
| `--strict` | Refuses to start when the store data has errors, instead of serving what is valid. |
|
|
17
|
+
| `--quiet` | No request log. |
|
|
18
|
+
| `--port 9393` | The port the tests expect, or an error: without `--port`, a taken `9292` makes the server move to the next free port. |
|
|
19
|
+
| `--port 0` | Picks a free port; the chosen address is printed as `ready: http://127.0.0.1:<port>/`. |
|
|
20
|
+
| `--host 0.0.0.0` | Listens on every interface, e.g. inside a container. |
|
|
21
|
+
| `--throttle <rules>` | Answers requests late: a preset (`simulated`, `slow`) or rules by kind. See [Simulating a slow network](#simulating-a-slow-network). |
|
|
22
|
+
|
|
23
|
+
Every option: [CLI reference](cli.md#serve).
|
|
24
|
+
|
|
25
|
+
Leave `--live-reload` off: it injects a script into the pages.
|
|
26
|
+
|
|
27
|
+
Responses are compressed, as a storefront's are, when the client accepts it. It costs about
|
|
28
|
+
half a millisecond per page. `--no-compression` turns it off.
|
|
29
|
+
|
|
30
|
+
The stylesheets and the scripts of the theme are minified, as a storefront's are, and come
|
|
31
|
+
with a source map: a stack trace or a coverage report still names the lines of your files.
|
|
32
|
+
`--no-minify` serves them as they are written.
|
|
33
|
+
|
|
34
|
+
With Playwright, let the runner start and stop the server:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// playwright.config.ts
|
|
38
|
+
import { defineConfig } from '@playwright/test';
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
fullyParallel: true,
|
|
42
|
+
use: { baseURL: 'http://127.0.0.1:9393' },
|
|
43
|
+
webServer: {
|
|
44
|
+
command: 'npx local-storefront serve --port 9393 --static --quiet --strict',
|
|
45
|
+
url: 'http://127.0.0.1:9393/__lsf/status',
|
|
46
|
+
reuseExistingServer: !process.env.CI,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The tests get a port of their own, outside the `9292` to `9391` that `serve` takes without
|
|
52
|
+
`--port`. A local run then never reuses your dev server, with its live reload and its data that
|
|
53
|
+
changes.
|
|
54
|
+
|
|
55
|
+
### An embedded app
|
|
56
|
+
|
|
57
|
+
For the backend of an app (the React Router template), start lsf and the backend, and open the
|
|
58
|
+
app in lsf's admin ([preview the embedded app](admin.md#preview-the-embedded-app)). The config
|
|
59
|
+
is the [quickstart's](quickstart-app.md#7-a-playwright-test), the same in every page of lsf's
|
|
60
|
+
docs:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// playwright.config.ts
|
|
64
|
+
import { defineConfig } from '@playwright/test';
|
|
65
|
+
|
|
66
|
+
// The app's values (SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SCOPES): react-router-serve reads no .env.
|
|
67
|
+
process.loadEnvFile('.env');
|
|
68
|
+
|
|
69
|
+
export default defineConfig({
|
|
70
|
+
testDir: 'e2e',
|
|
71
|
+
workers: 1, // one lsf, one shop: the tests change it in turn
|
|
72
|
+
use: {
|
|
73
|
+
// For the test's own requests to the backend: the template answers 410 to HeadlessChrome.
|
|
74
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
|
|
75
|
+
},
|
|
76
|
+
webServer: [
|
|
77
|
+
{
|
|
78
|
+
command: 'npx local-storefront app dev --app . --port 9393 --app-url http://localhost:3001 --polaris offline --static --quiet',
|
|
79
|
+
url: 'http://127.0.0.1:9393/__lsf/status',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// A build, not the dev server: on a cold start, Vite's dev server optimizes the
|
|
83
|
+
// dependencies a page asks for and reloads the page, under the test.
|
|
84
|
+
command: 'npx prisma migrate deploy && npx react-router build && npx react-router-serve ./build/server/index.js',
|
|
85
|
+
url: 'http://localhost:3001/',
|
|
86
|
+
env: { PORT: '3001', SHOPIFY_APP_URL: 'http://localhost:3001', LSF_URL: 'http://127.0.0.1:9393' },
|
|
87
|
+
timeout: 120_000,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
test('the app opens in lsf\'s admin', async ({ page }) => {
|
|
95
|
+
await page.goto('http://127.0.0.1:9393/admin/apps/my-app/app');
|
|
96
|
+
await expect(page.frameLocator('iframe').getByText('Get started with products')).toBeVisible();
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The template's `isbot` answers `410 Gone` to Playwright's headless Chromium (`HeadlessChrome` in
|
|
101
|
+
its user agent). Pages in lsf's admin pass: the frame's proxy says Chrome. A request of the test
|
|
102
|
+
to `http://localhost:3001` does not go through it, hence `use.userAgent`. The backend runs from a
|
|
103
|
+
build: Vite's dev server, on a cold start (CI), reloads the page under the test while it
|
|
104
|
+
optimizes its dependencies. A build has no HMR port either, so the tests run next to your dev
|
|
105
|
+
server.
|
|
106
|
+
|
|
107
|
+
## Sessions: every test is isolated
|
|
108
|
+
|
|
109
|
+
The server keeps one **session** per browser: the cart, the logged-in customer, the selected
|
|
110
|
+
country, and optionally data of its own. A session is identified by the `_lsf_session` cookie,
|
|
111
|
+
which the server sets on the first response. A Playwright test gets a fresh browser context,
|
|
112
|
+
hence a fresh cookie jar, hence its own session: tests can run in parallel against one server
|
|
113
|
+
without seeing each other's carts. Their checkouts share the compiled Functions: a Function is
|
|
114
|
+
compiled once, and as many run at once as the machine has CPUs.
|
|
115
|
+
|
|
116
|
+
Clients without a cookie jar (curl, an API test) can name their session with a header instead:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
curl -H 'x-lsf-session: my-test' http://127.0.0.1:9292/cart.js
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Putting a session in a known state
|
|
123
|
+
|
|
124
|
+
`PUT /__lsf/session` replaces the state of the caller's session. The body is a
|
|
125
|
+
[session](data-reference.md#session); every endpoint is in the [control API](control-api.md).
|
|
126
|
+
The examples use the demo store that `npx local-storefront init` writes: the SKU `TEE-BLK-M`,
|
|
127
|
+
the customer `jane.doe@example.com`, the product `ceramic-mug`. Put your own SKUs and emails.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { test, expect } from '@playwright/test';
|
|
131
|
+
|
|
132
|
+
test('a logged-in customer sees their cart', async ({ page }) => {
|
|
133
|
+
// page.request shares its cookies with the page.
|
|
134
|
+
const response = await page.request.put('/__lsf/session', {
|
|
135
|
+
data: {
|
|
136
|
+
customer: 'jane.doe@example.com',
|
|
137
|
+
cart: { items: [{ variant: 'TEE-BLK-M', quantity: 2 }], note: 'Gift wrap' },
|
|
138
|
+
country: 'FR',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
expect(response.ok()).toBeTruthy();
|
|
142
|
+
|
|
143
|
+
await page.goto('/cart');
|
|
144
|
+
const lines = page.getByRole('main').getByRole('table');
|
|
145
|
+
await expect(lines.getByRole('link', { name: 'Organic Cotton T-Shirt', exact: true })).toBeVisible();
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Look in the cart's table, where Dawn and Horizon put the lines of the cart. The menu of the
|
|
150
|
+
header, and the products listed under the cart (Horizon's "You may also like"), often name the
|
|
151
|
+
same products.
|
|
152
|
+
|
|
153
|
+
Variants are referenced by SKU, by id, or by a product handle (its first variant). When the body
|
|
154
|
+
is wrong the answer is `422` with the same diagnostics as
|
|
155
|
+
`npx local-storefront validate --format json`:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"ok": false,
|
|
160
|
+
"errors": 1,
|
|
161
|
+
"warnings": 0,
|
|
162
|
+
"diagnostics": [
|
|
163
|
+
{
|
|
164
|
+
"severity": "error",
|
|
165
|
+
"code": "unknown_variant",
|
|
166
|
+
"file": "(request body)",
|
|
167
|
+
"path": "/cart/items/0/variant",
|
|
168
|
+
"message": "\"TEE-BLK-XXL\" is neither the SKU of a variant nor the handle of a product",
|
|
169
|
+
"hint": "did you mean \"TEE-BLK-XL\"?"
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`customer` is an email, `"default"` (the first customer of the data) or `"none"`. For a B2B
|
|
176
|
+
customer, `company_location` names the location they buy for:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
await page.request.put('/__lsf/session', {
|
|
180
|
+
data: { customer: 'alex.morgan@example.com', company_location: 'Northwind Seattle' },
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
To start every test logged in, start the server with `--customer default` (or an email).
|
|
185
|
+
|
|
186
|
+
Fail the test on a wrong body, so that a wrong fixture never looks like a theme bug:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
190
|
+
|
|
191
|
+
async function setSession(page: Page, session: object) {
|
|
192
|
+
const response = await page.request.put('/__lsf/session', { data: session });
|
|
193
|
+
if (!response.ok()) throw new Error(JSON.stringify(await response.json(), null, 2));
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Data for one test only
|
|
198
|
+
|
|
199
|
+
A session can carry its own store data. `data` is a [data file](data-format.md) applied on
|
|
200
|
+
top of the files on disk, for this session only:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
await setSession(page, {
|
|
204
|
+
data: {
|
|
205
|
+
shop: { name: 'A very long shop name that must not break the header' },
|
|
206
|
+
products: [
|
|
207
|
+
{ title: 'Sold out thing', price: '10.00', available: false },
|
|
208
|
+
{ title: 'Ceramic Mug', handle: 'ceramic-mug', price: '1.00', compare_at_price: '22.00' },
|
|
209
|
+
],
|
|
210
|
+
now: '2025-12-24T08:00:00Z',
|
|
211
|
+
theme_settings: { cart_type: 'page' },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
await page.goto('/products/sold-out-thing');
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- A product, collection, page or blog with the handle of an existing one **replaces** it; the
|
|
218
|
+
others are **added**. Customers are matched by email, menus by handle, gift cards by code,
|
|
219
|
+
companies, locations and selling plan groups by name.
|
|
220
|
+
- `shop` is merged field by field. `localization`, `session` and `now` replace what the files
|
|
221
|
+
say. `checkout` is merged as a scenario's is: each field it gives (`shipping_rates`,
|
|
222
|
+
`payment_methods`, `discounts`, `tax_rate`) replaces the files', and `functions` is merged
|
|
223
|
+
Function by Function.
|
|
224
|
+
- `theme_settings`, `files` and `swatches` are merged key by key.
|
|
225
|
+
|
|
226
|
+
Other sessions keep seeing the data on disk. This is the way to test edge cases (an empty
|
|
227
|
+
collection, a product with 100 variants, a sold-out state, a long title, another currency)
|
|
228
|
+
without growing the shared fixtures.
|
|
229
|
+
|
|
230
|
+
The same goes for a subscription, a pickup location or a swatch. This test sells a product of
|
|
231
|
+
the demo store by subscription, and starts with one in the cart:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
await setSession(page, {
|
|
235
|
+
data: {
|
|
236
|
+
selling_plan_groups: [
|
|
237
|
+
{
|
|
238
|
+
name: 'Coffee club',
|
|
239
|
+
selling_plans: [
|
|
240
|
+
{ name: 'Every month', price_adjustments: [{ value_type: 'percentage', value: 15 }] },
|
|
241
|
+
],
|
|
242
|
+
products: ['ceramic-mug'],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
locations: [{ name: 'Pop-up store', pick_up_time: 'Ready in 1 hour' }],
|
|
246
|
+
swatches: { Oat: '#e8dcc4' },
|
|
247
|
+
},
|
|
248
|
+
cart: { items: [{ variant: 'MUG-OAT', selling_plan: 'Every month' }] },
|
|
249
|
+
});
|
|
250
|
+
await page.goto('/cart');
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`GET /__lsf/session` returns the current state, and `DELETE /__lsf/session` resets the
|
|
254
|
+
session to what the data files define.
|
|
255
|
+
|
|
256
|
+
## A checkout in a test
|
|
257
|
+
|
|
258
|
+
With an app, the same server runs its checkout, its thank-you page and its customer account
|
|
259
|
+
(see [checkout](checkout.md)). Start it with the theme and the app, or without a theme:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
npx local-storefront serve --theme . --app ../my-app --port 0 --static --quiet --strict
|
|
263
|
+
npx local-storefront app dev --app ../my-app --port 0 --quiet --strict # no theme
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// playwright.config.ts
|
|
268
|
+
export default defineConfig({
|
|
269
|
+
webServer: {
|
|
270
|
+
command: 'npx local-storefront serve --app ../my-app --port 9393 --static --quiet --strict',
|
|
271
|
+
url: 'http://127.0.0.1:9393/__lsf/status',
|
|
272
|
+
reuseExistingServer: !process.env.CI,
|
|
273
|
+
},
|
|
274
|
+
use: { baseURL: 'http://127.0.0.1:9393' },
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
With `--app`, the server also runs the app's [Admin API](admin.md), which records each order and
|
|
279
|
+
sends its webhooks. Tests of the checkout alone can leave it out: `--only theme,checkout`, or
|
|
280
|
+
`app dev --only checkout` without a theme ([what runs](cli.md#what-runs)). The orders are then
|
|
281
|
+
the session's only.
|
|
282
|
+
|
|
283
|
+
**Put the session in a state, then open the checkout.** The cart, the customer and data for this
|
|
284
|
+
test only go through `PUT /__lsf/session`, as above; `/checkout` opens the checkout of that cart
|
|
285
|
+
(`/checkout?scenario=<name>` the one of a [scenario](checkout.md#scenarios), with its cart and
|
|
286
|
+
buyer). A discount Function runs only for a discount that names it: give one in
|
|
287
|
+
`data.checkout`. Another Function runs only through an enabled owner, as on Shopify: give it
|
|
288
|
+
`"enabled": true` in `data.checkout.functions`, or open a scenario that names it in `extensions`
|
|
289
|
+
([what runs](checkout.md#owners-and-scenarios)). As in a scenario, each field it gives (`shipping_rates`, `payment_methods`,
|
|
290
|
+
`discounts`, `tax_rate`) replaces the store data's for the session, and the settings of the
|
|
291
|
+
Functions (`functions`) are merged Function by Function: what it does not give stays. A
|
|
292
|
+
discount has the settings of its Function in `metafields`, the metafield its
|
|
293
|
+
`[extensions.input.variables]` (or its input query) reads: without them, the Function has nothing
|
|
294
|
+
to apply.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
await setSession(page, {
|
|
298
|
+
customer: 'jane.doe@example.com',
|
|
299
|
+
cart: { items: [{ variant: 'TEE-BLK-M', quantity: 2 }] },
|
|
300
|
+
data: {
|
|
301
|
+
now: '2025-01-15T10:00:00Z',
|
|
302
|
+
checkout: {
|
|
303
|
+
shipping_rates: [{ handle: 'standard', title: 'Standard', price: '4.90' }],
|
|
304
|
+
discounts: [
|
|
305
|
+
{
|
|
306
|
+
function: 'discount-codes',
|
|
307
|
+
title: '10% off',
|
|
308
|
+
code: 'SAVE10',
|
|
309
|
+
metafields: { '$app.function-configuration': { type: 'json', value: { percentage: 10 } } },
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
await page.goto('/checkout');
|
|
316
|
+
await expect(page.locator('html')).toHaveAttribute('data-lsf-ready', 'true');
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Wait for the page, not for a time.** The checkout and the customer account set
|
|
320
|
+
`data-lsf-ready="true"` on `<html>` once no change is on its way to the server and every UI
|
|
321
|
+
extension on the page rendered (or threw, or said nothing for 5 s); it goes back to `false` while
|
|
322
|
+
a change is on its way, and the page has `aria-busy="true"` then. Wait for it after each action
|
|
323
|
+
that changes the checkout:
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
await page.getByRole('textbox', { name: 'Discount code' }).fill('SAVE10');
|
|
327
|
+
await page.getByRole('button', { name: 'Apply' }).click();
|
|
328
|
+
await expect(page.locator('html')).toHaveAttribute('data-lsf-ready', 'true');
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Select by role and name.** The fields have labels ("Email", "Address", "Discount code"), the
|
|
332
|
+
choices are radios named by their title, and the buttons say what they do ("Pay now",
|
|
333
|
+
"Complete order", "Apply"): `getByRole` finds them. What the Functions did is in the state too:
|
|
334
|
+
`GET /__lsf/checkout` answers the state of the session's checkout (its `runs`, `cost`,
|
|
335
|
+
`discounts`, `errors`), with the same session header or cookie.
|
|
336
|
+
|
|
337
|
+
**Orders are reproducible.** The orders a session places are numbered after the last one of the
|
|
338
|
+
store data, one after the other, whatever the other sessions do, and placed at `now` when the
|
|
339
|
+
data sets it: the thank-you page and the customer account (`/account`) read the same number and
|
|
340
|
+
date on every run. An order lives as long as its session; only the last one a session placed is
|
|
341
|
+
kept. `DELETE /__lsf/session` resets one session, `DELETE /__lsf/sessions` every one. With an
|
|
342
|
+
app, the [Admin API](admin.md) records each order too, under the same number unless another
|
|
343
|
+
session or the backend took it first (then under its next one, so that its orders have unique
|
|
344
|
+
names): the answer of `complete` gives the Admin API's. The backend of the app, its webhooks
|
|
345
|
+
and its tests: [test your backend](admin.md#test-your-backend). The checkout of an app in CI:
|
|
346
|
+
[checkout](checkout.md#in-ci).
|
|
347
|
+
|
|
348
|
+
## Simulating a slow network
|
|
349
|
+
|
|
350
|
+
A local server answers in milliseconds, so loading states flash by before a test can see
|
|
351
|
+
them. `--throttle` makes it answer late.
|
|
352
|
+
|
|
353
|
+
Like a real Shopify store:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
npx local-storefront serve --throttle simulated
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Like a real store on a slow phone:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
npx local-storefront serve --throttle slow
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Your own delay, for everything or for one kind of request:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
npx local-storefront serve --throttle 300ms
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
npx local-storefront serve --throttle cart-add=1s
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A preset with a change:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
npx local-storefront serve --throttle simulated,cart-add=2s
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
A throttle on the command line slows the whole suite. To slow one test, give its session a
|
|
382
|
+
throttle of its own, which replaces the server's for that session:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
test('the cart count waits for the server', async ({ page }) => {
|
|
386
|
+
await setSession(page, { throttle: { 'cart-add': '1s' } });
|
|
387
|
+
await page.goto('/products/ceramic-mug');
|
|
388
|
+
const added = page.waitForResponse('**/cart/add*');
|
|
389
|
+
await page
|
|
390
|
+
.locator('form[data-type="add-to-cart-form"]')
|
|
391
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
392
|
+
.click();
|
|
393
|
+
await expect(page.getByTestId('cart-bubble')).toHaveText('0'); // the request is on its way
|
|
394
|
+
expect((await added).headers()['x-lsf-throttle']).toBe('1000ms');
|
|
395
|
+
await expect(page.getByTestId('cart-bubble')).toHaveText('1');
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The selectors are Horizon's: its product page has a second "Add to cart" button, in a bar that
|
|
400
|
+
sticks to the bottom, and its cart count is `data-testid="cart-bubble"`. Put your theme's own,
|
|
401
|
+
and its loading state (a spinner, a disabled button) where Horizon has none.
|
|
402
|
+
|
|
403
|
+
`throttle` takes the same rules as the command line: a string (`"simulated"`,
|
|
404
|
+
`"simulated,cart-add=2s"`), a number of milliseconds for everything, or an object
|
|
405
|
+
(`{ "preset": "slow", "image": 0 }`). `"none"` lifts the server's throttle for the session.
|
|
406
|
+
The control API is never delayed, and a delayed response carries the header
|
|
407
|
+
`x-lsf-throttle: 1000ms`.
|
|
408
|
+
|
|
409
|
+
### Kinds of requests and presets
|
|
410
|
+
|
|
411
|
+
Delays are in milliseconds.
|
|
412
|
+
|
|
413
|
+
| Kind | Requests | `simulated` | `slow` |
|
|
414
|
+
|---|---|---|---|
|
|
415
|
+
| `page` | Pages | 80 | 800 |
|
|
416
|
+
| `section` | Section Rendering API | 80 | 600 |
|
|
417
|
+
| `cart-read` | Reading the cart (`/cart.js`) | 150 | 700 |
|
|
418
|
+
| `cart-add` | Adding to the cart | 300 | 1200 |
|
|
419
|
+
| `cart-change` | Changing a quantity | 300 | 1200 |
|
|
420
|
+
| `cart-update` | Updating the cart | 300 | 1200 |
|
|
421
|
+
| `cart-clear` | Emptying the cart | 300 | 1200 |
|
|
422
|
+
| `search` | Predictive search | 250 | 1000 |
|
|
423
|
+
| `recommendations` | Product recommendations | 250 | 1000 |
|
|
424
|
+
| `product` | Product data (`/products/<handle>.js`) | 200 | 800 |
|
|
425
|
+
| `form` | Forms: contact, login, language | 400 | 1500 |
|
|
426
|
+
| `asset` | Theme files (CSS, JavaScript, fonts) | 30 | 300 |
|
|
427
|
+
| `image` | Images | 40 | 500 |
|
|
428
|
+
|
|
429
|
+
Two more names group several kinds: `cart` (the five cart kinds) and `all` (everything).
|
|
430
|
+
|
|
431
|
+
| Preset | Meaning |
|
|
432
|
+
|---|---|
|
|
433
|
+
| `simulated` | A Shopify store on a good connection. |
|
|
434
|
+
| `slow` | The same store on a slow mobile connection. |
|
|
435
|
+
| `none` | No delay. |
|
|
436
|
+
|
|
437
|
+
The reads of `simulated` were timed on Shopify's Horizon demo store in October 2026. The
|
|
438
|
+
writes (cart changes, forms) are estimates. `slow` is not measured.
|
|
439
|
+
|
|
440
|
+
### Rules
|
|
441
|
+
|
|
442
|
+
- Write durations as `300ms`, `1.5s`, or a number of milliseconds.
|
|
443
|
+
- The most precise rule wins: `cart-add`, then `cart`, then `all`.
|
|
444
|
+
- Rules apply in order. A rule after a preset changes that preset.
|
|
445
|
+
- `0` removes the delay for a kind: `--throttle 300ms,image=0`.
|
|
446
|
+
|
|
447
|
+
## What to assert on
|
|
448
|
+
|
|
449
|
+
- **The page itself.** The HTML is what Shopify would render for the same data: section and
|
|
450
|
+
block wrappers (`#shopify-section-<id>`, `.shopify-block`), form markup, image URLs and
|
|
451
|
+
`srcset`s, money formats and translations follow Shopify's output.
|
|
452
|
+
- **Liquid errors.** A page with Liquid errors renders them inline, as Shopify does
|
|
453
|
+
(`Liquid error (sections/x line 12): ...`), and the response carries the header
|
|
454
|
+
`x-lsf-liquid-errors: <count>`. A blanket check catches regressions on every navigation:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
test.beforeEach(async ({ page }) => {
|
|
458
|
+
page.on('response', (response) => {
|
|
459
|
+
const errors = response.headers()['x-lsf-liquid-errors'];
|
|
460
|
+
if (errors) throw new Error(`${response.url()} rendered with Liquid errors: ${errors}`);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
- **What the theme tells Shopify.** The page has the `Shopify` JavaScript object a storefront
|
|
466
|
+
has, including the standard actions: `await page.evaluate(() => Shopify.actions.getCart())`
|
|
467
|
+
reads the cart the way an app would. Analytics events a theme publishes with
|
|
468
|
+
`Shopify.analytics.publish` are kept in `Shopify.analytics.replayQueue`, and cookie consent
|
|
469
|
+
goes through `Shopify.customerPrivacy`. See
|
|
470
|
+
[compatibility](compatibility.md#what-shopify-injects-into-pages).
|
|
471
|
+
- **The template.** `x-lsf-template` names the template that rendered the page (`product`,
|
|
472
|
+
`product.alternate`, `404`, ...).
|
|
473
|
+
- **Pages that are not found.** A 404 renders the theme's `404` template, so a wrong URL looks
|
|
474
|
+
like a page that works. The response carries `x-lsf-not-found` with the cause (`no product
|
|
475
|
+
with the handle "x"`, `no page at "/x"`, `no metaobject "type/x" in the store data`, `the
|
|
476
|
+
theme has no templates/metaobject/<type>.json...`, `no section "x" on the page...`) and the
|
|
477
|
+
server prints it in its log. To
|
|
478
|
+
fail a test on a link that leads nowhere:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
page.on('response', (response) => {
|
|
482
|
+
const reason = response.headers()['x-lsf-not-found'];
|
|
483
|
+
if (reason) throw new Error(`${response.url()} is a 404: ${reason}`);
|
|
484
|
+
});
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
A test of the theme's own 404 page can leave this out: a 404 is what it expects. Outside a
|
|
488
|
+
browser, `npx local-storefront render <path> --strict` fails on a 404.
|
|
489
|
+
- **The cart.** `GET /cart.js` returns the cart in Shopify's Ajax API format.
|
|
490
|
+
- **Render time.** Every response carries `Server-Timing`, as on Shopify: `processing` for the
|
|
491
|
+
whole request, `render` for the Liquid, and with `--timings` one entry per section and per
|
|
492
|
+
theme block. `GET /__lsf/timings` adds up every page, section and block rendered since the
|
|
493
|
+
server started, or since `DELETE /__lsf/timings`. These are the times of local-storefront on
|
|
494
|
+
the machine that runs the tests: compare them with each other rather than with a fixed number,
|
|
495
|
+
which a busy CI machine would miss. The entries are listed in
|
|
496
|
+
[performance](performance.md#entries-of-server-timing). To find what makes a page slow,
|
|
497
|
+
`npx local-storefront profile <path>` shows every section, block and snippet it renders, in
|
|
498
|
+
time or in points: what the page would cost Shopify, where a product or a metafield has to be
|
|
499
|
+
fetched. Points are the same at every render, so a test can compare them
|
|
500
|
+
([performance](performance.md#what-the-page-would-cost-shopify)).
|
|
501
|
+
- **Images.** A response with `x-lsf-placeholder: 1` is a generated placeholder: the data
|
|
502
|
+
references a file that is not in `files/`. Placeholders have the declared size, so layout
|
|
503
|
+
assertions hold; put real files in `files/` for visual regression tests. A browser gets
|
|
504
|
+
images as AVIF or WebP, as on Shopify; a request that does not say it accepts them
|
|
505
|
+
(`fetch`, `curl`, the HTTP client of a test) gets the format of the file.
|
|
506
|
+
|
|
507
|
+
## What the server answers
|
|
508
|
+
|
|
509
|
+
Everything a theme talks to on a storefront:
|
|
510
|
+
|
|
511
|
+
| Area | Endpoints |
|
|
512
|
+
|---|---|
|
|
513
|
+
| Pages | `/`, `/products/<handle>`, `/collections`, `/collections/<handle>`, `/collections/<handle>/<tag>`, `/collections/<handle>/products/<handle>`, `/pages/<handle>`, `/blogs/<handle>`, `/blogs/<handle>/tagged/<tag>`, `/blogs/<blog>/<article>`, `/cart`, `/search`, `/policies/<handle>`, `/gift_cards/<shop id>/<token>`, `/password`, `/account`. A 404 for the rest. Each also under a locale prefix (`/fr/...`). |
|
|
514
|
+
| Customer accounts | With new customer accounts, `/account` is a page of local-storefront where the visitor chooses who is logged in; `/account/login`, `/customer_authentication/login` and the other account URLs redirect to it. `/company_location/update?location_id=<id>&return_to=<path>` changes the location of a B2B customer. |
|
|
515
|
+
| Robots and sitemaps | `/robots.txt` (from `templates/robots.txt.liquid`, or Shopify's default rules), `/sitemap.xml` and `/sitemap_<products|pages|collections|blogs>_1.xml`. |
|
|
516
|
+
| Alternate templates | `?view=<suffix>` and the `template_suffix` of the resource. |
|
|
517
|
+
| Section Rendering API | `?section_id=<id>` and `?sections=<id>,<id>` on any page. An id the page does not have is a 404 for `section_id` and `null` in `sections`; either way `x-lsf-not-found` names it and lists the ids the page has. An id asked for twice in `sections` is rendered once, as on Shopify. |
|
|
518
|
+
| Cart Ajax API | `GET /cart.js`, `POST /cart/add.js`, `/cart/change.js`, `/cart/update.js`, `/cart/clear.js` (with `sections` and `sections_url`), and the form posts `/cart/add`, `/cart`. `add.js` and `change.js` keep to the stock as Shopify does: more than the stock puts what is in stock in the cart and answers `422`. `update.js` does not check it (Shopify does not either for the lines already in the cart). |
|
|
519
|
+
| Product and search JSON | `/products/<handle>.js`, `/products.json`, `/collections/<handle>/products.json`, `/search/suggest.json`, `/search/suggest?section_id=`, `/recommendations/products.json`, `/recommendations/products?section_id=&product_id=`. |
|
|
520
|
+
| Forms | Contact, newsletter (`customer`), blog comment, customer logout, localization (country and language), storefront password. The outcome shows in `form.posted_successfully?` and `form.errors` on the next page, once. A wrong storefront password comes back to `/password` with the error; the right one (`password`, or `shop.password`) goes to the home page. |
|
|
521
|
+
| CDN | `/cdn/shop/t/1/assets/<file>` (including `.liquid` assets), `/cdn/shop/files/<path>` with image transformations, the compiled `{% stylesheet %}` and `{% javascript %}` bundles, fonts. |
|
|
522
|
+
| Control | `/__lsf` (a status page), `/__lsf/status`, `/__lsf/session`, `DELETE /__lsf/sessions`, `/__lsf/login?customer=<email>`, `/__lsf/schema/<kind>`, `POST /__lsf/reload`, `GET` and `DELETE /__lsf/timings`, `/__lsf/profile?path=<path>`. With `--app`, those of the checkout and the Admin API too ([control API](control-api.md#apps-checkout-and-admin-api)). |
|
|
523
|
+
|
|
524
|
+
Not simulated: Shopify's checkout (`/checkout` shows a summary of the cart, or, with `--app`,
|
|
525
|
+
the local [checkout](checkout.md) of a Shopify app, unless `--only` leaves the checkout out), classic customer accounts (the theme's `templates/customers`; its account forms lead to `/account`),
|
|
526
|
+
and what other apps serve (with `--app`, the [theme app extensions](storefront.md#theme-app-extensions) of that app render). See [compatibility.md](compatibility.md).
|
|
527
|
+
|
|
528
|
+
## Reproducibility
|
|
529
|
+
|
|
530
|
+
Renders are deterministic: ids, handles, asset versions (`?v=`, derived from file content) and
|
|
531
|
+
default dates do not depend on the machine or the run. The one moving part is the clock. Set
|
|
532
|
+
`"now"` in the data when the theme shows relative dates, countdowns or "new" badges.
|
|
533
|
+
|
|
534
|
+
`npx local-storefront render /products/ceramic-mug` prints the HTML of a page without starting a
|
|
535
|
+
server, which is handy for snapshot tests of the markup:
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
npx local-storefront render '/collections/all?sort_by=price-ascending' --strict > collection.html
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
`--strict` makes it exit with an error when the page has Liquid errors.
|
|
542
|
+
|
|
543
|
+
## In CI
|
|
544
|
+
|
|
545
|
+
A complete workflow, for GitHub Actions. `npm ci` installs what `package-lock.json` locks: run
|
|
546
|
+
`npm install --save-dev local-storefront @playwright/test` once, and commit `package.json` and
|
|
547
|
+
`package-lock.json`.
|
|
548
|
+
|
|
549
|
+
```yaml
|
|
550
|
+
# .github/workflows/storefront.yml
|
|
551
|
+
name: storefront
|
|
552
|
+
on: [push, pull_request]
|
|
553
|
+
jobs:
|
|
554
|
+
storefront:
|
|
555
|
+
runs-on: ubuntu-latest
|
|
556
|
+
steps:
|
|
557
|
+
- uses: actions/checkout@v7
|
|
558
|
+
- uses: actions/setup-node@v7
|
|
559
|
+
with:
|
|
560
|
+
node-version: 24 # 18 or later
|
|
561
|
+
cache: npm
|
|
562
|
+
- run: npm ci
|
|
563
|
+
- run: npx local-storefront validate --format github # the store data
|
|
564
|
+
- run: npx local-storefront check --format github # what Shopify refuses on upload
|
|
565
|
+
- name: Render every page, and fail on a Liquid error
|
|
566
|
+
run: |
|
|
567
|
+
npx local-storefront routes --all > routes.txt
|
|
568
|
+
failed=0
|
|
569
|
+
while read -r route; do
|
|
570
|
+
npx local-storefront render "$route" --strict --format github > /dev/null || failed=1
|
|
571
|
+
done < routes.txt
|
|
572
|
+
exit $failed
|
|
573
|
+
# - run: npx local-storefront fmt --check # uncomment once `fmt` has run in its own commit
|
|
574
|
+
- run: npx playwright install --with-deps chromium
|
|
575
|
+
- run: npx playwright test
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
- `check` reads the files and renders none. A Liquid error at render time (a division by zero)
|
|
579
|
+
shows only when the page renders: with `render --strict`, or in a test.
|
|
580
|
+
- `routes --all` lists every page of the store data, each again in the other languages, a
|
|
581
|
+
search, and the sections the theme fetches (product recommendations, predictive search);
|
|
582
|
+
`render` answers each one, so the loop renders the whole store. `/account`, a page of lsf's,
|
|
583
|
+
is not listed. The step stops at once when `routes` fails (a wrong `--theme`), renders every
|
|
584
|
+
route, and fails at the end if one has a Liquid error. Name the variable `route`, not `path`:
|
|
585
|
+
in zsh, `path` is `PATH`, and the loop would lose `npx`.
|
|
586
|
+
- `--format github` shows each problem on its line of the pull request.
|
|
587
|
+
- `fmt --check` fails on a theme no one has formatted: Shopify's Horizon has 28 files to
|
|
588
|
+
format. Run `npx local-storefront fmt` first, in a commit of its own, then uncomment the step.
|
|
589
|
+
- The steps before Playwright take a few seconds, and fail before the browser tests start.
|
|
590
|
+
- For an app: [its checkout in CI](checkout.md#in-ci), and [its backend](admin.md#test-your-backend).
|
package/llms.txt
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# local-storefront
|
|
2
|
+
|
|
3
|
+
> local-storefront runs a Shopify theme locally, rendered from JSON store data instead of the Shopify API, with the HTML Shopify renders. It serves the storefront for end-to-end tests, validates store data, finds and fixes what Shopify's strict Liquid parser rejects, formats Liquid as Prettier does, and profiles pages. For a Shopify app, it runs the Functions and checkout UI extensions on a local checkout, and serves a local GraphQL Admin API (2026-10) with OAuth and signed webhooks, to test the app's backend offline. Install with `npm install --save-dev local-storefront`, then `npx local-storefront init` writes store data to edit; run it as `npx local-storefront <command>` (never `npx lsf`, another package).
|
|
4
|
+
|
|
5
|
+
Commands are non-interactive. Exit codes: 0 nothing to report, 1 something to fix, 2 the command could not run. Results go to stdout, messages to stderr. `--format json` prints one JSON document for `validate`, `check`, `app check`, `app function run` and `app webhook trigger`; `--format github` prints GitHub Actions annotations for `check`, `validate` and `render`; `routes` prints one path per line (`--all` adds the other languages, a search and the sections the theme fetches), `render` the HTML; the other commands print text.
|
|
6
|
+
|
|
7
|
+
The links below are relative to this file. Read the docs of the version you run (`npx local-storefront --version`): installed from npm, they are in `node_modules/local-storefront/` (`llms.txt`, `docs/`); 0.2.0 and earlier ship none, so read them at the tag, `https://raw.githubusercontent.com/0xtlt/local-storefront/v0.2.0/docs/agents.md`. An `lsf` built from a checkout of the repository, not installed from npm, has the docs of that checkout, in its `docs/`, whatever `--version` says.
|
|
8
|
+
|
|
9
|
+
## Start here
|
|
10
|
+
|
|
11
|
+
- [Quickstart: a theme](docs/quickstart-theme.md): `serve`, `init`, `validate`, `check`, `fmt --check`, a Playwright test with `PUT /__lsf/session`, CI
|
|
12
|
+
- [Quickstart: Functions](docs/quickstart-functions.md): `app init`, `app build`, `app dev --watch`, `app function run` (`--save-input`, `--input`), a scenario's `expect`, `app check --build` in CI
|
|
13
|
+
- [Quickstart: an Admin API backend](docs/quickstart-admin-api.md): `serve --app . --only admin`, the printed token, `@shopify/admin-api-client`, GraphiQL, webhooks with an HMAC check, `DELETE /__lsf/admin` between tests, the clock, CI
|
|
14
|
+
- [Quickstart: a public app](docs/quickstart-app.md): the React Router template with its one edit (the fetch function and `LSF_URL`), lsf's admin at `/admin/apps/<handle>`, webhooks and `[events]`, managed pricing, a theme app extension with `serve --app`, the app proxy, a Playwright test
|
|
15
|
+
- [Agents](docs/agents.md): how a tool or an AI agent drives local-storefront, task by task: set up, store data, checks, CI, a server, an app's backend
|
|
16
|
+
- [CLI reference](docs/cli.md): every command, option, exit code and output format; what runs (`--only theme,checkout,admin`: `serve` runs the theme, all three with `--app`; `app dev` the checkout and the Admin API; a part left out answers 404 with `X-LSF-Reason`); what a change of the data on disk reloads (a checkout scenario keeps the Admin API's state, store data seeds it again)
|
|
17
|
+
|
|
18
|
+
## Guides
|
|
19
|
+
|
|
20
|
+
- [Data format](docs/data-format.md): how to write the store data
|
|
21
|
+
- [Testing](docs/testing.md): end-to-end tests, sessions, per-test data, slow networks, CI
|
|
22
|
+
- [Strict Liquid](docs/strict-liquid.md): `npx local-storefront check` and `npx local-storefront check --fix`; `check` also reports the `{% schema %}` Shopify refuses, and the snippets, sections, blocks and assets a theme names and does not have ([CLI reference](docs/cli.md#check))
|
|
23
|
+
- [Formatting](docs/formatting.md): `npx local-storefront fmt` and its options
|
|
24
|
+
- [Performance](docs/performance.md): `npx local-storefront profile`, points, Server-Timing
|
|
25
|
+
- [Checkout](docs/checkout.md): `npx local-storefront app dev --app`, `app init` (scenarios), `app check` (for CI: the Functions of every scenario run, and each scenario's `expect` holds: payment methods and express buttons, delivery options, discounts, lines, errors and totals, with `includes`, `excludes` and `equals`), `app function run` (`--input` runs one Function on a JSON input, `--save-input` writes the one lsf built), `app function schema` (a Function's `schema.graphql`, offline) and `app function typegen` (a JavaScript Function's `generated/api.ts`): the Functions and checkout UI extensions of a Shopify app
|
|
26
|
+
- [Admin API](docs/admin.md): the local GraphQL Admin API (2026-10), OAuth, token exchange, webhooks and the app proxy, for the backend of a Shopify app; a local admin and App Bridge to preview its embedded pages, and Polaris served offline (`--polaris offline`, `/__lsf/cdn/shopifycloud/polaris-2.js`); managed pricing, its plans declared with `--pricing-plans`, picked on the admin's plan selection page (`legacy = false`: no subscription webhook), a plan ended without the app (`POST /__lsf/admin/pricing-plans/cancel`: `CANCELLED`, a test's stand-in, Shopify's page has no such button); the Partner API where the app reads its plan (`/{partner_id}/api/2026-10/graphql.json`: `activeSubscription`, `events`, `app { events }`; URL and token in `GET /__lsf/admin/partner`); an uninstall (`POST /__lsf/admin/uninstall`) that removes the app's Functions with every object that runs them, its script tags and web pixel, the metafield definitions of its reserved namespaces (their metafields stay; a reinstall creates the definitions again under new ids), the metaobject definitions of its reserved types (their metaobjects are kept for a reinstall), and its app proxy (the storefront's 404, nothing reaches the backend), and stops rendering its UI extensions (checkout, thank-you page, customer account) until it is installed again, as Shopify does
|
|
27
|
+
- [Storefront](docs/storefront.md): accounts, password page, images, feature previews; theme app extensions with `serve --app` (app blocks in `section.blocks` and `content_for 'blocks'`, app embed blocks turned on by `config/settings_data.json` or `app.embeds` of the store data, app-data metafields in `app.metafields`, `X-LSF-Reason` on a block that renders nothing, `check --app`, `app build` checks them)
|
|
28
|
+
|
|
29
|
+
## Reference
|
|
30
|
+
|
|
31
|
+
- [Control API](docs/control-api.md): `/__lsf` endpoints, sessions, response headers
|
|
32
|
+
- [Admin API reference](docs/admin-reference.md): what the local Admin API simulates, family by family: billing (and managed pricing: plans from `--pricing-plans` or `PUT /__lsf/admin/pricing-plans`, the plan selection page at `/admin/charges/<app handle>/pricing_plans`), bulk operations, discounts, staged uploads
|
|
33
|
+
- [Field reference](docs/data-reference.md): every field of the store data
|
|
34
|
+
- [Compatibility](docs/compatibility.md): what matches Shopify, and the known differences
|
|
35
|
+
|
|
36
|
+
## Optional
|
|
37
|
+
|
|
38
|
+
- [Architecture](docs/architecture.md): how the code is organised, for contributors
|
|
39
|
+
- [Changelog](CHANGELOG.md): what changed in each version
|