1- import { afterEach , describe , expect , it } from "vitest" ;
2- import { HandoffCheckpointTracker } from "./handoff-checkpoint" ;
1+ import { afterEach , describe , expect , it , vi } from "vitest" ;
2+ import {
3+ decodeHandoffArtifact ,
4+ HandoffCheckpointTracker ,
5+ } from "./handoff-checkpoint" ;
36import {
47 cloneTestRepo ,
58 createTestRepo ,
@@ -8,7 +11,7 @@ import {
811import type { HandoffLocalGitState } from "./types" ;
912
1013interface BundleStore {
11- artifacts : Record < string , string > ;
14+ artifacts : Record < string , Buffer > ;
1215 storagePath : string ;
1316 manifest : Array < { storage_path : string } > ;
1417}
@@ -20,19 +23,31 @@ interface HandoffRepos {
2023 localGitState : HandoffLocalGitState ;
2124}
2225
23- function createMockApi ( store : BundleStore ) {
24- return {
26+ interface MockApiOptions {
27+ /** Store inline uploads as base64 text, like the backend did before content_encoding was sent. */
28+ legacyBase64AtRest ?: boolean ;
29+ /** Expose the prepare/finalize direct-upload endpoints. */
30+ directUploads ?: boolean ;
31+ }
32+
33+ function createMockApi ( store : BundleStore , options ?: MockApiOptions ) {
34+ let nextId = 0 ;
35+ const api : Record < string , unknown > = {
2536 uploadTaskArtifacts : async (
2637 _taskId : string ,
2738 _runId : string ,
2839 artifacts : Array < {
2940 name : string ;
3041 content : string ;
42+ content_encoding ?: string ;
3143 } > ,
3244 ) => {
33- const uploaded = artifacts . map ( ( artifact , index ) => {
34- const storagePath = `${ store . storagePath } -${ store . manifest . length + index } -${ artifact . name } ` ;
35- store . artifacts [ storagePath ] = artifact . content ;
45+ const uploaded = artifacts . map ( ( artifact ) => {
46+ const storagePath = `${ store . storagePath } -${ nextId ++ } -${ artifact . name } ` ;
47+ store . artifacts [ storagePath ] =
48+ ! options ?. legacyBase64AtRest && artifact . content_encoding === "base64"
49+ ? Buffer . from ( artifact . content , "base64" )
50+ : Buffer . from ( artifact . content , "utf-8" ) ;
3651 return { storage_path : storagePath } ;
3752 } ) ;
3853 for ( const entry of uploaded ) {
@@ -45,15 +60,64 @@ function createMockApi(store: BundleStore) {
4560 _runId : string ,
4661 artifactPath : string ,
4762 ) => {
48- const contentBase64 = store . artifacts [ artifactPath ] ;
49- if ( ! contentBase64 ) return null ;
50- const buffer = Buffer . from ( contentBase64 , "utf-8" ) ;
51- return buffer . buffer . slice (
52- buffer . byteOffset ,
53- buffer . byteOffset + buffer . byteLength ,
63+ const content = store . artifacts [ artifactPath ] ;
64+ if ( ! content ) return null ;
65+ return content . buffer . slice (
66+ content . byteOffset ,
67+ content . byteOffset + content . byteLength ,
5468 ) ;
5569 } ,
5670 } ;
71+
72+ if ( options ?. directUploads ) {
73+ api . prepareTaskArtifactUploads = async (
74+ _taskId : string ,
75+ _runId : string ,
76+ artifacts : Array < { name : string ; type : string ; size : number } > ,
77+ ) =>
78+ artifacts . map ( ( artifact ) => {
79+ const storagePath = `${ store . storagePath } -${ nextId ++ } -${ artifact . name } ` ;
80+ return {
81+ id : `prepared-${ storagePath } ` ,
82+ name : artifact . name ,
83+ type : artifact . type ,
84+ size : artifact . size ,
85+ storage_path : storagePath ,
86+ expires_in : 300 ,
87+ presigned_post : {
88+ url : "https://object-storage.test/upload" ,
89+ fields : { key : storagePath } ,
90+ } ,
91+ } ;
92+ } ) ;
93+ api . finalizeTaskArtifactUploads = async (
94+ _taskId : string ,
95+ _runId : string ,
96+ artifacts : Array < { name : string ; type : string ; storage_path : string } > ,
97+ ) => {
98+ const finalized = artifacts . map ( ( artifact ) => ( {
99+ name : artifact . name ,
100+ type : artifact . type ,
101+ storage_path : artifact . storage_path ,
102+ } ) ) ;
103+ for ( const entry of finalized ) {
104+ store . manifest . push ( entry ) ;
105+ }
106+ return finalized ;
107+ } ;
108+ }
109+
110+ return api ;
111+ }
112+
113+ function stubPresignedUploadFetch ( store : BundleStore ) : void {
114+ vi . stubGlobal ( "fetch" , async ( _url : string , init ?: { body ?: unknown } ) => {
115+ const form = init ?. body as FormData ;
116+ const key = form . get ( "key" ) as string ;
117+ const file = form . get ( "file" ) as Blob ;
118+ store . artifacts [ key ] = Buffer . from ( await file . arrayBuffer ( ) ) ;
119+ return new Response ( null , { status : 204 } ) ;
120+ } ) ;
57121}
58122
59123function createBundleStore ( ) : BundleStore {
@@ -130,6 +194,7 @@ describe("HandoffCheckpointTracker", () => {
130194 const cleanups : Array < ( ) => Promise < void > > = [ ] ;
131195
132196 afterEach ( async ( ) => {
197+ vi . unstubAllGlobals ( ) ;
133198 await Promise . all ( cleanups . splice ( 0 ) . map ( ( cleanup ) => cleanup ( ) ) ) ;
134199 } ) ;
135200
@@ -166,4 +231,89 @@ describe("HandoffCheckpointTracker", () => {
166231 expect ( status ) . toContain ( "?? untracked.txt" ) ;
167232 expect ( localRepo . exists ( ".posthog/tmp" ) ) . toBe ( false ) ;
168233 } ) ;
234+
235+ it ( "round-trips a cloud capture without local git state via direct-to-storage uploads" , async ( ) => {
236+ const originRepo = await createTestRepo ( "handoff-origin" ) ;
237+ cleanups . push ( originRepo . cleanup ) ;
238+ await seedCloudRepo ( originRepo ) ;
239+
240+ const sandboxRepo = await cloneTestRepo ( originRepo . path , "handoff-sandbox" ) ;
241+ cleanups . push ( sandboxRepo . cleanup ) ;
242+ const resumeRepo = await cloneTestRepo ( originRepo . path , "handoff-resume" ) ;
243+ cleanups . push ( resumeRepo . cleanup ) ;
244+
245+ await makeCloudChanges ( sandboxRepo ) ;
246+
247+ const store = createBundleStore ( ) ;
248+ const apiClient = createMockApi ( store , { directUploads : true } ) ;
249+ stubPresignedUploadFetch ( store ) ;
250+
251+ const captureTracker = createTracker ( sandboxRepo . path , apiClient ) ;
252+ const checkpoint = await captureTracker . captureForHandoff ( ) ;
253+
254+ expect ( checkpoint ) . not . toBeNull ( ) ;
255+ if ( ! checkpoint ) return ;
256+ expect ( checkpoint . artifactPath ) . toBeDefined ( ) ;
257+ expect ( checkpoint . indexArtifactPath ) . toBeDefined ( ) ;
258+
259+ // Direct uploads store raw bytes, not base64 text.
260+ const pack = store . artifacts [ checkpoint . artifactPath as string ] ;
261+ expect ( pack . subarray ( 0 , 4 ) . toString ( "utf-8" ) ) . toBe ( "PACK" ) ;
262+
263+ const applyTracker = createTracker ( resumeRepo . path , apiClient ) ;
264+ await applyTracker . applyFromHandoff ( checkpoint ) ;
265+
266+ expect ( await resumeRepo . git ( [ "rev-parse" , "HEAD" ] ) ) . toBe ( checkpoint . head ) ;
267+ expect ( await resumeRepo . readFile ( "committed.txt" ) ) . toBe ( "cloud commit\n" ) ;
268+ expect ( await resumeRepo . readFile ( "tracked.txt" ) ) . toBe ( "staged change\n" ) ;
269+ expect ( await resumeRepo . readFile ( "unstaged.txt" ) ) . toBe ( "unstaged change\n" ) ;
270+ expect ( await resumeRepo . readFile ( "untracked.txt" ) ) . toBe ( "untracked\n" ) ;
271+
272+ const status = await resumeRepo . git ( [ "status" , "--porcelain" ] ) ;
273+ expect ( status ) . toContain ( "M tracked.txt" ) ;
274+ expect ( status ) . toContain ( " M unstaged.txt" ) ;
275+ expect ( status ) . toContain ( "?? untracked.txt" ) ;
276+ } ) ;
277+
278+ it ( "applies checkpoints whose artifacts are stored as legacy base64 text" , async ( ) => {
279+ const { cloudRepo, localRepo, localGitState } =
280+ await prepareHandoffRepos ( cleanups ) ;
281+ await makeCloudChanges ( cloudRepo ) ;
282+
283+ const store = createBundleStore ( ) ;
284+ const apiClient = createMockApi ( store , { legacyBase64AtRest : true } ) ;
285+ const captureTracker = createTracker ( cloudRepo . path , apiClient ) ;
286+
287+ const checkpoint = await captureTracker . captureForHandoff ( localGitState ) ;
288+ expect ( checkpoint ) . not . toBeNull ( ) ;
289+ if ( ! checkpoint ) return ;
290+
291+ // Sanity-check the fixture: artifacts at rest are base64 text, not raw bytes.
292+ const pack = store . artifacts [ checkpoint . artifactPath as string ] ;
293+ expect ( pack . subarray ( 0 , 4 ) . toString ( "utf-8" ) ) . not . toBe ( "PACK" ) ;
294+
295+ const applyTracker = createTracker ( localRepo . path , apiClient ) ;
296+ await applyTracker . applyFromHandoff ( checkpoint ) ;
297+
298+ expect ( await localRepo . git ( [ "rev-parse" , "HEAD" ] ) ) . toBe ( checkpoint . head ) ;
299+ expect ( await localRepo . readFile ( "committed.txt" ) ) . toBe ( "cloud commit\n" ) ;
300+ expect ( await localRepo . readFile ( "tracked.txt" ) ) . toBe ( "staged change\n" ) ;
301+ } ) ;
302+
303+ it ( "decodes raw and legacy base64 artifact buffers" , ( ) => {
304+ const rawPack = Buffer . concat ( [
305+ Buffer . from ( "PACK" ) ,
306+ Buffer . from ( [ 0 , 0 , 0 , 2 , 255 , 1 , 2 , 3 ] ) ,
307+ ] ) ;
308+ expect ( decodeHandoffArtifact ( rawPack ) ) . toEqual ( rawPack ) ;
309+
310+ const rawIndex = Buffer . concat ( [
311+ Buffer . from ( "DIRC" ) ,
312+ Buffer . from ( [ 0 , 0 , 0 , 2 , 255 , 4 , 5 , 6 ] ) ,
313+ ] ) ;
314+ expect ( decodeHandoffArtifact ( rawIndex ) ) . toEqual ( rawIndex ) ;
315+
316+ const legacyBase64 = Buffer . from ( rawPack . toString ( "base64" ) , "utf-8" ) ;
317+ expect ( decodeHandoffArtifact ( legacyBase64 ) ) . toEqual ( rawPack ) ;
318+ } ) ;
169319} ) ;
0 commit comments