Skip to content

Commit 1c036f3

Browse files
MajorTalclaude
andcommitted
test(fullstack): add JPEG + HEIC image-variant e2e parity tests
Implements the deferred 9.1 and 9.2 tasks from the asset-image-variants-client openspec change. Two new fullstack integration tests, plus 640x480 JPEG and HEIC fixtures (generated via ffmpeg testsrc + sips). Both tests assert the actual contract that holds today: - Source bytes preserved verbatim and served at cdn_url with the correct Content-Type (image/jpeg or image/heic). - AssetRef shape is identical across r.assets.put and r.project(id).apply (parity contract). - When variant fields ARE populated (forward-compatible branch), the deterministic per-source-SHA variant content matches across paths. Gateway-side gap discovered: the wallet-apply plan-response builds asset_ref WITHOUT the image: AssetImageData arg at packages/gateway/src/services/apply-v1.ts:2900, so variants are generated at commit time but never surfaced to the API consumer for either write path. Both r.assets.put (v2.1+ routes through apply) and r.project(id).apply land in the no-variants branch today. The tests will automatically pick up the future state when the gateway threads image_data through to the plan-response builder. Also drops a stale before-hook check for functions/dist/index.js (@run402/functions source migrated to the private gateway monorepo in commit bb1b5f2; the gateway bundles its own copy via esbuild alias). Closes asset-image-variants-client tasks 9.1, 9.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb45697 commit 1c036f3

3 files changed

Lines changed: 223 additions & 1 deletion

File tree

fullstack-integration.test.ts

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,10 +367,13 @@ async function removeBlobKey(key: string): Promise<void> {
367367
}
368368

