Skip to content
Merged
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
70 changes: 70 additions & 0 deletions packages/config/src/cli/run.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type {Identity} from '@dfinity/agent';
import type {Principal} from '@dfinity/principal';
import * as z from 'zod/v4';
import {StrictIdentitySchema} from '../utils/identity.utils';
import {StrictPrincipalSchema} from '../utils/principal.utils';
import {createFunctionSchema} from '../utils/zod.utils';

/**
* @see OnRunContext
*/
export const OnRunContextSchema = z.strictObject({
satelliteId: StrictPrincipalSchema,
identity: StrictIdentitySchema,
container: z.string().optional()
});

/**
* The context for running a task.
*/
export interface OnRunContext {
/**
* The Satellite ID as defined in the `juno.config` file.
*
* A {@link Principal} instance.
*/
satelliteId: Principal;

/**
* The {@link Identity} used by the CLI for this execution,
* resolved according to the selected mode and profile.
*/
identity: Identity;

/**
* A custom container URL. Useful when your local emulator runs on a non-default URL or port.
* @type {string}
* @optional
*/
container?: string;
}

/**
* @see RunFunction
*/
const RunFunctionSchema = z.function({
input: z.tuple([OnRunContextSchema]),
output: z.promise(z.void()).or(z.void())
});
/**
* The function executed by a task.
*/
export type RunFunction = (context: OnRunContext) => void | Promise<void>;

/**
* @see OnRun
*/
export const OnRunSchema = z.strictObject({
run: createFunctionSchema(RunFunctionSchema)
});

/**
* A runner (job) executed with `juno run`.
*/
export interface OnRun {
/**
* The function that will be executed and called with parameters
* inherited from your configuration and CLI.
*/
run: RunFunction;
}
19 changes: 19 additions & 0 deletions packages/config/src/cli/run.env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as z from 'zod/v4';
import {type JunoConfigEnv, JunoConfigEnvSchema} from '../types/juno.env';

/**
* @see OnRunEnv
*/
export const OnRunEnvSchema = JunoConfigEnvSchema.extend({
profile: z.string().optional()
});

/**
* The environment available when executing `juno run`.
*/
export type OnRunEnv = JunoConfigEnv & {
/**
* Optional profile (e.g. `personal`, `team`) used for execution.
*/
profile?: string;
};
20 changes: 20 additions & 0 deletions packages/config/src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as z from 'zod/v4';
import {createFunctionSchema} from '../utils/zod.utils';
import {type OnRun, OnRunContextSchema, OnRunSchema} from './run.context';
import type {OnRunEnv} from './run.env';

export const RunFnSchema = z.function({
input: z.tuple([OnRunContextSchema]),
output: OnRunSchema
});
export type RunFn = (context: OnRunEnv) => OnRun;

export const RunFnOrObjectSchema = z.union([OnRunSchema, createFunctionSchema(RunFnSchema)]);
export type RunFnOrObject = OnRun | RunFn;

export function defineRun(run: OnRun): OnRun;
export function defineRun(run: RunFn): RunFn;
export function defineRun(run: RunFnOrObject): RunFnOrObject;
export function defineRun(run: RunFnOrObject): RunFnOrObject {
return run;
}
3 changes: 3 additions & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from './cli/run';
export * from './cli/run.context';
export * from './cli/run.env';
export * from './console/config';
export * from './console/console.config';
export * from './pkg/juno.package';
Expand Down
138 changes: 138 additions & 0 deletions packages/config/src/tests/cli/run.context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {Principal} from '@dfinity/principal';
import {OnRun, OnRunContextSchema, OnRunSchema} from '../../cli/run.context';
import {mockIdentity} from '../mocks/identity.mock';
import {mockUserIdPrincipal} from '../mocks/principal.mock';

describe('run.context', () => {
describe('OnRunContextSchema', () => {
it('accepts a valid context with required fields only', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity
});
expect(result.success).toBe(true);
});

it('should not accept a container as boolean', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity,
container: true
});
expect(result.success).toBe(false);
});

it('accepts with container as string (URL)', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity,
container: 'http://localhost:4943'
});
expect(result.success).toBe(true);
});

it('rejects if satelliteId is missing', () => {
const result = OnRunContextSchema.safeParse({
identity: mockIdentity
});
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].path).toEqual(['satelliteId']);
}
});

it('rejects if identity is missing', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal
});
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].path).toEqual(['identity']);
}
});

it('rejects if container is not boolean or string', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity,
container: 123
});
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].path).toEqual(['container']);
}
});

it('is strict: rejects unknown keys', () => {
const result = OnRunContextSchema.safeParse({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity,
extra: 'nope'
});
expect(result.success).toBe(false);
});
});

