@@ -367,10 +367,13 @@ async function removeBlobKey(key: string): Promise<void> {
367367}
368368
369369before ( 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 ) , / r e t e n t i o n v 1 m a r k e r / ) ;
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" ) ,
0 commit comments