369369
before(async () => {
370+
// `@run402/functions` source moved to the private gateway monorepo
371+
// (commit bb1b5f2 — gateway bundles its own copy via esbuild alias at
372+
// deploy time). The fixture functions still import from
373+
// `@run402/functions`, but that's resolved gateway-side, not locally.
370374
const requiredBuilds = [
371375
join(ROOT, "core", "dist", "config.js"),
372376
join(ROOT, "sdk", "core-dist", "config.js"),
373-
join(ROOT, "functions", "dist", "index.js"),
374377
];
375378
const missingBuilds = requiredBuilds.filter((path) => !existsSync(path));
376379
if (missingBuilds.length > 0) {
@@ -726,6 +729,225 @@ describe("Run402 full-stack integration (live API, no mocks)", { timeout: 900_00
726729
assert.match(await fetchTextOk(v1Url), /retention v1 marker/);
727730
});
728731

732+
it("(v1.49 image variants) JPEG upload preserves source bytes and parity holds across write paths", async () => {
733+
// Asserts the v2.3 client surface against the v1.49 gateway encoder.
734+
// Uploads the same JPEG bytes via two write paths and asserts:
735+
// 1. Source bytes preserved verbatim and served correctly (always)
736+
// 2. AssetRef SHAPE is identical across paths (variants present XOR
737+
// absent on both — parity contract)
738+
// 3. If variant fields are populated, the deterministic per-SHA
739+
// variant content matches across paths
740+
//
741+
// KNOWN GATEWAY-SIDE GAP (as of v1.49): the plan-response builder
742+
// (`buildAssetRefForPlan` at
743+
// packages/gateway/src/services/apply-v1.ts:2900) is called WITHOUT
744+
// the `image: AssetImageData` argument for the wallet-apply route, so
745+
// the response's `asset_ref` envelope omits variant fields even
746+
// though variants ARE generated and stored at commit time. r.assets.put
747+
// (v2.1+) and r.project(id).apply BOTH route through the wallet-apply
748+
// hero, so neither surfaces variants today. The service-key path
749+
// (POST /apply/v1/service-asset-put), used by @run402/functions,
750+
// does surface them — that exit point is exercised by the gateway-
751+
// side test suite in run402-private.
752+
//
753+
// This test asserts parity (which IS guaranteed by sharing the path)
754+
// and forward-compatibility: when the gateway threads image_data
755+
// through to the plan response, the asserts in the `if (haveVariants)`
756+
// branch will kick in automatically.
757+
const FIXTURES = join(ROOT, "integration-fixtures", "image-variants");
758+
const jpegBytes = new Uint8Array(readFileSync(join(FIXTURES, "source.jpg")));
759+
const putKey = `fullstack/image-variants/put-${RUN_ID}.jpg`;
760+
const applyKey = `fullstack/image-variants/apply-${RUN_ID}.jpg`;
761+
createdBlobKeys.add(putKey);
762+
createdBlobKeys.add(applyKey);
763+
764+
// Path A: single-shot r.assets.put.
765+
const putRef = await r.assets.put(projectId, putKey, { bytes: jpegBytes }, {
766+
contentType: "image/jpeg",
767+
});
768+
769+
// Unconditional: source bytes preserved verbatim. cdn_url (or its
770+
// immutable form) serves the JPEG source.
771+
const putCdnUrl = putRef.cdnUrl ?? putRef.cdn_immutable_url;
772+
assert.ok(putCdnUrl, "JPEG upload must return a cdn_url");
773+
await eventually("JPEG source served at cdn_url", async () => {
774+
const res = await fetch(putCdnUrl, { headers: { "cache-control": "no-cache" } });
775+
assert.equal(res.status, 200);
776+
const ctype = res.headers.get("content-type") ?? "";
777+
assert.ok(ctype.includes("image/jpeg"), `cdn_url should serve image/jpeg, got '${ctype}'`);
778+
});
779+
780+
// Path B: wallet-apply via r.project(id).apply.
781+
// resolveContent (the apply-path source resolver) accepts bare
782+
// Uint8Array, not the { bytes } wrapper that r.assets.put normalizes.
783+
const project = await r.project(projectId);
784+
const applyResult = await project.apply({
785+
assets: {
786+
put: [{ key: applyKey, source: jpegBytes, content_type: "image/jpeg" }],
787+
},
788+
});
789+
const applyRef = applyResult.assets?.byKey[applyKey];
790+
assert.ok(applyRef, "apply path must return a populated AssetManifestEntry for the key");
791+
792+
// Parity contract: both paths produce the same shape. If variants
793+
// are present on one, they must be present on the other (and vice
794+
// versa). This is guaranteed today because both paths route through
795+
// the wallet-apply hero, but the test pins the contract.
796+
const putHasVariants = Boolean(putRef.variants?.thumb);
797+
const applyHasVariants = Boolean(applyRef.variants?.thumb);
798+
assert.equal(
799+
applyHasVariants,
800+
putHasVariants,
801+
"variant presence must be identical across write paths (parity contract)",
802+
);
803+
804+
if (putHasVariants && applyHasVariants) {
805+
// Forward-compatible branch: enabled when the gateway threads
806+
// image_data into the plan response. Today these asserts are
807+
// skipped because both refs land in the else branch.
808+
assert.equal(putRef.variant_spec_version, "v1");
809+
assert.equal(applyRef.variant_spec_version, "v1");
810+
assert.equal(putRef.width_px, 640);
811+
assert.equal(putRef.height_px, 480);
812+
assert.equal(applyRef.width_px, putRef.width_px);
813+
assert.equal(applyRef.height_px, putRef.height_px);
814+
assert.ok(typeof putRef.blurhash === "string" && putRef.blurhash.length > 0);
815+
assert.equal(applyRef.blurhash, putRef.blurhash);
816+
assert.equal(putRef.variants!.thumb!.format, "webp");
817+
assert.equal(putRef.variants!.medium!.format, "webp");
818+
assert.equal(putRef.variants!.large!.format, "webp");
819+
// Deterministic variant SHAs verify the encoder isn't double-running.
820+
assert.equal(applyRef.variants!.thumb!.sha256, putRef.variants!.thumb!.sha256);
821+
assert.equal(applyRef.variants!.medium!.sha256, putRef.variants!.medium!.sha256);
822+
assert.equal(applyRef.variants!.large!.sha256, putRef.variants!.large!.sha256);
823+
// SDK convenience getters.
824+
assert.equal(putRef.thumbUrl, putRef.variants!.thumb!.cdn_url);
825+
assert.equal(putRef.displayUrl, putRef.display_url);
826+
// Non-HEIC: display_url should equal cdn_url.
827+
assert.equal(putRef.display_url, putRef.cdnUrl ?? putRef.cdn_immutable_url ?? undefined);
828+
// The variant URL is actually served by the CDN.
829+
await eventually("thumb cdn fetch", async () => {
830+
const res = await fetch(putRef.variants!.thumb!.cdn_url, { headers: { "cache-control": "no-cache" } });
831+
assert.equal(res.status, 200, `thumb cdn_url returned ${res.status}`);
832+
const ctype = res.headers.get("content-type") ?? "";
833+
assert.ok(ctype.includes("image/webp"), `thumb Content-Type should be image/webp, got '${ctype}'`);
834+
});
835+
} else {
836+
// Current production reality: gateway emits variants at commit
837+
// time but doesn't surface them in the plan-response asset_ref
838+
// for the wallet-apply route. Both paths land here.
839+
assert.equal(putRef.variants, undefined);
840+
assert.equal(applyRef.variants, undefined);
841+
assert.equal(applyRef.variant_spec_version, putRef.variant_spec_version);
842+
assert.equal(applyRef.width_px, putRef.width_px);
843+
assert.equal(applyRef.height_px, putRef.height_px);
844+
assert.equal(applyRef.blurhash, putRef.blurhash);
845+
}
846+
});
847+
848+
it("(v1.49 image variants) HEIC upload preserves source bytes and parity holds across write paths", async () => {
849+
// HEIC source: cdn_url MUST serve the original HEIC bytes verbatim
850+
// (audit-friendly; uploaded SHA == stored SHA). This is the
851+
// unconditional contract.
852+
//
853+
// Variant generation for HEIC is currently gated on the gateway's
854+
// sharp+libheif decoder reaching musl Alpine; today (parent change
855+
// task 1.2) the encoder catches HEIC pixel-decode failures and
856+
// returns a source-only EncodedVariantSet (no blurhash, no
857+
// variants). So the post-source assertions are conditional:
858+
// either BOTH paths populate variants (future state when HEIC decoder
859+
// ships) OR BOTH paths return the absent-variants shape (today).
860+
// Either way, parity across write paths must hold.
861+
const FIXTURES = join(ROOT, "integration-fixtures", "image-variants");
862+
const heicBytes = new Uint8Array(readFileSync(join(FIXTURES, "source.heic")));
863+
const putKey = `fullstack/image-variants/put-${RUN_ID}.heic`;
864+
const applyKey = `fullstack/image-variants/apply-${RUN_ID}.heic`;
865+
createdBlobKeys.add(putKey);
866+
createdBlobKeys.add(applyKey);
867+
868+
const putRef = await r.assets.put(projectId, putKey, { bytes: heicBytes }, {
869+
contentType: "image/heic",
870+
});
871+
872+
// Unconditional: HEIC source bytes preserved verbatim and served at
873+
// cdn_url with image/heic content type. This is the platform's
874+
// byte-faithful CAS contract and must hold regardless of whether
875+
// variant generation succeeded.
876+
await eventually("HEIC source served at cdn_url", async () => {
877+
const cdnUrl = putRef.cdnUrl ?? putRef.cdn_immutable_url;
878+
assert.ok(cdnUrl, "HEIC upload must return a cdn_url");
879+
const res = await fetch(cdnUrl, { headers: { "cache-control": "no-cache" } });
880+
assert.equal(res.status, 200);
881+
const ctype = res.headers.get("content-type") ?? "";
882+
assert.ok(
883+
ctype.includes("image/heic") || ctype.includes("image/heif"),
884+
`HEIC cdn_url should serve image/heic, got '${ctype}'`,
885+
);
886+
});
887+
888+
// Wallet-apply path parity: same bytes via project.apply must
889+
// produce a manifest entry, and the variant-shape (present or
890+
// absent) must match across paths. resolveContent accepts bare
891+
// Uint8Array, not the { bytes } wrapper.
892+
const project = await r.project(projectId);
893+
const applyResult = await project.apply({
894+
assets: {
895+
put: [{ key: applyKey, source: heicBytes, content_type: "image/heic" }],
896+
},
897+
});
898+
const applyRef = applyResult.assets?.byKey[applyKey];
899+
assert.ok(applyRef, "wallet-apply must return a manifest entry for the HEIC upload");
900+
901+
const putHasVariants = Boolean(putRef.variants?.display_jpeg);
902+
const applyHasVariants = Boolean(applyRef.variants?.display_jpeg);
903+
assert.equal(
904+
applyHasVariants,
905+
putHasVariants,
906+
"HEIC variant presence must be identical across write paths (parity contract)",
907+
);
908+
909+
if (putHasVariants) {
910+
// Future state: gateway's HEIC decoder works on Alpine.
911+
// Assert the full HEIC variant set + parity.
912+
assert.equal(putRef.variant_spec_version, "v1");
913+
assert.equal(applyRef.variant_spec_version, "v1");
914+
assert.ok(typeof putRef.blurhash === "string" && putRef.blurhash.length > 0);
915+
assert.equal(applyRef.blurhash, putRef.blurhash);
916+
assert.equal(applyRef.width_px, putRef.width_px);
917+
assert.equal(applyRef.height_px, putRef.height_px);
918+
assert.equal(putRef.variants!.display_jpeg!.format, "jpeg");
919+
assert.equal(applyRef.variants!.display_jpeg!.sha256, putRef.variants!.display_jpeg!.sha256);
920+
// display_url must differ from cdn_url for HEIC (cdn_url serves
921+
// unrenderable HEIC bytes; display_url is the JPEG transcode).
922+
assert.notEqual(
923+
putRef.display_url,
924+
putRef.cdnUrl ?? putRef.cdn_immutable_url,
925+
"HEIC display_url must NOT equal cdn_url",
926+
);
927+
// Verify the JPEG display variant renders.
928+
await eventually("HEIC display_url serves JPEG", async () => {
929+
const res = await fetch(putRef.display_url!, { headers: { "cache-control": "no-cache" } });
930+
assert.equal(res.status, 200);
931+
const ctype = res.headers.get("content-type") ?? "";
932+
assert.ok(ctype.includes("image/jpeg"), `HEIC display_url should serve image/jpeg, got '${ctype}'`);
933+
});
934+
} else {
935+
// Current production reality (parent change task 1.2): sharp +
936+
// libheif's HEVC decoder on musl Alpine can't decode HEIC pixel
937+
// data, so the encoder returns a source-only EncodedVariantSet.
938+
// Both paths must omit the image-variant fields identically.
939+
assert.equal(putRef.variants, undefined, "HEIC variants should be absent when decoder fails");
940+
assert.equal(applyRef.variants, undefined, "wallet-apply HEIC variants should also be absent");
941+
assert.equal(putRef.blurhash, undefined);
942+
assert.equal(applyRef.blurhash, undefined);
943+
// Both paths should agree on the absence of width_px / height_px
944+
// / display_url too.
945+
assert.equal(applyRef.width_px, putRef.width_px);
946+
assert.equal(applyRef.height_px, putRef.height_px);
947+
assert.equal(applyRef.display_url, putRef.display_url);
948+
}
949+
});
950+
729951
it("observes runtime secrets and exercises email plus AI helper paths", async () => {
730952
const secret = await directFunctionJson({
731953
headers: apiHeaders("service"),
5.13 KB
Binary file not shown.
16.4 KB
Loading

0 commit comments

Comments
 (0)