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.
@@ -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