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
182 changes: 182 additions & 0 deletions packages/cli-kit/src/core/app-module/app-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import {AppModule, contentHashIdentity} from './app-module.js'
import {ModuleSpecification} from '../module-specification/module-specification.js'
import {Contract} from '../contract/contract.js'
import {describe, expect, test} from 'vitest'

function specWithContract(
contract?: Contract,
overrides: Partial<ConstructorParameters<typeof ModuleSpecification>[0]> = {},
): ModuleSpecification {
return new ModuleSpecification({
identifier: 'test_spec',
name: 'Test',
externalIdentifier: 'test_spec_external',
contract,
appModuleLimit: 1,
uidIsClientProvided: false,
features: [],
...overrides,
})
}

describe('AppModule', () => {
describe('construction', () => {
test('deep copies config', () => {
const config = {name: 'hello', nested: {value: 42}}
const mod = new AppModule({
spec: specWithContract(),
config,
sourcePath: '/tmp/shopify.app.toml',
})

config.name = 'mutated'
;(config.nested as {value: number}).value = 0

expect(mod.config.name).toBe('hello')
expect((mod.config.nested as {value: number}).value).toBe(42)
})

test('preserves sourcePath', () => {
const mod = new AppModule({
spec: specWithContract(),
config: {name: 'test'},
sourcePath: '/tmp/extensions/my-ext/shopify.extension.toml',
})
expect(mod.sourcePath).toBe('/tmp/extensions/my-ext/shopify.extension.toml')
})

test('preserves directory and entryPath when provided', () => {
const mod = new AppModule({
spec: specWithContract(),
config: {},
sourcePath: '/tmp/ext.toml',
directory: '/tmp/extensions/my-ext',
entryPath: '/tmp/extensions/my-ext/src/index.ts',
})
expect(mod.directory).toBe('/tmp/extensions/my-ext')
expect(mod.entryPath).toBe('/tmp/extensions/my-ext/src/index.ts')
})
})

describe('validation state', () => {
test('starts as unvalidated', () => {
const mod = new AppModule({
spec: specWithContract(),
config: {},
sourcePath: '/tmp/app.toml',
})
expect(mod.isUnvalidated).toBe(true)
expect(mod.isValid).toBe(false)
expect(mod.isInvalid).toBe(false)
expect(mod.errors).toHaveLength(0)
})

test('transitions to valid when contract passes', async () => {
const contract = await Contract.fromJsonSchema(
JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}),
)
const mod = new AppModule({
spec: specWithContract(contract),
config: {name: 'hello'},
sourcePath: '/tmp/app.toml',
})

const state = mod.validate()
expect(state.status).toBe('valid')
expect(mod.isValid).toBe(true)
expect(mod.isInvalid).toBe(false)
})

test('transitions to invalid when contract fails', async () => {
const contract = await Contract.fromJsonSchema(
JSON.stringify({
type: 'object',
properties: {name: {type: 'string'}},
required: ['name'],
additionalProperties: false,
}),
)
const mod = new AppModule({
spec: specWithContract(contract),
config: {wrong_field: 'oops'},
sourcePath: '/tmp/app.toml',
})

const state = mod.validate()
expect(state.status).toBe('invalid')
expect(mod.isInvalid).toBe(true)
expect(mod.errors.length).toBeGreaterThan(0)
})

test('is valid when spec has no contract', () => {
const mod = new AppModule({
spec: specWithContract(undefined),
config: {anything: 'goes'},
sourcePath: '/tmp/app.toml',
})

mod.validate()
expect(mod.isValid).toBe(true)
})

test('second validate call returns same state (idempotent)', async () => {
const contract = await Contract.fromJsonSchema(
JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}),
)
const mod = new AppModule({
spec: specWithContract(contract),
config: {name: 'hello'},
sourcePath: '/tmp/app.toml',
})

const state1 = mod.validate()
const state2 = mod.validate()
expect(state1).toBe(state2)
})
})

