Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {DevSessionEventLog} from './dev-session-event-log.js'
import {inTemporaryDirectory, readFile, fileExists, rmdir} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {describe, test, expect} from 'vitest'

describe('DevSessionEventLog', () => {
test('init creates the event log file', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
await eventLog.init()

const exists = await fileExists(joinPath(tmpDir, '.shopify', 'dev-session-events.jsonl'))
expect(exists).toBe(true)
})
})

test('init truncates existing file', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
await eventLog.init()
await eventLog.write({event: 'test-event'})

// Re-init should truncate
await eventLog.init()
const content = await readFile(eventLog.path)
expect(content).toBe('')
})
})

test('write appends JSONL lines', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
await eventLog.init()

await eventLog.write({event: 'first'})
await eventLog.write({event: 'second', extra: 'data'})

const content = await readFile(eventLog.path)
const lines = content.trim().split('\n')
expect(lines).toHaveLength(2)

const first = JSON.parse(lines[0]!)
expect(first.event).toBe('first')
expect(first.ts).toBeDefined()

const second = JSON.parse(lines[1]!)
expect(second.event).toBe('second')
expect(second.extra).toBe('data')
})
})

test('write is a no-op before init', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)

// Should not throw
await eventLog.write({event: 'ignored'})

const exists = await fileExists(joinPath(tmpDir, '.shopify', 'dev-session-events.jsonl'))
expect(exists).toBe(false)
})
})

test('path returns the expected file path', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
expect(eventLog.path).toBe(joinPath(tmpDir, '.shopify', 'dev-session-events.jsonl'))
})
})

test('concurrent writes produce valid JSONL', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
await eventLog.init()

await Promise.all([
eventLog.write({event: 'event1', data: 'a'}),
eventLog.write({event: 'event2', data: 'b'}),
eventLog.write({event: 'event3', data: 'c'}),
])

const content = await readFile(eventLog.path)
const lines = content.trim().split('\n')
expect(lines).toHaveLength(3)
for (const line of lines) {
expect(() => JSON.parse(line)).not.toThrow()
}
})
})

test('write rejects when file is deleted', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const eventLog = new DevSessionEventLog(tmpDir)
await eventLog.init()

await rmdir(joinPath(tmpDir, '.shopify'), {force: true})

await expect(eventLog.write({event: 'test'})).rejects.toThrow()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {mkdir, writeFile, appendFile} from '@shopify/cli-kit/node/fs'
import {addToGitIgnore} from '@shopify/cli-kit/node/git'

export interface DevSessionEvent {
ts: string
event: string
[key: string]: unknown
}

/**
* Append-only JSONL event log for dev sessions.
* Enables external agents to observe dev session lifecycle by tailing
* `.shopify/dev-session-events.jsonl` in the app directory.
*
* Event types emitted:
* - `session-starting` — Dev session initialization (`store`, `app_id`, `extension_count`)
* - `session-created` — Session successfully created (`preview_url`, `graphiql_url`)
* - `session-updated` — Extensions updated (`extensions_updated`)
* - `session-start-failed` — Initial build errors prevented session creation (`reason`, `error_count`)
* - `change-detected` — File change detected (`extension_count`, `extensions[]` with `handle` and `type`)
* - `bundle-uploaded` — Extensions bundled and uploaded (`duration_ms`, `extensions`, `inherited_count`)
* - `status-loading`, `status-success`, `status-error` — Status transitions (`message`, `is_ready`, `preview_url`)
* - `remote-error`, `unknown-error` — Error events (`errors[]`)
*
* All events include a `ts` field with an ISO 8601 timestamp.
* The file is truncated on each `shopify app dev` start.
*/
export class DevSessionEventLog {
private readonly filePath: string
private readonly appDirectory: string
private initialized = false

constructor(appDirectory: string) {
this.appDirectory = appDirectory
this.filePath = joinPath(appDirectory, '.shopify', 'dev-session-events.jsonl')
}

async init(): Promise<void> {
await addToGitIgnore(this.appDirectory, '.shopify')
await mkdir(dirname(this.filePath))
await writeFile(this.filePath, '')
this.initialized = true
}

async write(event: Omit<DevSessionEvent, 'ts'>): Promise<void> {
if (!this.initialized) return
const line = JSON.stringify({ts: new Date().toISOString(), ...event})
await appendFile(this.filePath, `${line}\n`)
}

close(): void {
this.initialized = false
}

get path(): string {
return this.filePath
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {setupDevSessionProcess, pushUpdatesForDevSession} from './dev-session-process.js'
import {DevSessionEventLog} from './dev-session-event-log.js'
import {DevSessionStatusManager} from './dev-session-status-manager.js'
import {DeveloperPlatformClient} from '../../../../utilities/developer-platform-client.js'
import {AppLinkedInterface} from '../../../../models/app/app.js'
Expand Down Expand Up @@ -673,6 +674,27 @@ describe('pushUpdatesForDevSession', () => {
expect(stdout.write).not.toHaveBeenCalledWith(expect.stringContaining('taken ownership of this dev preview'))
})

test('writes lifecycle events to event log when provided', async () => {
// Given
const eventLog = {
write: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
} as unknown as DevSessionEventLog
options.eventLog = eventLog

// When
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, {...options, eventLog})
await appWatcher.start({stdout, stderr, signal: abortController.signal})
await flushPromises()

// Then - event log should have received lifecycle events during create
const writtenEvents = vi.mocked(eventLog.write).mock.calls.map((call) => call[0].event)
expect(writtenEvents).toContain('session-starting')
expect(writtenEvents).toContain('session-created')
// Status events from statusManager updates
expect(writtenEvents).toContain('status-success')
})

test('retries failed events along with newly received events', async () => {
vi.mocked(getUploadURL).mockResolvedValue('https://gcs.url')
vi.mocked(readdir).mockResolvedValue([])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {DevSessionEventLog} from './dev-session-event-log.js'
import {DevSessionStatusManager} from './dev-session-status-manager.js'
import {DevSession} from './dev-session.js'
import {BaseProcess, DevProcessFunction} from '../types.js'
Expand All @@ -17,6 +18,7 @@ export interface DevSessionProcessOptions {
appPreviewURL: string
appLocalProxyURL: string
devSessionStatusManager: DevSessionStatusManager
eventLog?: DevSessionEventLog
}

export interface DevSessionProcess extends BaseProcess<DevSessionProcessOptions> {
Expand All @@ -42,6 +44,9 @@ export async function setupDevSessionProcess({
}
}

export const pushUpdatesForDevSession: DevProcessFunction<DevSessionProcessOptions> = async ({stdout}, options) => {
await DevSession.start(options, stdout)
export const pushUpdatesForDevSession: DevProcessFunction<DevSessionProcessOptions> = async (
{stdout, abortSignal},
options,
) => {
await DevSession.start(options, stdout, abortSignal)
}
Loading
Loading