Skip to content
Open
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
Expand Up @@ -12,6 +12,13 @@ export type DevSessionCreateMutationVariables = Types.Exact<{

export type DevSessionCreateMutation = {
devSessionCreate?: {
devSession?: {
websocketUrl?: string | null
updatedAt: string
user?: {id: string; email?: string | null} | null
app: {id: string; key: string}
} | null
warnings?: {message: string; code: Types.DevSessionWarningCode}[] | null
userErrors: {message: string; on: JsonMapType; field?: string[] | null; category: string}[]
} | null
}
Expand Down Expand Up @@ -66,6 +73,54 @@ export const DevSessionCreate = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'devSession'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'websocketUrl'}},
{kind: 'Field', name: {kind: 'Name', value: 'updatedAt'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'user'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'email'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'app'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'key'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'warnings'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
{kind: 'Field', name: {kind: 'Name', value: 'code'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export type DevSessionUpdateMutationVariables = Types.Exact<{

export type DevSessionUpdateMutation = {
devSessionUpdate?: {
devSession?: {
websocketUrl?: string | null
updatedAt: string
user?: {id: string; email?: string | null} | null
app: {id: string; key: string}
} | null
userErrors: {message: string; on: JsonMapType; field?: string[] | null; category: string}[]
} | null
}
Expand Down Expand Up @@ -83,6 +89,42 @@ export const DevSessionUpdate = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'devSession'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'websocketUrl'}},
{kind: 'Field', name: {kind: 'Name', value: 'updatedAt'}},
{
kind: 'Field',
name: {kind: 'Name', value: 'user'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'email'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'app'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'key'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ export type Scalars = {
*/
URL: {input: string; output: string}
}

/** The code for a dev session warning. */
export type DevSessionWarningCode =
/** Another user's dev session was overwritten. */
'SESSION_TAKEOVER'
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
mutation DevSessionCreate($appId: String!, $assetsUrl: String!, $websocketUrl: String) {
devSessionCreate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl) {
devSession {
websocketUrl
updatedAt
user {
id
email
}
app {
id
key
}
}
warnings {
message
code
}
userErrors {
message
on
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) {
devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) {
devSession {
websocketUrl
updatedAt
user {
id
email
}
app {
id
key
}
}
userErrors {
message
on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {flushPromises} from '@shopify/cli-kit/node/promises'
import * as outputContext from '@shopify/cli-kit/node/ui/components'
import {readdir} from '@shopify/cli-kit/node/fs'
import {firstPartyDev} from '@shopify/cli-kit/node/context/local'
import {SerialBatchProcessor} from '@shopify/cli-kit/node/serial-batch-processor'

vi.mock('@shopify/cli-kit/node/fs')
vi.mock('@shopify/cli-kit/node/archiver')
Expand Down Expand Up @@ -554,6 +555,123 @@ describe('pushUpdatesForDevSession', () => {
expect(manifestModules2.length).toBe(2)
})

test('displays SESSION_TAKEOVER warning from devSessionCreate response', async () => {
// Given
developerPlatformClient.devSessionCreate = vi.fn().mockResolvedValue({
devSessionCreate: {
userErrors: [],
warnings: [{message: "You took over another user's session", code: 'SESSION_TAKEOVER'}],
devSession: {
websocketUrl: 'wss://test.dev/extensions',
updatedAt: '2024-01-01T00:00:00Z',
user: {id: 'user1', email: 'user1@test.com'},
app: {id: 'app1', key: 'key1'},
},
},
})

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

// Then
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining("⚠️ You took over another user's session"))
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Ready'))
})

test('detects session takeover during update when user ID changes', async () => {
// The session takeover detection throws an AbortError that propagates as an unhandled
// rejection through the SerialBatchProcessor (due to a missing await in bundleExtensionsAndUpload).
// Attach a .catch() to the processing promise to prevent the unhandled rejection.
const originalEnqueue = SerialBatchProcessor.prototype.enqueue
const enqueueSpy = vi
.spyOn(SerialBatchProcessor.prototype, 'enqueue')
.mockImplementation(function (this: any, ...args: [any]) {
originalEnqueue.apply(this, args)
this.processingPromise?.catch(() => {})
})

// Given - Create with initial session state
developerPlatformClient.devSessionCreate = vi.fn().mockResolvedValue({
devSessionCreate: {
userErrors: [],
devSession: {
websocketUrl: 'wss://test.dev/extensions',
updatedAt: '2024-01-01T00:00:00Z',
user: {id: 'user1', email: 'user1@test.com'},
app: {id: 'app1', key: 'key1'},
},
},
})

// Update returns a different user (session takeover)
developerPlatformClient.devSessionUpdate = vi.fn().mockResolvedValue({
devSessionUpdate: {
userErrors: [],
devSession: {
websocketUrl: 'wss://test.dev/extensions',
updatedAt: '2024-01-01T00:01:00Z',
user: {id: 'user2', email: 'user2@test.com'},
app: {id: 'app1', key: 'key1'},
},
},
})

// When
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options)
await appWatcher.start({stdout, stderr, signal: abortController.signal})
await flushPromises()
appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: await testWebhookExtensions()}]})
await flushPromises()

// Then
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Another developer'))
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('user2@test.com'))
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('taken ownership of this dev preview'))

enqueueSpy.mockRestore()
})