describe('identity', () => {
test('uses fixed identity when uidIsClientProvided is false', () => {
const mod = new AppModule({
spec: specWithContract(undefined, {identifier: 'app_home', uidIsClientProvided: false}),
config: {name: 'My App'},
sourcePath: '/tmp/app.toml',
})
expect(mod.handle).toBe('app_home')
expect(mod.uid).toBe('app_home')
})

test('uses config-derived identity when uidIsClientProvided is true', () => {
const mod = new AppModule({
spec: specWithContract(undefined, {identifier: 'function', uidIsClientProvided: true}),
config: {handle: 'my-func', uid: 'custom-uid', name: 'My Function'},
sourcePath: '/tmp/ext.toml',
})
expect(mod.handle).toBe('my-func')
expect(mod.uid).toBe('custom-uid')
})

test('uses explicit identity override when provided', () => {
const mod = new AppModule({
spec: specWithContract(undefined, {identifier: 'webhook_subscription'}),
config: {topic: 'products/create', uri: '/webhooks', filter: ''},
sourcePath: '/tmp/app.toml',
identity: contentHashIdentity(['topic', 'uri', 'filter']),
})
// Handle is a hash of the content fields
expect(mod.handle).toBeTruthy()
expect(mod.handle).not.toBe('webhook_subscription')
// UID is the joined content fields
expect(mod.uid).toBe('products/create::/webhooks::')
})

test('type is always the spec identifier', () => {
const mod = new AppModule({
spec: specWithContract(undefined, {identifier: 'branding'}),
config: {},
sourcePath: '/tmp/app.toml',
})
expect(mod.type).toBe('branding')
})
})
})
112 changes: 112 additions & 0 deletions packages/cli-kit/src/core/app-module/app-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {ModuleSpecification} from '../module-specification/module-specification.js'
import {ValidationError} from '../contract/contract.js'
import {JsonMapType} from '../../public/node/toml/codec.js'
import {slugify} from '../../public/common/string.js'
import {hashString, nonRandomUUID} from '../../public/node/crypto.js'

export type ValidationState =
| {status: 'unvalidated'}
| {status: 'valid'}
| {status: 'invalid'; errors: ValidationError[]}

/**
* How a module resolves its handle and uid.
*/
export interface ModuleIdentity {
resolveHandle(config: JsonMapType): string
resolveUid(config: JsonMapType, handle: string): string
}

export const fixedIdentity = (id: string): ModuleIdentity => ({
resolveHandle: () => id,
resolveUid: () => id,
})

export const configDerivedIdentity: ModuleIdentity = {
resolveHandle: (config) => (config.handle as string) ?? slugify(config.name as string),
resolveUid: (config, handle) => (config.uid as string) ?? nonRandomUUID(handle),
}

export const contentHashIdentity = (fields: string[]): ModuleIdentity => ({
resolveHandle: (config) => hashString(fields.map((field) => String(config[field] ?? '')).join(':')),
resolveUid: (config) => fields.map((field) => String(config[field] ?? '')).join('::'),
})

/**
* A concrete module instance — a specification paired with actual config data.
*
* Immutable config, one-way validation state. If the underlying file changes,
* the system creates new AppModules — it doesn't update existing ones.
*/
export class AppModule {
readonly spec: ModuleSpecification
readonly config: JsonMapType
readonly sourcePath: string
readonly directory?: string
readonly entryPath?: string
readonly identity: ModuleIdentity
private _state: ValidationState = {status: 'unvalidated'}

constructor(options: {
spec: ModuleSpecification
config: JsonMapType
sourcePath: string
directory?: string
entryPath?: string
identity?: ModuleIdentity
}) {
this.spec = options.spec
this.config = structuredClone(options.config)
this.sourcePath = options.sourcePath
this.directory = options.directory
this.entryPath = options.entryPath
this.identity =
options.identity ??
(options.spec.uidIsClientProvided ? configDerivedIdentity : fixedIdentity(options.spec.identifier))
}

get state(): ValidationState {
return this._state
}

get isValid(): boolean {
return this._state.status === 'valid'
}

get isInvalid(): boolean {
return this._state.status === 'invalid'
}

get isUnvalidated(): boolean {
return this._state.status === 'unvalidated'
}

get errors(): ValidationError[] {
return this._state.status === 'invalid' ? this._state.errors : []
}

/**
* Validates the config and transitions state. Can only be called once.
* How validation works (contract, schema, etc.) is an implementation detail.
*/
validate(): ValidationState {
if (this._state.status !== 'unvalidated') return this._state

const errors = this.spec.contract?.validate(this.config) ?? []
this._state = errors.length === 0 ? {status: 'valid'} : {status: 'invalid', errors}

return this._state
}

get handle(): string {
return this.identity.resolveHandle(this.config)
}

get uid(): string {
return this.identity.resolveUid(this.config, this.handle)
}

get type(): string {
return this.spec.identifier
}
}
Loading
Loading