[wip] cubes packaging and distribution via registry
Publish snapshot / snapshot (push) Successful in 1m21s
Publish snapshot / snapshot (push) Successful in 1m21s
This commit is contained in:
@@ -136,14 +136,63 @@ describe('loader edge cases', () => {
|
||||
expect(errors[0]).toMatch(/Failed to load manifest/);
|
||||
});
|
||||
|
||||
it('reports duplicate cube ids', async () => {
|
||||
it('reports duplicate cube ids, naming every directory that claims one', async () => {
|
||||
cube('first', 'export default { id: "dup", name: "First" }');
|
||||
cube('second', 'export default { id: "dup", name: "Second" }');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(Object.keys(cubes)).toEqual(['dup']);
|
||||
expect(errors[0]).toMatch(/Duplicate cube id 'dup'/);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatch(/Duplicate cube id 'dup' from 2 sources/);
|
||||
expect(errors[0]).toContain(path.join(tmpDir, 'first'));
|
||||
expect(errors[0]).toContain(path.join(tmpDir, 'second'));
|
||||
});
|
||||
|
||||
it('keeps scanning below a duplicate instead of dropping the subtree', async () => {
|
||||
cube('first', 'export default { id: "dup", name: "First" }');
|
||||
cube('second', 'export default { id: "dup", name: "Second" }');
|
||||
cube('second/inner', 'export default { id: "buried", name: "Buried" }');
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(cubes.buried).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports the same duplicate whichever root is scanned first', async () => {
|
||||
cube('a/one', 'export default { id: "dup", name: "One" }');
|
||||
cube('b/two', 'export default { id: "dup", name: "Two" }');
|
||||
const roots = [path.join(tmpDir, 'a'), path.join(tmpDir, 'b')];
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: roots }));
|
||||
const forwards = await loadCubes();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.nopyrc.json'),
|
||||
JSON.stringify({ cubeDirs: [...roots].reverse() })
|
||||
);
|
||||
const backwards = await loadCubes();
|
||||
|
||||
expect(forwards.errors[0]).toContain(path.join(tmpDir, 'a', 'one'));
|
||||
expect(forwards.errors[0]).toContain(path.join(tmpDir, 'b', 'two'));
|
||||
expect(backwards.errors).toHaveLength(1);
|
||||
expect(new Set(backwards.errors[0].split('\n'))).toEqual(
|
||||
new Set(forwards.errors[0].split('\n'))
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call one directory a duplicate of itself when two roots reach it', async () => {
|
||||
cube('nested/one', 'export default { id: "once", name: "Once" }');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.nopyrc.json'),
|
||||
JSON.stringify({ cubeDirs: ['./', './nested'] })
|
||||
);
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(cubes.once).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips hidden and node_modules directories', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { Cube, Manifest } from '../src/cubes/types.js';
|
||||
import { foreignZodSchema } from './helpers/foreign-zod.js';
|
||||
|
||||
const cube = (schema: z.ZodObject<any>) =>
|
||||
new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py');
|
||||
@@ -64,6 +65,22 @@ describe('Cube.getDefaults', () => {
|
||||
it('returns an empty object for an empty schema', () => {
|
||||
expect(cube(z.object({})).getDefaults()).toEqual({});
|
||||
});
|
||||
|
||||
it('reads defaults off a schema built by a different copy of zod', () => {
|
||||
// The per-field fallback reads zod's internals directly. Under `instanceof`
|
||||
// a foreign schema yields no defaults at all, without erroring.
|
||||
const c = cube(
|
||||
foreignZodSchema(
|
||||
z.object({
|
||||
REQUIRED: z.string(),
|
||||
PRIORITY: z.number().default(10),
|
||||
NESTED: z.number().default(2).optional(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(c.getDefaults()).toEqual({ PRIORITY: 10, NESTED: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cube.requiredKeys', () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* A schema that behaves like zod's but does not share zod's prototypes.
|
||||
*
|
||||
* Once cubes arrive from `node_modules`, the schema a manifest builds may come
|
||||
* from a *second* copy of zod — its own dependency, or one shipped inside a
|
||||
* bundle. Such a schema is structurally identical and `instanceof` blind to it.
|
||||
* Rebuilding the nodes as plain objects reproduces that from inside a single
|
||||
* process, so anything that reads zod's internals stays pinned to `def.type`.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
/** Strips the prototype off a schema node and everything it wraps. */
|
||||
function strip(node: unknown): unknown {
|
||||
const def = { ...(node as { def: Record<string, unknown> }).def };
|
||||
if (def.innerType) def.innerType = strip(def.innerType);
|
||||
return { def };
|
||||
}
|
||||
|
||||
export function foreignZodSchema<S extends z.ZodObject<any>>(schema: S): S {
|
||||
return {
|
||||
// Parsing is not what is under test — delegate it and keep the real
|
||||
// behaviour, so only the introspection path sees the foreign nodes.
|
||||
safeParse: (value: unknown) => schema.safeParse(value),
|
||||
shape: Object.fromEntries(
|
||||
Object.entries(schema.shape).map(([key, node]) => [key, strip(node)])
|
||||
),
|
||||
} as unknown as S;
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
PasswordSelection,
|
||||
VariableAssignment,
|
||||
} from '../src/nopy.prompts.js';
|
||||
import { foreignZodSchema } from './helpers/foreign-zod.js';
|
||||
|
||||
/** Grabs the single question object passed to the last inquirer.prompt call. */
|
||||
const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record<string, any>[];
|
||||
@@ -310,13 +311,14 @@ describe('VariableAssignment', () => {
|
||||
const nullableSchema = z.object({
|
||||
maybe: z.number().nullable().default(1),
|
||||
opt: z.number().optional().default(2),
|
||||
given: z.number().nullable().default(3),
|
||||
});
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ maybe: 'null', opt: '7' });
|
||||
formRun.mockResolvedValue({ maybe: 'null', opt: '7', given: '42' });
|
||||
|
||||
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
|
||||
|
||||
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7 });
|
||||
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7, given: 42 });
|
||||
});
|
||||
|
||||
it('treats an empty string as null for a nullable field', async () => {
|
||||
@@ -356,4 +358,28 @@ describe('VariableAssignment', () => {
|
||||
).resolves.toBeUndefined();
|
||||
expect(variables.get('svc', 'prompts')).toEqual({});
|
||||
});
|
||||
|
||||
it('coerces against a schema built by a different copy of zod', async () => {
|
||||
// Guards the discriminant in `coerceValue`: under `instanceof` every check
|
||||
// here returns false and the answers stay strings, silently.
|
||||
const variables = new Variables();
|
||||
formRun.mockResolvedValue({ port: '9090', enabled: 'true', maybe: '' });
|
||||
|
||||
await VariableAssignment(
|
||||
cube(
|
||||
'svc',
|
||||
'Service',
|
||||
foreignZodSchema(
|
||||
z.object({
|
||||
port: z.number().default(8080),
|
||||
enabled: z.boolean().default(false),
|
||||
maybe: z.number().nullable().default(1),
|
||||
})
|
||||
)
|
||||
),
|
||||
variables
|
||||
);
|
||||
|
||||
expect(variables.get('svc', 'prompts')).toEqual({ port: 9090, enabled: true, maybe: null });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user