fix: replace hand-scaffolded Functions with real CLI-generated ones
The user linked the app to a real Partner org and hit two live errors
running `shopify app dev`, which is exactly the verification the earlier
hand-scaffolded Functions couldn't get in this environment. Root-caused and
fixed both, then went further: regenerated both Functions from scratch via
`shopify app generate extension` (now possible — the user's session had
authenticated) instead of patching the guesses.
What broke and why:
- `shopify app config link` pulled a fresh app's (empty) remote config and
overwrote shopify.app.toml, dropping the webhook subscriptions and
app_proxy block — restored both, keeping the real client_id/name/scopes
the CLI set.
- `[extensions.build.watch]` as a nested table was invalid TOML for this
field — it's a plain `watch = [...]` array directly under
`[extensions.build]`.
- The real failure ("doesn't have a build command or it's empty") turned
out to be a red herring pointing at a stale filename
(shopify.function.extension.toml, not the current shopify.extension.toml)
— the actual problem was that `@shopify/shopify_function` was never
installed for these extensions (confirmed: no node_modules), because
hand-writing package.json doesn't run the install step
`shopify app generate extension` does automatically.
Rather than keep guessing at the toolchain, regenerated both Functions for
real:
- `shopify app generate extension --template=cart_checkout_validation` and
`--template=delivery_customization` (--flavor=vanilla-js), which produces
a working vite/vitest-based build+test setup, real
`@shopify/shopify-function-test-helpers` fixture testing (builds actual
WASM and runs it via function-runner), and a generated GraphQL type file
per extension.
- This surfaced several concrete corrections to what was hand-written
before: the real target names are `cart.validations.generate.run` and
`cart.delivery-options.transform.run` (not `purchase.validation.run` /
`purchase.delivery-customization.run`), current api_version is 2026-07
(not 2025-01), the validation output wraps errors in
`operations: [{ validationAdd: { errors } }]` with a plain `message`
field (not top-level `errors` with `localizedMessage`), and the rename
operation is `deliveryOptionRename: { deliveryOptionHandle, title }` (not
`rename: { deliveryOptionHandle, title }`).
- Rewrote each extension's `.graphql` input query to request our actual
dd_* cart attributes (plus delivery option handles for the rename case),
regenerated types via `npm run typegen` in each, and ported the pure
evaluate.js decision logic (same exported function names/behavior as
before, now proven correct against the live schema) into the adapter
file the generator expects.
- Replaced each extension's demo fixture with ones matching our real
logic; `npm test` inside each extension now compiles real WASM and runs
function-runner against them — this is strictly stronger verification
than the previous pure-JS-only unit tests (which are kept too, unchanged,
since the evaluate.js files kept the same interface).
Repo-wide wiring: extensions/*/generated and extensions/*/dist are not
committed (matches the CLI's own per-extension .gitignore) — added
`npm run typegen:functions` (runs automatically before `npm run typecheck`
via a pretypecheck hook) and `npm run test:functions`, both now also in CI.
Root `npm install` picked these two folders up as proper npm workspace
members (they already have their own package.json from generation).
Verified: lint, typecheck, all 52 unit tests, all 8 integration tests, both
extensions' real WASM/function-runner test suites (5 fixtures total), and
both `npm run build` / `npm run build:widget` all pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7a340ad135
commit
381c52d01a
@ -5,3 +5,4 @@ shopify-app-remix
|
|||||||
*/*.yml
|
*/*.yml
|
||||||
.shopify
|
.shopify
|
||||||
extensions/*/assets/*.js
|
extensions/*/assets/*.js
|
||||||
|
extensions/*/dist
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -48,6 +48,8 @@ jobs:
|
|||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test -- --run
|
run: npm test -- --run
|
||||||
|
- name: Function tests (real WASM build + function-runner against fixtures)
|
||||||
|
run: npm run test:functions
|
||||||
- name: Apply migrations
|
- name: Apply migrations
|
||||||
run: npx prisma migrate deploy
|
run: npx prisma migrate deploy
|
||||||
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
|
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
|
||||||
|
|||||||
19
README.md
19
README.md
@ -35,6 +35,8 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything.
|
|||||||
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
||||||
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
||||||
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
|
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
|
||||||
|
| `npm run test:functions` | Real WASM build + `function-runner` tests for both Shopify Functions, against fixtures |
|
||||||
|
| `npm run typegen:functions` | Regenerate `extensions/*/generated/api.ts` from each Function's `schema.graphql` + `.graphql` query (also runs automatically before `npm run typecheck`) |
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@ -50,10 +52,13 @@ automatically before `npm run dev` / `npm run deploy`.
|
|||||||
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
|
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
|
||||||
`extensions/delivery-customization/`) — no Rust toolchain was available in
|
`extensions/delivery-customization/`) — no Rust toolchain was available in
|
||||||
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
|
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
|
||||||
explicitly allows JS as a fallback. They (and the widget's Theme App
|
explicitly allows JS as a fallback. Both were generated with
|
||||||
Extension) were hand-scaffolded rather than generated via
|
`shopify app generate extension` (once a real Partner login was available)
|
||||||
`shopify app generate extension`, which needs an interactive Partner login.
|
and their business logic (`src/evaluate.js` in each) is verified two ways:
|
||||||
**Before deploying either Function**, run `shopify app function schema` in
|
plain Vitest unit tests at the repo root (`npm test`) and real
|
||||||
its directory to pull the authoritative `schema.graphql` for your API
|
`function-runner` fixture tests that compile actual WASM
|
||||||
version and confirm `src/run.graphql` still matches it — these were written
|
(`npm run test:functions`, also in CI). `extensions/*/generated/` and
|
||||||
from documented conventions, not validated against a live schema.
|
`extensions/*/dist/` aren't committed (matching the CLI's own
|
||||||
|
`.gitignore` for these extensions) — `npm run typegen:functions`
|
||||||
|
regenerates the types from the committed `schema.graphql`, and building
|
||||||
|
runs automatically as part of `npm run dev` / `test:functions`.
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
name = "Delivery Date & Time"
|
name = "Delivery Date & Time"
|
||||||
type = "theme"
|
type = "theme"
|
||||||
|
uid = "4935f147-fc17-827b-2629-385b36917fa08faa33de"
|
||||||
|
|||||||
2
extensions/delivery-customization/.gitignore
vendored
Normal file
2
extensions/delivery-customization/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
generated
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Delivery Method Labeling",
|
||||||
|
"description": "Relabels delivery options at checkout with the shopper's chosen pickup/delivery date and time."
|
||||||
|
}
|
||||||
@ -1,12 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "delivery-customization",
|
"name": "delivery-customization",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"license": "UNLICENSED",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "shopify-function-build"
|
"shopify": "npm exec -- shopify",
|
||||||
|
"typegen": "npm exec -- shopify app function typegen",
|
||||||
|
"build": "npm exec -- shopify app function build",
|
||||||
|
"preview": "npm exec -- shopify app function run",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"schema": "schema.graphql",
|
||||||
|
"documents": "src/*.graphql",
|
||||||
|
"generates": {
|
||||||
|
"./generated/api.ts": {
|
||||||
|
"plugins": [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"omitOperationSuffix": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shopify/shopify_function": "^1.0.0"
|
"@shopify/shopify_function": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5297
extensions/delivery-customization/schema.graphql
Normal file
5297
extensions/delivery-customization/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,21 @@
|
|||||||
# NOTE: hand-written — see validation-slot/shopify.extension.toml for why,
|
api_version = "2026-07"
|
||||||
# and run `shopify app function schema` before deploying to confirm
|
|
||||||
# src/run.graphql matches the live schema for your API version.
|
|
||||||
|
|
||||||
api_version = "2025-01"
|
|
||||||
|
|
||||||
[[extensions]]
|
[[extensions]]
|
||||||
name = "Delivery Method Labeling"
|
name = "t:name"
|
||||||
handle = "delivery-customization"
|
handle = "delivery-customization"
|
||||||
type = "function"
|
type = "function"
|
||||||
|
uid = "7caef09d-bc92-c4fe-9c8c-5e43dc1b38bce5a7fa55"
|
||||||
|
description = "t:description"
|
||||||
|
|
||||||
[[extensions.targeting]]
|
[[extensions.targeting]]
|
||||||
target = "purchase.delivery-customization.run"
|
target = "cart.delivery-options.transform.run"
|
||||||
input_query = "src/run.graphql"
|
input_query = "src/cart_delivery_options_transform_run.graphql"
|
||||||
export = "deliveryCustomizationRun"
|
export = "cart-delivery-options-transform-run"
|
||||||
|
|
||||||
[extensions.build]
|
[extensions.build]
|
||||||
command = ""
|
command = ""
|
||||||
path = "dist/function.wasm"
|
path = "dist/function.wasm"
|
||||||
|
|
||||||
[extensions.build.watch]
|
[extensions.ui.paths]
|
||||||
paths = ["src/**/*.js"]
|
create = "/"
|
||||||
|
details = "/"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
query RunInput {
|
query CartDeliveryOptionsTransformRunInput {
|
||||||
cart {
|
cart {
|
||||||
ddMethod: attribute(key: "dd_method") {
|
ddMethod: attribute(key: "dd_method") {
|
||||||
value
|
value
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { renameLabelFor } from "./evaluate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunInput} CartDeliveryOptionsTransformRunInput
|
||||||
|
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {CartDeliveryOptionsTransformRunResult}
|
||||||
|
*/
|
||||||
|
const NO_CHANGES = {
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relabels every presented delivery option to the shopper's actual chosen
|
||||||
|
* method + date, so checkout never shows a generic carrier label
|
||||||
|
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
|
||||||
|
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
|
||||||
|
* have a per-option method mapping (that needs Phase 5's zones/rates
|
||||||
|
* work), so every option in the cart gets the same clarified label —
|
||||||
|
* reasonable since a single order only has one chosen fulfillment method
|
||||||
|
* today.
|
||||||
|
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||||
|
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||||
|
*/
|
||||||
|
export function cartDeliveryOptionsTransformRun(input) {
|
||||||
|
const decision = renameLabelFor({
|
||||||
|
dd_method: input.cart.ddMethod?.value,
|
||||||
|
dd_date: input.cart.ddDate?.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!decision.rename) {
|
||||||
|
return NO_CHANGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = input.cart.deliveryGroups.flatMap((group) =>
|
||||||
|
group.deliveryOptions.map((option) => ({
|
||||||
|
deliveryOptionRename: {
|
||||||
|
deliveryOptionHandle: option.handle,
|
||||||
|
title: decision.title,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { operations };
|
||||||
|
};
|
||||||
@ -1,6 +1,8 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Pure decision logic — see validation-slot/src/evaluate.js for why this is
|
// Pure decision logic, kept separate from the run.js adapter so it's
|
||||||
// split out from run.js (unit-testable without a WASM build).
|
// unit-testable directly with plain Vitest — no WASM build needed to verify
|
||||||
|
// the actual rule (see tests/unit/delivery-customization.test.ts at the
|
||||||
|
// repo root).
|
||||||
//
|
//
|
||||||
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
|
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
|
||||||
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
|
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
|
||||||
|
|||||||
1
extensions/delivery-customization/src/index.js
Normal file
1
extensions/delivery-customization/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cart_delivery_options_transform_run';
|
||||||
@ -1,42 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import { renameLabelFor } from "./evaluate.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import("../generated/api").CartInput} RunInput
|
|
||||||
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delivery Customization Function: relabels every presented delivery
|
|
||||||
* option to the shopper's actual chosen method + date, so checkout never
|
|
||||||
* shows a generic carrier label ("Standard", "Economy") that could be
|
|
||||||
* mistaken for a shipping ETA on what's actually a pickup or local-delivery
|
|
||||||
* order. Phase 4 doesn't yet have a per-option method mapping (that needs
|
|
||||||
* Phase 5's zones/rates work), so every option in the cart gets the same
|
|
||||||
* clarified label — reasonable since a single order only has one chosen
|
|
||||||
* fulfillment method today.
|
|
||||||
* @param {RunInput} input
|
|
||||||
* @returns {FunctionRunResult}
|
|
||||||
*/
|
|
||||||
export function deliveryCustomizationRun(input) {
|
|
||||||
const attributes = {
|
|
||||||
dd_method: input.cart.ddMethod?.value,
|
|
||||||
dd_date: input.cart.ddDate?.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
const decision = renameLabelFor(attributes);
|
|
||||||
if (!decision.rename) {
|
|
||||||
return { operations: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const operations = input.cart.deliveryGroups.flatMap((group) =>
|
|
||||||
group.deliveryOptions.map((option) => ({
|
|
||||||
rename: {
|
|
||||||
deliveryOptionHandle: option.handle,
|
|
||||||
title: decision.title,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { operations };
|
|
||||||
}
|
|
||||||
45
extensions/delivery-customization/tests/default.test.js
Normal file
45
extensions/delivery-customization/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { describe, beforeAll, test, expect } from "vitest";
|
||||||
|
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||||
|
|
||||||
|
describe("Default Integration Test", () => {
|
||||||
|
let schema;
|
||||||
|
let functionDir;
|
||||||
|
let functionInfo;
|
||||||
|
let schemaPath;
|
||||||
|
let targeting;
|
||||||
|
let functionRunnerPath;
|
||||||
|
let wasmPath;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
functionDir = path.dirname(__dirname);
|
||||||
|
await buildFunction(functionDir);
|
||||||
|
functionInfo = await getFunctionInfo(functionDir);
|
||||||
|
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||||
|
schema = await loadSchema(schemaPath);
|
||||||
|
}, 45000);
|
||||||
|
|
||||||
|
const fixturesDir = path.join(__dirname, "fixtures");
|
||||||
|
const fixtureFiles = fs
|
||||||
|
.readdirSync(fixturesDir)
|
||||||
|
.filter((file) => file.endsWith(".json"))
|
||||||
|
.map((file) => path.join(fixturesDir, file));
|
||||||
|
|
||||||
|
fixtureFiles.forEach((fixtureFile) => {
|
||||||
|
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||||
|
const fixture = await loadFixture(fixtureFile);
|
||||||
|
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||||
|
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||||
|
|
||||||
|
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||||
|
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||||
|
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||||
|
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||||
|
|
||||||
|
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||||
|
expect(runResult.error).toBeNull();
|
||||||
|
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-delivery-options-transform-run",
|
||||||
|
"target": "cart.delivery-options.transform.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": null,
|
||||||
|
"ddDate": null,
|
||||||
|
"deliveryGroups": [
|
||||||
|
{
|
||||||
|
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-delivery-options-transform-run",
|
||||||
|
"target": "cart.delivery-options.transform.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"deliveryGroups": [
|
||||||
|
{
|
||||||
|
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"deliveryOptionRename": {
|
||||||
|
"deliveryOptionHandle": "standard-shipping",
|
||||||
|
"title": "Pickup — 2026-08-25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deliveryOptionRename": {
|
||||||
|
"deliveryOptionHandle": "express-shipping",
|
||||||
|
"title": "Pickup — 2026-08-25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/delivery-customization/vite.config.js
Normal file
1
extensions/delivery-customization/vite.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Prevents inheritance from parent Remix project
|
||||||
8
extensions/delivery-customization/vitest.config.js
Normal file
8
extensions/delivery-customization/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
test: {
|
||||||
|
forceRerunTriggers: [
|
||||||
|
'**/tests/fixtures/**',
|
||||||
|
'**/src/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
2
extensions/validation-slot/.gitignore
vendored
Normal file
2
extensions/validation-slot/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
generated
|
||||||
4
extensions/validation-slot/locales/en.default.json
Normal file
4
extensions/validation-slot/locales/en.default.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Delivery Slot Validation",
|
||||||
|
"description": "Blocks checkout if no delivery date/time slot was selected."
|
||||||
|
}
|
||||||
@ -1,12 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "validation-slot",
|
"name": "validation-slot",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"license": "UNLICENSED",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "shopify-function-build"
|
"shopify": "npm exec -- shopify",
|
||||||
|
"typegen": "npm exec -- shopify app function typegen",
|
||||||
|
"build": "npm exec -- shopify app function build",
|
||||||
|
"preview": "npm exec -- shopify app function run",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"codegen": {
|
||||||
|
"schema": "schema.graphql",
|
||||||
|
"documents": "src/*.graphql",
|
||||||
|
"generates": {
|
||||||
|
"./generated/api.ts": {
|
||||||
|
"plugins": [
|
||||||
|
"typescript",
|
||||||
|
"typescript-operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"omitOperationSuffix": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shopify/shopify_function": "^1.0.0"
|
"@shopify/shopify_function": "^2.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5437
extensions/validation-slot/schema.graphql
Normal file
5437
extensions/validation-slot/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,27 +1,17 @@
|
|||||||
# NOTE: hand-written (shopify app generate extension needs an interactive
|
api_version = "2026-07"
|
||||||
# Partner login unavailable in this environment — see README.md). This
|
|
||||||
# mirrors Shopify's documented structure for a JS Cart/Checkout Validation
|
|
||||||
# Function as closely as possible from training knowledge, but has not been
|
|
||||||
# validated against a live `shopify app function schema` pull. Before
|
|
||||||
# deploying: run `shopify app function schema` in this directory to fetch
|
|
||||||
# the authoritative schema.graphql for your app's API version, confirm
|
|
||||||
# src/run.graphql still matches it, and fix up anything that's drifted.
|
|
||||||
|
|
||||||
api_version = "2025-01"
|
|
||||||
|
|
||||||
[[extensions]]
|
[[extensions]]
|
||||||
name = "Delivery Slot Validation"
|
name = "t:name"
|
||||||
handle = "validation-slot"
|
handle = "validation-slot"
|
||||||
type = "function"
|
type = "function"
|
||||||
|
uid = "f5265178-4064-010b-5acf-a7b68096a1d29be8d5da"
|
||||||
|
description = "t:description"
|
||||||
|
|
||||||
[[extensions.targeting]]
|
[[extensions.targeting]]
|
||||||
target = "purchase.validation.run"
|
target = "cart.validations.generate.run"
|
||||||
input_query = "src/run.graphql"
|
input_query = "src/cart_validations_generate_run.graphql"
|
||||||
export = "cartCheckoutValidationRun"
|
export = "cart-validations-generate-run"
|
||||||
|
|
||||||
[extensions.build]
|
[extensions.build]
|
||||||
command = ""
|
command = ""
|
||||||
path = "dist/function.wasm"
|
path = "dist/function.wasm"
|
||||||
|
|
||||||
[extensions.build.watch]
|
|
||||||
paths = ["src/**/*.js"]
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
query RunInput {
|
query CartValidationsGenerateRunInput {
|
||||||
cart {
|
cart {
|
||||||
ddMethod: attribute(key: "dd_method") {
|
ddMethod: attribute(key: "dd_method") {
|
||||||
value
|
value
|
||||||
@ -2,8 +2,8 @@
|
|||||||
import { evaluateCartAttributes } from "./evaluate.js";
|
import { evaluateCartAttributes } from "./evaluate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("../generated/api").CartInput} RunInput
|
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
|
||||||
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
|
* @typedef {import("../generated/api").CartValidationsGenerateRunResult} CartValidationsGenerateRunResult
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ERROR_MESSAGE = {
|
const ERROR_MESSAGE = {
|
||||||
@ -14,10 +14,10 @@ const ERROR_MESSAGE = {
|
|||||||
/**
|
/**
|
||||||
* Cart & Checkout Validation Function — the server-side enforcement half of
|
* Cart & Checkout Validation Function — the server-side enforcement half of
|
||||||
* the widget/Function split (see evaluate.js for the full scope note).
|
* the widget/Function split (see evaluate.js for the full scope note).
|
||||||
* @param {RunInput} input
|
* @param {CartValidationsGenerateRunInput} input
|
||||||
* @returns {FunctionRunResult}
|
* @returns {CartValidationsGenerateRunResult}
|
||||||
*/
|
*/
|
||||||
export function cartCheckoutValidationRun(input) {
|
export function cartValidationsGenerateRun(input) {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
dd_method: input.cart.ddMethod?.value,
|
dd_method: input.cart.ddMethod?.value,
|
||||||
dd_date: input.cart.ddDate?.value,
|
dd_date: input.cart.ddDate?.value,
|
||||||
@ -28,16 +28,13 @@ export function cartCheckoutValidationRun(input) {
|
|||||||
|
|
||||||
const result = evaluateCartAttributes(attributes);
|
const result = evaluateCartAttributes(attributes);
|
||||||
|
|
||||||
if (result.valid) {
|
|
||||||
return { errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
errors: [
|
operations: [
|
||||||
{
|
{
|
||||||
localizedMessage: ERROR_MESSAGE[result.reason],
|
validationAdd: {
|
||||||
target: "cart",
|
errors: result.valid ? [] : [{ message: ERROR_MESSAGE[result.reason], target: "$.cart" }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
@ -1,11 +1,14 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Pure decision logic, kept separate from run.js so it can be unit-tested
|
// Pure decision logic, kept separate from the run.js adapter so it can be
|
||||||
// directly with plain Vitest — no WASM build or function-runner needed to
|
// unit-tested directly with plain Vitest (see
|
||||||
// verify the actual rule. This is the enforcement half of the "widget
|
// tests/unit/validation-slot.test.ts at the repo root) as well as through
|
||||||
// collects, Function enforces" split (CLAUDE.md non-negotiable): a shopper
|
// the real function-runner fixtures in tests/fixtures/ here.
|
||||||
// who clears the cart attribute, calls the cart API directly, or otherwise
|
//
|
||||||
// bypasses the storefront widget still cannot complete checkout, because
|
// This is the enforcement half of the "widget collects, Function enforces"
|
||||||
// this runs server-side inside Shopify's own checkout, not in the browser.
|
// split (CLAUDE.md non-negotiable): a shopper who clears the cart
|
||||||
|
// attribute, calls the cart API directly, or otherwise bypasses the
|
||||||
|
// storefront widget still cannot complete checkout, because this runs
|
||||||
|
// server-side inside Shopify's own checkout, not in the browser.
|
||||||
//
|
//
|
||||||
// Scope note (Phase 4): every order on shops that activate this Function is
|
// Scope note (Phase 4): every order on shops that activate this Function is
|
||||||
// currently treated as requiring a schedule selection — there's no
|
// currently treated as requiring a schedule selection — there's no
|
||||||
@ -17,8 +20,8 @@
|
|||||||
// capacity snapshot at the moment checkout completes ("has since been
|
// capacity snapshot at the moment checkout completes ("has since been
|
||||||
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
|
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
|
||||||
// building a metafield-snapshot refresh pipeline for that is real,
|
// building a metafield-snapshot refresh pipeline for that is real,
|
||||||
// unscoped work. The 10-minute hold TTL (holds.server.ts) is the mitigation
|
// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is
|
||||||
// for that specific race in the meantime.
|
// the mitigation for that specific race in the meantime.
|
||||||
|
|
||||||
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
||||||
|
|
||||||
|
|||||||
1
extensions/validation-slot/src/index.js
Normal file
1
extensions/validation-slot/src/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cart_validations_generate_run';
|
||||||
45
extensions/validation-slot/tests/default.test.js
Normal file
45
extensions/validation-slot/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import { describe, beforeAll, test, expect } from "vitest";
|
||||||
|
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||||
|
|
||||||
|
describe("Default Integration Test", () => {
|
||||||
|
let schema;
|
||||||
|
let functionDir;
|
||||||
|
let functionInfo;
|
||||||
|
let schemaPath;
|
||||||
|
let targeting;
|
||||||
|
let functionRunnerPath;
|
||||||
|
let wasmPath;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
functionDir = path.dirname(__dirname);
|
||||||
|
await buildFunction(functionDir);
|
||||||
|
functionInfo = await getFunctionInfo(functionDir);
|
||||||
|
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||||
|
schema = await loadSchema(schemaPath);
|
||||||
|
}, 45000);
|
||||||
|
|
||||||
|
const fixturesDir = path.join(__dirname, "fixtures");
|
||||||
|
const fixtureFiles = fs
|
||||||
|
.readdirSync(fixturesDir)
|
||||||
|
.filter((file) => file.endsWith(".json"))
|
||||||
|
.map((file) => path.join(fixturesDir, file));
|
||||||
|
|
||||||
|
fixtureFiles.forEach((fixtureFile) => {
|
||||||
|
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||||
|
const fixture = await loadFixture(fixtureFile);
|
||||||
|
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||||
|
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||||
|
|
||||||
|
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||||
|
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||||
|
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||||
|
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||||
|
|
||||||
|
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||||
|
expect(runResult.error).toBeNull();
|
||||||
|
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddStartMin": { "value": "540" },
|
||||||
|
"ddEndMin": { "value": "600" },
|
||||||
|
"ddLocationId": { "value": "loc_123" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": { "value": "PICKUP" },
|
||||||
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddStartMin": null,
|
||||||
|
"ddEndMin": null,
|
||||||
|
"ddLocationId": { "value": "loc_123" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "Your delivery date/time selection is incomplete — please choose it again.",
|
||||||
|
"target": "$.cart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"payload": {
|
||||||
|
"export": "cart-validations-generate-run",
|
||||||
|
"target": "cart.validations.generate.run",
|
||||||
|
"input": {
|
||||||
|
"cart": {
|
||||||
|
"ddMethod": null,
|
||||||
|
"ddDate": null,
|
||||||
|
"ddStartMin": null,
|
||||||
|
"ddEndMin": null,
|
||||||
|
"ddLocationId": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"validationAdd": {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "Please choose a delivery date and time before checking out.",
|
||||||
|
"target": "$.cart"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/validation-slot/vite.config.js
Normal file
1
extensions/validation-slot/vite.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Prevents inheritance from parent Remix project
|
||||||
8
extensions/validation-slot/vitest.config.js
Normal file
8
extensions/validation-slot/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
test: {
|
||||||
|
forceRerunTriggers: [
|
||||||
|
'**/tests/fixtures/**',
|
||||||
|
'**/src/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
1083
package-lock.json
generated
1083
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,10 @@
|
|||||||
"docker-start": "npm run setup && npm run start",
|
"docker-start": "npm run setup && npm run start",
|
||||||
"setup": "prisma generate && prisma migrate deploy",
|
"setup": "prisma generate && prisma migrate deploy",
|
||||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
||||||
|
"pretypecheck": "npm run typegen:functions",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
|||||||
@ -1,25 +1,19 @@
|
|||||||
# This file stores configurations for your Shopify app.
|
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
|
|
||||||
|
|
||||||
client_id = ""
|
client_id = "890f611da9f31c1a8e3183b5300b2f53"
|
||||||
name = "delivery-datetime-app"
|
name = "Metatron-delivery"
|
||||||
application_url = "https://replace-with-your-tunnel-url.example.com"
|
application_url = "https://shopify.dev/apps/default-app-home"
|
||||||
embedded = true
|
embedded = true
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||||
# feature in IMPLEMENTATION_PLAN.md actually needs it.
|
scopes = "read_customers,read_locales,read_locations,read_markets,read_metaobjects,read_orders,read_products,write_cart_transforms,write_delivery_customizations,write_metaobjects,write_orders,write_payment_customizations"
|
||||||
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
|
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
||||||
"https://replace-with-your-tunnel-url.example.com/auth/callback",
|
|
||||||
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
|
|
||||||
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
|
|
||||||
]
|
|
||||||
|
|
||||||
[webhooks]
|
[webhooks]
|
||||||
api_version = "2024-10"
|
api_version = "2026-10"
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
@ -31,17 +25,17 @@ api_version = "2024-10"
|
|||||||
uri = "/webhooks/app/scopes_update"
|
uri = "/webhooks/app/scopes_update"
|
||||||
topics = ["app/scopes_update"]
|
topics = ["app/scopes_update"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.create.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/create"
|
uri = "/webhooks/orders/create"
|
||||||
topics = ["orders/create"]
|
topics = ["orders/create"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.updated.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/updated"
|
uri = "/webhooks/orders/updated"
|
||||||
topics = ["orders/updated"]
|
topics = ["orders/updated"]
|
||||||
|
|
||||||
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
|
# Handled by: app/routes/webhooks.orders.cancelled.tsx
|
||||||
[[webhooks.subscriptions]]
|
[[webhooks.subscriptions]]
|
||||||
uri = "/webhooks/orders/cancelled"
|
uri = "/webhooks/orders/cancelled"
|
||||||
topics = ["orders/cancelled"]
|
topics = ["orders/cancelled"]
|
||||||
@ -63,7 +57,12 @@ api_version = "2024-10"
|
|||||||
compliance_topics = ["shop/redact"]
|
compliance_topics = ["shop/redact"]
|
||||||
|
|
||||||
# App proxy so the storefront Theme App Extension can call our backend
|
# App proxy so the storefront Theme App Extension can call our backend
|
||||||
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3).
|
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
||||||
|
# points this at your dev tunnel automatically when
|
||||||
|
# automatically_update_urls_on_dev is true (see [build] below); if it
|
||||||
|
# doesn't, set it manually to `<your-tunnel-url>/apps/scheduling` — the
|
||||||
|
# Remix routes it forwards to (apps.scheduling.*.tsx) assume that exact
|
||||||
|
# prefix.
|
||||||
[app_proxy]
|
[app_proxy]
|
||||||
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
||||||
subpath = "scheduling"
|
subpath = "scheduling"
|
||||||
@ -71,3 +70,4 @@ prefix = "apps"
|
|||||||
|
|
||||||
[build]
|
[build]
|
||||||
include_config_on_deploy = true
|
include_config_on_deploy = true
|
||||||
|
automatically_update_urls_on_dev = true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user