describe('OnRunSchema', () => {
it('accepts a sync run function', () => {
const onRun: OnRun = {
run: (ctx) => {
const parsed = OnRunContextSchema.parse(ctx);
expect(parsed.satelliteId.toText()).toBe('2vxsx-fae');
}
};
const res = OnRunSchema.safeParse(onRun);
expect(res.success).toBe(true);
});

it('accepts an async run function', async () => {
const onRun: OnRun = {
run: async (ctx) => {
const parsed = OnRunContextSchema.parse(ctx);
expect(parsed.identity).toBeTruthy();
}
};
const res = OnRunSchema.safeParse(onRun);
expect(res.success).toBe(true);
});

it('rejects when run is not a function', () => {
expect(() =>
OnRunSchema.parse({
run: 'not-a-function'
})
).toThrow();
});

it('rejects when run is missing', () => {
expect(() => OnRunSchema.parse({})).toThrow();
});

it('executes a validated run function at runtime', async () => {
const onRun: OnRun = {
run: async (ctx: unknown) => {
const parsed = OnRunContextSchema.parse(ctx);
expect(parsed.satelliteId).toBeInstanceOf(Principal);
}
};

const parsed = OnRunSchema.parse(onRun);
await parsed.run({
satelliteId: Principal.fromText('jx5yt-yyaaa-aaaal-abzbq-cai'),
identity: mockIdentity
});
});

it('is strict: rejects unknown keys at top-level OnRun object', () => {
const onRun: any = {
run: () => {},
extra: 'nope'
};
const res = OnRunSchema.safeParse(onRun);
expect(res.success).toBe(false);
});
});
});
38 changes: 38 additions & 0 deletions packages/config/src/tests/cli/run.env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {OnRunEnvSchema} from '../../cli/run.env';

describe('run.env', () => {
describe('OnRunEnvSchema', () => {
it('accepts valid env with mode only', () => {
const result = OnRunEnvSchema.safeParse({mode: 'production'});
expect(result.success).toBe(true);
});

it('accepts valid env with mode and profile', () => {
const result = OnRunEnvSchema.safeParse({mode: 'staging', profile: 'team'});
expect(result.success).toBe(true);
});

it('rejects if profile is not a string', () => {
const result = OnRunEnvSchema.safeParse({mode: 'production', profile: 123});
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].path).toEqual(['profile']);
}
});

it('rejects if mode is missing', () => {
const result = OnRunEnvSchema.safeParse({profile: 'personal'});
expect(result.success).toBe(false);

if (!result.success) {
expect(result.error.issues[0].path).toEqual(['mode']);
}
});

it('rejects null/undefined mode', () => {
expect(OnRunEnvSchema.safeParse({mode: null}).success).toBe(false);
expect(OnRunEnvSchema.safeParse({mode: undefined}).success).toBe(false);
});
});
});
64 changes: 64 additions & 0 deletions packages/config/src/tests/cli/run.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {defineRun, RunFnOrObjectSchema} from '../../cli/run';
import {OnRunContextSchema, OnRunSchema} from '../../cli/run.context';
import {mockIdentity} from '../mocks/identity.mock';
import {mockUserIdPrincipal} from '../mocks/principal.mock';

describe('run', () => {
const validOnRunObj = {
run: (_ctx: unknown) => {}
};

describe('RunFnOrObjectSchema', () => {
it('accepts an OnRun object', () => {
const res = RunFnOrObjectSchema.safeParse(validOnRunObj);
expect(res.success).toBe(true);

if (res.success) {
expect(OnRunSchema.safeParse(res.data).success).toBe(true);
}
});

it('accepts a function returning a valid OnRun object', () => {
const fn = (_env: unknown) => validOnRunObj;
const res = RunFnOrObjectSchema.safeParse(fn);
expect(res.success).toBe(true);
});

it('throws when input is not a function nor a valid OnRun object', () => {
expect(() => RunFnOrObjectSchema.parse('nope')).toThrow();
});

it('runtime: call the function form and validate the returned OnRun', async () => {
const fn = (_env: unknown) => ({
run: async (ctx: unknown) => {
const parsed = OnRunContextSchema.parse(ctx);
expect(parsed.satelliteId.toText()).toBe(mockUserIdPrincipal.toText());
}
});

const parsedUnion = RunFnOrObjectSchema.parse(fn);
const onRun = (parsedUnion as typeof fn)({});

const valid = OnRunSchema.parse(onRun);

await valid.run({
satelliteId: mockUserIdPrincipal,
identity: mockIdentity
});
});
});

describe('defineRun', () => {
it('returns the same OnRun object reference', () => {
const res = defineRun(validOnRunObj);
expect(res).toBe(validOnRunObj);
expect(OnRunSchema.safeParse(res).success).toBe(true);
});

it('returns the same function reference', () => {
const fn = (_env: unknown) => validOnRunObj;
const res = defineRun(fn);
expect(res).toBe(fn);
});
});
});
15 changes: 15 additions & 0 deletions packages/config/src/tests/mocks/identity.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type {Identity} from '@dfinity/agent';
import {mockUserIdPrincipal} from './principal.mock';

const transformRequest = () => {
console.error(
'It looks like the agent is trying to make a request that should have been mocked at',
new Error().stack
);
throw new Error('Not implemented');
};

export const mockIdentity = {
getPrincipal: () => mockUserIdPrincipal,
transformRequest
} as unknown as Identity;
4 changes: 4 additions & 0 deletions packages/config/src/tests/mocks/principal.mock.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
import {Principal} from '@dfinity/principal';

export const mockModuleIdText = 'ucnx3-aqaaa-aaaal-ab3ea-cai';
export const mockUserIdText = 'xlmdg-vkosz-ceopx-7wtgu-g3xmd-koiyc-awqaq-7modz-zf6r6-364rh-oqe';

export const mockUserIdPrincipal = Principal.fromText(mockUserIdText);
Loading