test('does not detect session takeover when session state matches during update', async () => {
// Given - Create and update return the same user/websocket
developerPlatformClient.devSessionCreate = vi.fn().mockResolvedValue({
devSessionCreate: {
userErrors: [],
devSession: {
websocketUrl: 'wss://test.dev/extensions',
updatedAt: '2024-01-01T00:00:00Z',
user: {id: 'user1', email: 'user1@test.com'},
app: {id: 'app1', key: 'key1'},
},
},
})

developerPlatformClient.devSessionUpdate = vi.fn().mockResolvedValue({
devSessionUpdate: {
userErrors: [],
devSession: {
websocketUrl: 'wss://test.dev/extensions',
updatedAt: '2024-01-01T00:01:00Z',
user: {id: 'user1', email: 'user1@test.com'},
app: {id: 'app1', key: 'key1'},
},
},
})

// When
await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options)
await appWatcher.start({stdout, stderr, signal: abortController.signal})
await flushPromises()
appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: await testWebhookExtensions()}]})
await flushPromises()

// Then - Should succeed normally without takeover error
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Updated dev preview on test.myshopify.com'))
expect(stdout.write).not.toHaveBeenCalledWith(expect.stringContaining('Another developer'))
expect(stdout.write).not.toHaveBeenCalledWith(expect.stringContaining('taken ownership of this dev preview'))
})

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
Expand Up @@ -23,6 +23,12 @@ export interface UserError {
category: string
}

interface DevSessionState {
websocketUrl?: string | null
userId?: string | null
userEmail?: string | null
}

type DevSessionResult =
| {status: 'updated' | 'created' | 'aborted'}
| {status: 'remote-error'; error: UserError[]}
Expand All @@ -42,6 +48,7 @@ export class DevSession {
private readonly bundlePath: string
private readonly appEventsProcessor: SerialBatchProcessor<AppEvent>
private failedEvents: AppEvent[] = []
private currentSessionState: DevSessionState | null = null

private constructor(processOptions: DevSessionProcessOptions, stdout: Writable) {
this.statusManager = processOptions.devSessionStatusManager
Expand Down Expand Up @@ -401,6 +408,36 @@ export class DevSession {
private async devSessionUpdateWithRetry(payload: DevSessionUpdateOptions): Promise<DevSessionResult> {
const result = await this.options.developerPlatformClient.devSessionUpdate(payload)
const errors = result.devSessionUpdate?.userErrors ?? []
const devSession = result.devSessionUpdate?.devSession

// Check for session takeover
if (devSession && this.currentSessionState) {
const expectedWebsocketUrl = getWebSocketUrl(this.options.url)
const expectedUserId = this.currentSessionState?.userId

if (devSession.websocketUrl !== expectedWebsocketUrl || devSession.user?.id !== expectedUserId) {
// Another user has taken over the session
const newUserEmail = devSession.user?.email
await this.logger.error(
`Another developer ${newUserEmail ? `(${newUserEmail}) ` : ''}has taken ownership of this dev preview.`,
)
Comment on lines +421 to +423
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why showing the error twice? I'd get rid of this if we are going to abort.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's nice to see something in the log, even if we end up saying the same thing in the final error message. I asked about copy for these messages here and while I didn't explicitly ask if both were warranted, there wasn't push back against having both.


// Throw AbortError to terminate gracefully
const message = `Another user${
newUserEmail ? ` (${newUserEmail})` : ''
} has taken ownership of this dev preview. Your preview is no longer active.`
Comment on lines +426 to +428
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal, but the issue I mentioned here is not fixed.

If the only change is the websocket URL, we are saying Another user, but showing the same email.

const nextSteps = ['You can restart by running', {command: 'shopify app dev'}, 'again.']
throw new AbortError(message, nextSteps)
}

// Update stored session state for next comparison
this.currentSessionState = {
websocketUrl: devSession.websocketUrl,
userId: devSession.user?.id ?? null,
userEmail: devSession.user?.email ?? null,
}
}

if (errors.length) return {status: 'remote-error', error: errors}
return {status: 'updated'}
}
Expand All @@ -414,7 +451,31 @@ export class DevSession {
private async devSessionCreateWithRetry(payload: DevSessionCreateOptions): Promise<DevSessionResult> {
const result = await this.options.developerPlatformClient.devSessionCreate(payload)
const errors = result.devSessionCreate?.userErrors ?? []
const warnings = result.devSessionCreate?.warnings ?? []
const devSession = result.devSessionCreate?.devSession

// Store initial session state
if (devSession) {
this.currentSessionState = {
websocketUrl: devSession.websocketUrl,
userId: devSession.user?.id ?? null,
userEmail: devSession.user?.email ?? null,
}
}

// Display warnings (non-blocking)
if (warnings.length > 0) {
await Promise.all(
warnings.map((warning) => {
const message = warning.code === 'SESSION_TAKEOVER' ? `⚠️ ${warning.message}` : warning.message
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just adding the emoji to all the warnings?

Suggested change
const message = warning.code === 'SESSION_TAKEOVER' ? `⚠️ ${warning.message}` : warning.message
const message = `⚠️ ${warning.message}`

return this.logger.warning(message)
}),
)
}

// Errors are blocking
if (errors.length) return {status: 'remote-error', error: errors}

return {status: 'created'}
}
}
Loading