| … | … | @@ -0,0 +1,4 @@ |
| 1 | # Fixtures are inputs to the test suite, not part of codetree's own structure. | |
| 2 | # Without this, scanning codetree counts fixture files as its own source (48 -> | |
| 3 | # 65 files) and its self-check measures them too. | |
| 4 | test/fixtures/ |
| … | … | @@ -0,0 +1,19 @@ |
| 1 | # workspace — a fixture that stands in for real sibling repos | |
| 2 | ||
| 3 | `parser`, `traverse` and `tree` used to read `../hyper-orgs`, `../hyper-sync`, | |
| 4 | `../hyper-deploy` and `../pear-git-v3` — LIVE repos outside this one. When | |
| 5 | hyper-orgs went 0.2.0 -> 0.2.2 and dropped a field, fifteen assertions here went | |
| 6 | red for a change that had nothing to do with codetree. That is invariant I1 in | |
| 7 | docs/DOC-DEDUP-LAYER.md being violated by codetree's own suite. | |
| 8 | ||
| 9 | These four packages reproduce the SHAPES those tests were really checking: | |
| 10 | ||
| 11 | | package | stands in for | shape under test | | |
| 12 | |---|---|---| | |
| 13 | | `widget-store` | hyper-orgs | ESM barrel of re-exports; a big class with constructor JSDoc, many methods, getters, `this._x` state, delegation, error contracts; lib/ + schema + test file roles | | |
| 14 | | `widget-rooms` | hyper-rooms | the dependency edge, and a second user of a shared dep | | |
| 15 | | `legacy-sync` | hyper-sync | CJS: no `type` field, `module.exports`, a class | | |
| 16 | | `mapped-io` | hyper-deploy | package.json `imports` maps | | |
| 17 | | `widget-store/lib/handlers/widgets.js` | pear-git-v3 handlers | the handler-factory pattern | | |
| 18 | ||
| 19 | Keep them boring. They exist to be parsed, not to work. |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | // CommonJS on purpose: no "type" field in package.json, require + module.exports. | |
| 2 | const path = require('path') | |
| 3 | ||
| 4 | class Syncer { | |
| 5 | constructor (dir) { | |
| 6 | this._dir = path.resolve(dir) | |
| 7 | } | |
| 8 | ||
| 9 | sync () { | |
| 10 | return this._dir | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | module.exports = { Syncer } |
| … | … | @@ -0,0 +1,5 @@ |
| 1 | { | |
| 2 | "name": "legacy-sync", | |
| 3 | "version": "0.1.0", | |
| 4 | "main": "index.js" | |
| 5 | } |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | import fs from 'fs' | |
| 2 | import path from 'path' | |
| 3 | ||
| 4 | export function readIt (p) { | |
| 5 | return fs.readFileSync(path.resolve(p), 'utf-8') | |
| 6 | } |
| … | … | @@ -0,0 +1,11 @@ |
| 1 | { | |
| 2 | "name": "mapped-io", | |
| 3 | "version": "0.3.0", | |
| 4 | "type": "module", | |
| 5 | "main": "index.js", | |
| 6 | "imports": { | |
| 7 | "fs": { "bare": "bare-fs", "default": "fs" }, | |
| 8 | "path": { "bare": "bare-path", "default": "path" }, | |
| 9 | "os": { "bare": "bare-os", "default": "os" } | |
| 10 | } | |
| 11 | } |
| … | … | @@ -0,0 +1 @@ |
| 1 | export { Room } from './lib/room.js' |
| … | … | @@ -0,0 +1,19 @@ |
| 1 | export class Room { | |
| 2 | constructor (corestore, options) { | |
| 3 | this._corestore = corestore | |
| 4 | this._options = options || {} | |
| 5 | } | |
| 6 | ||
| 7 | get key () { return this._options.key || null } | |
| 8 | get writable () { return true } | |
| 9 | ||
| 10 | async ready () {} | |
| 11 | async close () {} | |
| 12 | async put (key, value) { return { key, value } } | |
| 13 | async get (key) { return { key } } | |
| 14 | async del (key) { return true } | |
| 15 | async list () { return [] } | |
| 16 | async member (peer) { return { peer, level: 0 } } | |
| 17 | async invite (level) { return { level } } | |
| 18 | async addWriter (key) { return key } | |
| 19 | } |
| … | … | @@ -0,0 +1,12 @@ |
| 1 | { | |
| 2 | "name": "widget-rooms", | |
| 3 | "version": "1.0.0", | |
| 4 | "type": "module", | |
| 5 | "main": "index.js", | |
| 6 | "scripts": { | |
| 7 | "test": "brittle test/*.test.js" | |
| 8 | }, | |
| 9 | "dependencies": { | |
| 10 | "b4a": "^1.6.0" | |
| 11 | } | |
| 12 | } |
| … | … | @@ -0,0 +1,2 @@ |
| 1 | export { WidgetStore } from './lib/widget-store.js' | |
| 2 | export { storeSchema, RESERVED_NAMES, validateName } from './lib/schema.js' |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | /** | |
| 2 | * The handler-factory pattern: a function returning a map of RPC handlers, | |
| 3 | * standing in for pear-git-v3's lib/handlers/*.js. | |
| 4 | */ | |
| 5 | export function createWidgetHandlers (ctx) { | |
| 6 | return { | |
| 7 | async list () { | |
| 8 | return ctx.store.list() | |
| 9 | }, | |
| 10 | async create (req) { | |
| 11 | return ctx.store.create(req.name, req.type, req.data) | |
| 12 | } | |
| 13 | } | |
| 14 | } |
| … | … | @@ -0,0 +1,11 @@ |
| 1 | export const storeSchema = { | |
| 2 | widget: { name: 'string', kind: 'string' } | |
| 3 | } | |
| 4 | ||
| 5 | export const RESERVED_NAMES = ['admin', 'root', 'system'] | |
| 6 | ||
| 7 | export function validateName (name) { | |
| 8 | if (!name) throw new Error('Name is required') | |
| 9 | if (RESERVED_NAMES.includes(name)) throw new Error(`Reserved name: ${name}`) | |
| 10 | return true | |
| 11 | } |
| … | … | @@ -0,0 +1,89 @@ |
| 1 | import { Room } from 'widget-rooms' | |
| 2 | import { storeSchema, validateName } from './schema.js' | |
| 3 | ||
| 4 | /** | |
| 5 | * A store of widgets, backed by a room. | |
| 6 | * | |
| 7 | * Stands in for hyper-orgs: a facade class that delegates lifecycle to an inner | |
| 8 | * room and exposes a CRUD surface over it. | |
| 9 | */ | |
| 10 | export class WidgetStore { | |
| 11 | /** | |
| 12 | * Create a store. | |
| 13 | * @param {object} corestore - the corestore to build on | |
| 14 | * @param {object} options - room options | |
| 15 | */ | |
| 16 | constructor (corestore, options) { | |
| 17 | this._corestore = corestore | |
| 18 | this._options = options || {} | |
| 19 | this._room = new Room(corestore, this._options) | |
| 20 | this._opened = false | |
| 21 | } | |
| 22 | ||
| 23 | get key () { return this._room.key } | |
| 24 | get keyHex () { return this._room.key.toString('hex') } | |
| 25 | get writable () { return this._room.writable } | |
| 26 | get view () { return this._room.view } | |
| 27 | get base () { return this._room.base } | |
| 28 | get room () { return this._room } | |
| 29 | ||
| 30 | async ready () { | |
| 31 | await this._room.ready() | |
| 32 | this._opened = true | |
| 33 | } | |
| 34 | ||
| 35 | async close () { | |
| 36 | await this._room.close() | |
| 37 | this._opened = false | |
| 38 | } | |
| 39 | ||
| 40 | async create (name, type, data) { | |
| 41 | validateName(name) | |
| 42 | if (!storeSchema[type]) throw new Error(`Unknown type: ${type}`) | |
| 43 | return this._room.put(name, { type, data }) | |
| 44 | } | |
| 45 | ||
| 46 | async get (name) { | |
| 47 | return this._room.get(name) | |
| 48 | } | |
| 49 | ||
| 50 | async update (name, data) { | |
| 51 | const existing = await this.get(name) | |
| 52 | if (!existing) throw new Error(`No such widget: ${name}`) | |
| 53 | return this._room.put(name, { ...existing, data }) | |
| 54 | } | |
| 55 | ||
| 56 | async delete (name) { | |
| 57 | return this._room.del(name) | |
| 58 | } | |
| 59 | ||
| 60 | async list () { | |
| 61 | return this._room.list() | |
| 62 | } | |
| 63 | ||
| 64 | async checkAccess (peer, level) { | |
| 65 | const member = await this._room.member(peer) | |
| 66 | if (!member) return false | |
| 67 | return member.level >= level | |
| 68 | } | |
| 69 | ||
| 70 | async addMember (peer, level) { | |
| 71 | return this._room.put('member/' + peer, { level }) | |
| 72 | } | |
| 73 | ||
| 74 | async addResource (name, resource) { | |
| 75 | return this._room.put('resource/' + name, resource) | |
| 76 | } | |
| 77 | ||
| 78 | async createGrant (peer, scope) { | |
| 79 | return this._room.put('grant/' + peer, { scope }) | |
| 80 | } | |
| 81 | ||
| 82 | async createInvite (level) { | |
| 83 | return this._room.invite(level) | |
| 84 | } | |
| 85 | ||
| 86 | async addWriter (key) { | |
| 87 | return this._room.addWriter(key) | |
| 88 | } | |
| 89 | } |
| … | … | @@ -0,0 +1,16 @@ |
| 1 | { | |
| 2 | "name": "widget-store", | |
| 3 | "version": "0.2.0", | |
| 4 | "type": "module", | |
| 5 | "main": "index.js", | |
| 6 | "scripts": { | |
| 7 | "test": "brittle test/*.test.js" | |
| 8 | }, | |
| 9 | "dependencies": { | |
| 10 | "widget-rooms": "^1.0.0", | |
| 11 | "b4a": "^1.6.0" | |
| 12 | }, | |
| 13 | "devDependencies": { | |
| 14 | "brittle": "^3.0.0" | |
| 15 | } | |
| 16 | } |
| … | … | @@ -0,0 +1,7 @@ |
| 1 | // Exists so the scanner has a file with the 'test' role to classify. | |
| 2 | import { test } from 'brittle' | |
| 3 | import { WidgetStore } from '../index.js' | |
| 4 | ||
| 5 | test('widget-store: constructs', (t) => { | |
| 6 | t.ok(WidgetStore, 'class is exported') | |
| 7 | }) |
| … | … | @@ -5,43 +5,49 @@ import { fileURLToPath } from 'url' |
| 5 | 5 | import { parseFile } from '../lib/parsers/js.js' |
| 6 | 6 | |
| 7 | 7 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 8 | const ROOT = resolve(HERE, '../../') | |
| 8 | ||
| 9 | // These tests used to read ../hyper-orgs, ../hyper-sync and ../pear-git-v3 — | |
| 10 | // live repos outside this one. hyper-orgs went 0.2.0 -> 0.2.2 and dropped a | |
| 11 | // field, and eight assertions here went red for a change that had nothing to do | |
| 12 | // with codetree. Invariant I1 (docs/DOC-DEDUP-LAYER.md) forbids exactly that. | |
| 13 | // fixtures/workspace reproduces the shapes; see its README for the mapping. | |
| 14 | const FIXTURE = resolve(HERE, 'fixtures/workspace') | |
| 9 | 15 | |
| 10 | 16 | function readSource (rel) { |
| 11 | return readFileSync(resolve(ROOT, rel), 'utf-8') | |
| 17 | return readFileSync(resolve(FIXTURE, rel), 'utf-8') | |
| 12 | 18 | } |
| 13 | 19 | |
| 14 | test('parser: hyper-orgs/index.js re-exports', (t) => { | |
| 15 | const source = readSource('hyper-orgs/index.js') | |
| 16 | const result = parseFile(source, 'hyper-orgs/index.js') | |
| 20 | test('parser: a barrel of re-exports', (t) => { | |
| 21 | const source = readSource('widget-store/index.js') | |
| 22 | const result = parseFile(source, 'widget-store/index.js') | |
| 17 | 23 | |
| 18 | 24 | t.absent(result.error, 'no parse error') |
| 19 | 25 | t.is(result.exports.length, 4, '4 re-exports') |
| 20 | 26 | |
| 21 | 27 | const names = result.exports.map(e => e.name) |
| 22 | t.ok(names.includes('HyperOrgs'), 'exports HyperOrgs') | |
| 23 | t.ok(names.includes('orgsSchema'), 'exports orgsSchema') | |
| 28 | t.ok(names.includes('WidgetStore'), 'exports WidgetStore') | |
| 29 | t.ok(names.includes('storeSchema'), 'exports storeSchema') | |
| 24 | 30 | t.ok(names.includes('RESERVED_NAMES'), 'exports RESERVED_NAMES') |
| 25 | 31 | t.ok(names.includes('validateName'), 'exports validateName') |
| 26 | 32 | |
| 27 | // All are re-exports from other files | |
| 28 | const hyperOrgsExport = result.exports.find(e => e.name === 'HyperOrgs') | |
| 29 | t.is(hyperOrgsExport.from, './lib/hyper-orgs.js', 'HyperOrgs from lib/hyper-orgs.js') | |
| 33 | // Every one is a re-export, carrying the file it came from. | |
| 34 | const storeExport = result.exports.find(e => e.name === 'WidgetStore') | |
| 35 | t.is(storeExport.from, './lib/widget-store.js', 'WidgetStore from lib/widget-store.js') | |
| 30 | 36 | |
| 31 | 37 | const schemaExports = result.exports.filter(e => e.from === './lib/schema.js') |
| 32 | 38 | t.is(schemaExports.length, 3, '3 exports from schema.js') |
| 33 | 39 | }) |
| 34 | 40 | |
| 35 | test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => { | |
| 36 | const source = readSource('hyper-orgs/lib/hyper-orgs.js') | |
| 37 | const result = parseFile(source, 'hyper-orgs/lib/hyper-orgs.js') | |
| 41 | test('parser: a facade class — constructor, methods, getters, state, delegation', (t) => { | |
| 42 | const source = readSource('widget-store/lib/widget-store.js') | |
| 43 | const result = parseFile(source, 'widget-store/lib/widget-store.js') | |
| 38 | 44 | |
| 39 | 45 | t.absent(result.error, 'no parse error') |
| 40 | 46 | |
| 41 | 47 | // Imports |
| 42 | 48 | t.is(result.imports.length, 2, '2 imports') |
| 43 | const roomImport = result.imports.find(i => i.specifier === 'hyper-rooms') | |
| 44 | t.ok(roomImport, 'imports from hyper-rooms') | |
| 49 | const roomImport = result.imports.find(i => i.specifier === 'widget-rooms') | |
| 50 | t.ok(roomImport, 'imports from widget-rooms') | |
| 45 | 51 | t.ok(roomImport.names.includes('Room'), 'imports Room') |
| 46 | 52 | |
| 47 | 53 | const schemaImport = result.imports.find(i => i.specifier === './schema.js') |
| … | … | @@ -49,13 +55,13 @@ test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => { |
| 49 | 55 | |
| 50 | 56 | // Exports |
| 51 | 57 | t.is(result.exports.length, 1, '1 export') |
| 52 | t.is(result.exports[0].name, 'HyperOrgs', 'exports HyperOrgs') | |
| 58 | t.is(result.exports[0].name, 'WidgetStore', 'exports WidgetStore') | |
| 53 | 59 | t.is(result.exports[0].type, 'class', 'export type is class') |
| 54 | 60 | |
| 55 | 61 | // Class |
| 56 | 62 | t.is(result.classes.length, 1, '1 class') |
| 57 | 63 | const cls = result.classes[0] |
| 58 | t.is(cls.name, 'HyperOrgs', 'class name') | |
| 64 | t.is(cls.name, 'WidgetStore', 'class name') | |
| 59 | 65 | t.absent(cls.extends, 'does not extend anything') |
| 60 | 66 | |
| 61 | 67 | // Constructor |
| … | … | @@ -67,73 +73,54 @@ test('parser: hyper-orgs/lib/hyper-orgs.js class structure', (t) => { |
| 67 | 73 | |
| 68 | 74 | // Methods |
| 69 | 75 | const methodNames = cls.methods.map(m => m.name) |
| 70 | t.ok(methodNames.includes('ready'), 'has ready()') | |
| 71 | t.ok(methodNames.includes('create'), 'has create()') | |
| 72 | t.ok(methodNames.includes('get'), 'has get()') | |
| 73 | t.ok(methodNames.includes('update'), 'has update()') | |
| 74 | t.ok(methodNames.includes('delete'), 'has delete()') | |
| 75 | t.ok(methodNames.includes('list'), 'has list()') | |
| 76 | t.ok(methodNames.includes('checkAccess'), 'has checkAccess()') | |
| 77 | t.ok(methodNames.includes('addMember'), 'has addMember()') | |
| 78 | t.ok(methodNames.includes('addResource'), 'has addResource()') | |
| 79 | t.ok(methodNames.includes('createGrant'), 'has createGrant()') | |
| 80 | t.ok(methodNames.includes('createInvite'), 'has createInvite()') | |
| 81 | t.ok(methodNames.includes('addWriter'), 'has addWriter()') | |
| 82 | ||
| 83 | // Async methods | |
| 76 | t.is(cls.methods.length, 13, '13 methods') | |
| 77 | for (const name of ['ready', 'close', 'create', 'get', 'update', 'delete', 'list', | |
| 78 | 'checkAccess', 'addMember', 'addResource', 'createGrant', 'createInvite', 'addWriter']) { | |
| 79 | t.ok(methodNames.includes(name), 'has ' + name + '()') | |
| 80 | } | |
| 81 | ||
| 82 | // Async methods keep their signature | |
| 84 | 83 | const createMethod = cls.methods.find(m => m.name === 'create') |
| 85 | 84 | t.ok(createMethod.async, 'create is async') |
| 86 | t.is(createMethod.params.length, 3, 'create has 3 params') | |
| 87 | t.is(createMethod.params[0], 'name', 'create param 1: name') | |
| 88 | t.is(createMethod.params[1], 'type', 'create param 2: type') | |
| 89 | t.is(createMethod.params[2], 'data', 'create param 3: data') | |
| 85 | t.alike(createMethod.params, ['name', 'type', 'data'], 'create params') | |
| 90 | 86 | |
| 91 | 87 | // Getters |
| 92 | const getterNames = cls.getters.map(g => g.name) | |
| 93 | t.ok(getterNames.includes('key'), 'has key getter') | |
| 94 | t.ok(getterNames.includes('keyHex'), 'has keyHex getter') | |
| 95 | t.ok(getterNames.includes('writable'), 'has writable getter') | |
| 96 | t.ok(getterNames.includes('view'), 'has view getter') | |
| 97 | t.ok(getterNames.includes('base'), 'has base getter') | |
| 98 | t.ok(getterNames.includes('room'), 'has room getter') | |
| 99 | ||
| 100 | // State | |
| 101 | t.ok(result.state.includes('_room'), 'has _room state') | |
| 102 | ||
| 103 | // Delegation | |
| 104 | t.ok(result.delegates.length > 0, 'has delegation patterns') | |
| 88 | t.alike(cls.getters.map(g => g.name), ['key', 'keyHex', 'writable', 'view', 'base', 'room'], | |
| 89 | 'all six getters, in source order') | |
| 90 | ||
| 91 | // State — `this._field = ...` | |
| 92 | t.alike(result.state, ['_corestore', '_options', '_room', '_opened'], 'state fields') | |
| 93 | ||
| 94 | // Delegation — `this._field.method()` | |
| 105 | 95 | const roomDelegate = result.delegates.find(d => d.field === '_room') |
| 106 | 96 | t.ok(roomDelegate, 'delegates to _room') |
| 107 | 97 | t.ok(roomDelegate.methods.includes('ready'), 'delegates ready to _room') |
| 108 | 98 | t.ok(roomDelegate.methods.includes('close'), 'delegates close to _room') |
| 109 | 99 | |
| 110 | // Error contracts | |
| 111 | t.ok(result.errorContracts.length > 0, 'has error contracts') | |
| 100 | // Error contracts, including the template-literal form | |
| 101 | t.is(result.errorContracts.length, 2, '2 error contracts') | |
| 102 | t.ok(result.errorContracts.some(e => e.includes('Unknown type')), 'template error captured') | |
| 112 | 103 | }) |
| 113 | 104 | |
| 114 | test('parser: pear-git-v3 handler factory pattern', (t) => { | |
| 115 | const source = readSource('pear-git-v3/lib/handlers/orgs.js') | |
| 116 | const result = parseFile(source, 'pear-git-v3/lib/handlers/orgs.js') | |
| 105 | test('parser: the handler-factory pattern', (t) => { | |
| 106 | const source = readSource('widget-store/lib/handlers/widgets.js') | |
| 107 | const result = parseFile(source, 'widget-store/lib/handlers/widgets.js') | |
| 117 | 108 | |
| 118 | 109 | t.absent(result.error, 'no parse error') |
| 119 | ||
| 120 | // Should have an exported function (the handler factory) | |
| 121 | 110 | const exportedFns = result.exports.filter(e => e.type === 'function') |
| 122 | t.ok(exportedFns.length > 0, 'has exported function(s)') | |
| 111 | t.is(exportedFns.length, 1, '1 exported factory') | |
| 112 | t.is(exportedFns[0].name, 'createWidgetHandlers', 'named createWidgetHandlers') | |
| 123 | 113 | }) |
| 124 | 114 | |
| 125 | test('parser: CJS module (hyper-sync)', (t) => { | |
| 126 | const source = readSource('hyper-sync/index.js') | |
| 127 | const result = parseFile(source, 'hyper-sync/index.js') | |
| 128 | ||
| 129 | t.absent(result.error, 'no parse error') | |
| 130 | ||
| 131 | // Should detect CJS exports | |
| 132 | const hasExport = result.exports.length > 0 | |
| 133 | t.ok(hasExport, 'detected CJS exports') | |
| 115 | test('parser: a CJS module', (t) => { | |
| 116 | const source = readSource('legacy-sync/index.js') | |
| 117 | const result = parseFile(source, 'legacy-sync/index.js') | |
| 134 | 118 | |
| 135 | // Should detect a class | |
| 136 | t.ok(result.classes.length > 0, 'found class(es)') | |
| 119 | t.absent(result.error, 'no parse error — script mode fallback handled it') | |
| 120 | t.ok(result.exports.length > 0, 'detected CJS exports') | |
| 121 | t.ok(result.exports[0].cjs, 'marked as CJS') | |
| 122 | t.is(result.classes.length, 1, 'found the class') | |
| 123 | t.is(result.classes[0].name, 'Syncer', 'class is Syncer') | |
| 137 | 124 | }) |
| 138 | 125 | |
| 139 | 126 | test('parser: JSDoc comment attachment', (t) => { |
| … | … | @@ -4,94 +4,87 @@ import { fileURLToPath } from 'url' |
| 4 | 4 | import { walkGraph } from '../lib/traverse.js' |
| 5 | 5 | |
| 6 | 6 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 7 | const HYPER_FLEEKY = resolve(HERE, '../../') | |
| 8 | 7 | |
| 9 | test('traverse: hyper-orgs module', async (t) => { | |
| 10 | const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-orgs')) | |
| 8 | // Was `resolve(HERE, '../../')` — the live hyper-fleeky tree. A sibling repo's | |
| 9 | // version bump turned two assertions here red (invariant I1). fixtures/workspace | |
| 10 | // is ours and cannot drift; see its README for what stands in for what. | |
| 11 | const FIXTURE = resolve(HERE, 'fixtures/workspace') | |
| 12 | ||
| 13 | test('traverse: a single module', async (t) => { | |
| 14 | const graph = await walkGraph(resolve(FIXTURE, 'widget-store')) | |
| 11 | 15 | |
| 12 | 16 | t.is(graph.modules.size, 1, '1 module found') |
| 13 | t.ok(graph.modules.has('hyper-orgs'), 'found hyper-orgs') | |
| 17 | t.ok(graph.modules.has('widget-store'), 'found widget-store') | |
| 14 | 18 | |
| 15 | const mod = graph.modules.get('hyper-orgs') | |
| 19 | const mod = graph.modules.get('widget-store') | |
| 16 | 20 | t.is(mod.version, '0.2.0', 'correct version') |
| 17 | 21 | t.ok(mod.entry.endsWith('index.js'), 'entry is index.js') |
| 18 | t.ok(mod.dependencies['hyper-rooms'], 'depends on hyper-rooms') | |
| 22 | t.ok(mod.dependencies['widget-rooms'], 'depends on widget-rooms') | |
| 19 | 23 | t.is(mod.testCommand, 'brittle test/*.test.js', 'test command') |
| 20 | 24 | t.is(mod.type, 'module', 'ESM module') |
| 21 | 25 | |
| 22 | 26 | // Files |
| 23 | 27 | t.ok(mod.files.has('index.js'), 'has index.js') |
| 24 | t.ok(mod.files.has('lib/hyper-orgs.js'), 'has lib/hyper-orgs.js') | |
| 28 | t.ok(mod.files.has('lib/widget-store.js'), 'has lib/widget-store.js') | |
| 25 | 29 | t.ok(mod.files.has('lib/schema.js'), 'has lib/schema.js') |
| 30 | t.ok(mod.files.has('lib/handlers/widgets.js'), 'walks nested directories') | |
| 31 | t.ok(mod.files.has('test/store.test.js'), 'includes the test file') | |
| 26 | 32 | |
| 27 | 33 | // File is parsed (source no longer retained — see TreeReducer.onFileDiscovered) |
| 28 | 34 | const indexFile = mod.files.get('index.js') |
| 29 | 35 | t.ok(indexFile.parsed, 'index.js has parsed result') |
| 30 | t.ok(indexFile.parsed.exports.length > 0, 'index.js has exports') | |
| 36 | t.is(indexFile.parsed.exports.length, 4, 'index.js has its 4 exports') | |
| 31 | 37 | }) |
| 32 | 38 | |
| 33 | test('traverse: hyper-orgs file relationships', async (t) => { | |
| 34 | const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-orgs')) | |
| 35 | const mod = graph.modules.get('hyper-orgs') | |
| 39 | test('traverse: file relationships', async (t) => { | |
| 40 | const graph = await walkGraph(resolve(FIXTURE, 'widget-store')) | |
| 41 | const mod = graph.modules.get('widget-store') | |
| 36 | 42 | |
| 37 | // index.js imports from lib/ | |
| 43 | // The barrel imports from lib/ | |
| 38 | 44 | const indexFile = mod.files.get('index.js') |
| 39 | t.ok(indexFile.localImports.length > 0, 'index.js has local imports') | |
| 45 | t.alike(indexFile.localImports.map(i => i.specifier).sort(), | |
| 46 | ['./lib/schema.js', './lib/widget-store.js'], 'barrel resolves both lib imports') | |
| 40 | 47 | |
| 41 | const importPaths = indexFile.localImports.map(i => i.specifier) | |
| 42 | t.ok(importPaths.includes('./lib/hyper-orgs.js'), 'imports from lib/hyper-orgs.js') | |
| 43 | t.ok(importPaths.includes('./lib/schema.js'), 'imports from lib/schema.js') | |
| 48 | // lib/widget-store.js imports schema.js — a relative edge one level down | |
| 49 | const mainFile = mod.files.get('lib/widget-store.js') | |
| 50 | t.alike(mainFile.localImports.map(i => i.specifier), ['./schema.js'], | |
| 51 | 'widget-store.js imports schema.js') | |
| 44 | 52 | |
| 45 | // lib/hyper-orgs.js imports from schema.js | |
| 46 | const mainFile = mod.files.get('lib/hyper-orgs.js') | |
| 47 | const mainImports = mainFile.localImports.map(i => i.specifier) | |
| 48 | t.ok(mainImports.includes('./schema.js'), 'hyper-orgs.js imports schema.js') | |
| 53 | // 'widget-rooms' is a package, not a local edge, so it is NOT a localImport | |
| 54 | t.absent(mainFile.localImports.find(i => i.specifier === 'widget-rooms'), | |
| 55 | 'a bare specifier is not a local import') | |
| 49 | 56 | }) |
| 50 | 57 | |
| 51 | test('traverse: hyper-deploy import maps', async (t) => { | |
| 52 | const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-deploy')) | |
| 53 | ||
| 54 | t.ok(graph.modules.size >= 1, 'found module(s)') | |
| 55 | ||
| 56 | const mod = graph.modules.get('hyper-deploy') | |
| 57 | if (!mod) { | |
| 58 | // Might be named differently | |
| 59 | const first = graph.modules.values().next().value | |
| 60 | t.ok(first.importMaps, 'module has importMaps field') | |
| 61 | return | |
| 62 | } | |
| 58 | test('traverse: import maps', async (t) => { | |
| 59 | const graph = await walkGraph(resolve(FIXTURE, 'mapped-io')) | |
| 63 | 60 | |
| 61 | const mod = graph.modules.get('mapped-io') | |
| 62 | t.ok(mod, 'found mapped-io') | |
| 64 | 63 | t.ok(mod.importMaps, 'has import maps') |
| 65 | // hyper-deploy uses #fs, #path, #os import maps | |
| 66 | const hasImportMap = Object.keys(mod.importMaps).length > 0 | |
| 67 | t.ok(hasImportMap, 'import maps are populated') | |
| 64 | t.alike(Object.keys(mod.importMaps).sort(), ['fs', 'os', 'path'], 'all three maps') | |
| 65 | t.is(mod.importMaps.fs.bare, 'bare-fs', 'fs maps to bare-fs under Bare') | |
| 66 | t.is(mod.importMaps.fs.default, 'fs', 'and to fs by default') | |
| 68 | 67 | }) |
| 69 | 68 | |
| 70 | 69 | test('traverse: multi-module scan', async (t) => { |
| 71 | // Scan a directory containing multiple modules | |
| 72 | const graph = await walkGraph(HYPER_FLEEKY, { maxDepth: 1 }) | |
| 70 | const graph = await walkGraph(FIXTURE, { maxDepth: 1 }) | |
| 73 | 71 | |
| 74 | // Should find multiple modules | |
| 75 | t.ok(graph.modules.size > 5, `found ${graph.modules.size} modules`) | |
| 72 | // Deterministic now: the old version could only assert "more than five", | |
| 73 | // because it was counting whatever happened to be checked out. | |
| 74 | t.is(graph.modules.size, 4, '4 modules') | |
| 75 | t.alike(Array.from(graph.modules.keys()).sort(), | |
| 76 | ['legacy-sync', 'mapped-io', 'widget-rooms', 'widget-store'], 'the expected set') | |
| 76 | 77 | |
| 77 | // Check some known modules exist | |
| 78 | const names = Array.from(graph.modules.keys()) | |
| 79 | t.comment(`found modules: ${names.slice(0, 10).join(', ')}...`) | |
| 80 | ||
| 81 | // Each module should have files | |
| 82 | 78 | for (const [name, mod] of graph.modules) { |
| 83 | t.ok(mod.files.size > 0, `${name} has ${mod.files.size} files`) | |
| 79 | t.ok(mod.files.size > 0, name + ' has ' + mod.files.size + ' files') | |
| 84 | 80 | } |
| 85 | 81 | }) |
| 86 | 82 | |
| 87 | test('traverse: CJS module (hyper-sync)', async (t) => { | |
| 88 | const graph = await walkGraph(resolve(HYPER_FLEEKY, 'hyper-sync')) | |
| 89 | ||
| 90 | t.ok(graph.modules.size >= 1, 'found module') | |
| 83 | test('traverse: a CJS module', async (t) => { | |
| 84 | const graph = await walkGraph(resolve(FIXTURE, 'legacy-sync')) | |
| 91 | 85 | |
| 92 | const mod = graph.modules.get('hyper-sync') | |
| 93 | if (mod) { | |
| 94 | t.is(mod.type, 'commonjs', 'detected as commonjs (no type field)') | |
| 95 | t.ok(mod.files.has('index.js'), 'has index.js') | |
| 96 | } | |
| 86 | const mod = graph.modules.get('legacy-sync') | |
| 87 | t.ok(mod, 'found legacy-sync') | |
| 88 | t.is(mod.type, 'commonjs', 'detected as commonjs (no type field)') | |
| 89 | t.ok(mod.files.has('index.js'), 'has index.js') | |
| 97 | 90 | }) |
| … | … | @@ -4,123 +4,103 @@ import { fileURLToPath } from 'url' |
| 4 | 4 | import { scan } from '../lib/scanner.js' |
| 5 | 5 | |
| 6 | 6 | const HERE = dirname(fileURLToPath(import.meta.url)) |
| 7 | const HYPER_FLEEKY = resolve(HERE, '../../') | |
| 8 | 7 | |
| 9 | test('tree: single module (hyper-orgs)', async (t) => { | |
| 10 | const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs')) | |
| 8 | // Was `resolve(HERE, '../../')` — the live hyper-fleeky tree, which put five | |
| 9 | // assertions here at the mercy of a sibling repo's version bump (invariant I1). | |
| 10 | const FIXTURE = resolve(HERE, 'fixtures/workspace') | |
| 11 | ||
| 12 | test('tree: single module', async (t) => { | |
| 13 | const tree = await scan(resolve(FIXTURE, 'widget-store')) | |
| 11 | 14 | |
| 12 | 15 | t.ok(tree.generated, 'has timestamp') |
| 13 | 16 | t.ok(tree.root, 'has root path') |
| 14 | 17 | |
| 15 | 18 | // System level |
| 16 | t.ok(tree.system.modules.includes('hyper-orgs'), 'module listed') | |
| 17 | t.ok(tree.system.dependencies['hyper-orgs'], 'has dependencies') | |
| 19 | t.ok(tree.system.modules.includes('widget-store'), 'module listed') | |
| 20 | t.ok(tree.system.dependencies['widget-store'], 'has dependencies') | |
| 18 | 21 | |
| 19 | 22 | // Module level |
| 20 | const mod = tree.modules['hyper-orgs'] | |
| 23 | const mod = tree.modules['widget-store'] | |
| 21 | 24 | t.ok(mod, 'module entry exists') |
| 22 | t.is(mod.name, 'hyper-orgs', 'correct name') | |
| 25 | t.is(mod.name, 'widget-store', 'correct name') | |
| 23 | 26 | t.is(mod.version, '0.2.0', 'correct version') |
| 24 | 27 | t.is(mod.type, 'module', 'ESM') |
| 25 | 28 | t.is(mod.entry, 'index.js', 'entry point') |
| 26 | t.ok(mod.testCommand, 'has test command') | |
| 29 | t.is(mod.testCommand, 'brittle test/*.test.js', 'has test command') | |
| 27 | 30 | |
| 28 | // Public API | |
| 29 | t.ok(mod.publicAPI.includes('HyperOrgs'), 'public API has HyperOrgs') | |
| 30 | t.ok(mod.publicAPI.includes('orgsSchema'), 'public API has orgsSchema') | |
| 31 | t.ok(mod.publicAPI.includes('validateName'), 'public API has validateName') | |
| 32 | t.ok(mod.publicAPI.includes('RESERVED_NAMES'), 'public API has RESERVED_NAMES') | |
| 31 | // Public API comes from the entry file's exports | |
| 32 | t.alike(mod.publicAPI, ['WidgetStore', 'storeSchema', 'RESERVED_NAMES', 'validateName'], | |
| 33 | 'public API is the barrel, in source order') | |
| 33 | 34 | |
| 34 | 35 | // Files |
| 35 | 36 | t.ok(mod.files['index.js'], 'has index.js') |
| 36 | t.is(mod.files['index.js'].role, 'entry', 'index.js is entry') | |
| 37 | t.ok(mod.files['lib/hyper-orgs.js'], 'has lib/hyper-orgs.js') | |
| 38 | t.is(mod.files['lib/hyper-orgs.js'].role, 'lib', 'hyper-orgs.js is lib') | |
| 37 | t.ok(mod.files['lib/widget-store.js'], 'has lib/widget-store.js') | |
| 39 | 38 | t.ok(mod.files['lib/schema.js'], 'has lib/schema.js') |
| 40 | t.is(mod.files['lib/schema.js'].role, 'schema', 'schema.js is schema') | |
| 41 | 39 | }) |
| 42 | 40 | |
| 43 | test('tree: file-level detail (hyper-orgs/lib/hyper-orgs.js)', async (t) => { | |
| 44 | const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs')) | |
| 45 | const file = tree.modules['hyper-orgs'].files['lib/hyper-orgs.js'] | |
| 41 | test('tree: file-level detail', async (t) => { | |
| 42 | const tree = await scan(resolve(FIXTURE, 'widget-store')) | |
| 43 | const file = tree.modules['widget-store'].files['lib/widget-store.js'] | |
| 46 | 44 | |
| 47 | 45 | // Imports |
| 48 | t.ok(file.imports.length > 0, 'has imports') | |
| 49 | const roomImport = file.imports.find(i => i.specifier === 'hyper-rooms') | |
| 50 | t.ok(roomImport, 'imports hyper-rooms') | |
| 46 | t.is(file.imports.length, 2, '2 imports') | |
| 47 | const roomImport = file.imports.find(i => i.specifier === 'widget-rooms') | |
| 48 | t.ok(roomImport, 'imports widget-rooms') | |
| 51 | 49 | |
| 52 | 50 | // Exports |
| 53 | 51 | t.is(file.exports.length, 1, '1 export') |
| 54 | t.is(file.exports[0].name, 'HyperOrgs', 'exports HyperOrgs') | |
| 52 | t.is(file.exports[0].name, 'WidgetStore', 'exports WidgetStore') | |
| 55 | 53 | t.is(file.exports[0].type, 'class', 'type is class') |
| 56 | 54 | |
| 57 | 55 | // Class structure |
| 58 | t.ok(file.classes, 'has classes') | |
| 59 | 56 | t.is(file.classes.length, 1, '1 class') |
| 60 | ||
| 61 | 57 | const cls = file.classes[0] |
| 62 | t.is(cls.name, 'HyperOrgs', 'class name') | |
| 58 | t.is(cls.name, 'WidgetStore', 'class name') | |
| 63 | 59 | t.ok(cls.constructor, 'has constructor') |
| 64 | t.ok(cls.methods.length > 10, `has ${cls.methods.length} methods`) | |
| 65 | t.ok(cls.getters.length > 3, `has ${cls.getters.length} getters`) | |
| 60 | t.is(cls.methods.length, 13, '13 methods') | |
| 61 | t.is(cls.getters.length, 6, '6 getters') | |
| 66 | 62 | |
| 67 | // State and delegation | |
| 68 | t.ok(file.state, 'has state') | |
| 63 | // State and delegation survive into the tree, not just the parser | |
| 69 | 64 | t.ok(file.state.includes('_room'), 'state includes _room') |
| 70 | t.ok(file.delegates, 'has delegates') | |
| 71 | t.ok(file.delegates.length > 0, 'has delegation patterns') | |
| 65 | t.ok(file.delegates.find(d => d.field === '_room'), 'delegates to _room') | |
| 72 | 66 | |
| 73 | 67 | // Error contracts |
| 74 | t.ok(file.errorContracts, 'has error contracts') | |
| 75 | t.ok(file.errorContracts.length > 0, 'has specific errors') | |
| 68 | t.is(file.errorContracts.length, 2, '2 error contracts') | |
| 76 | 69 | }) |
| 77 | 70 | |
| 78 | 71 | test('tree: multi-module scan with shared deps', async (t) => { |
| 79 | const tree = await scan(HYPER_FLEEKY, { maxDepth: 1 }) | |
| 72 | const tree = await scan(FIXTURE, { maxDepth: 1 }) | |
| 80 | 73 | |
| 81 | // System level | |
| 82 | t.ok(tree.system.modules.length > 10, `${tree.system.modules.length} modules`) | |
| 83 | ||
| 84 | // Shared deps | |
| 85 | t.ok(tree.system.sharedDeps, 'has shared deps') | |
| 86 | const sharedNames = Object.keys(tree.system.sharedDeps) | |
| 87 | t.ok(sharedNames.length > 0, `${sharedNames.length} shared deps`) | |
| 88 | t.comment(`shared deps: ${sharedNames.slice(0, 5).join(', ')}...`) | |
| 89 | ||
| 90 | // Verify some modules have parsed file data | |
| 91 | const orgs = tree.modules['hyper-orgs'] | |
| 92 | if (orgs) { | |
| 93 | t.ok(orgs.files['lib/hyper-orgs.js'].classes, 'hyper-orgs has parsed class data') | |
| 94 | } | |
| 95 | ||
| 96 | const issues = tree.modules['hyper-issues'] | |
| 97 | if (issues) { | |
| 98 | t.ok(issues.publicAPI.length > 0, `hyper-issues has public API: ${issues.publicAPI.join(', ')}`) | |
| 99 | } | |
| 74 | t.is(tree.system.modules.length, 4, '4 modules') | |
| 75 | ||
| 76 | // A shared dep is one used by 2+ modules — here b4a, by both ESM packages. | |
| 77 | t.alike(tree.system.sharedDeps, { b4a: ['widget-rooms', 'widget-store'] }, | |
| 78 | 'b4a is the shared dep, with both users named') | |
| 79 | ||
| 80 | // Parsed file data survives a multi-module scan (it once did not) | |
| 81 | t.ok(tree.modules['widget-store'].files['lib/widget-store.js'].classes, | |
| 82 | 'widget-store has parsed class data') | |
| 83 | t.ok(tree.modules['widget-rooms'].publicAPI.includes('Room'), | |
| 84 | 'widget-rooms exposes Room') | |
| 100 | 85 | }) |
| 101 | 86 | |
| 102 | 87 | test('tree: dependency graph is populated', async (t) => { |
| 103 | const tree = await scan(HYPER_FLEEKY, { maxDepth: 1 }) | |
| 104 | ||
| 105 | const depsEntries = Object.entries(tree.system.dependencies) | |
| 106 | t.ok(depsEntries.length > 0, `${depsEntries.length} modules have deps`) | |
| 88 | const tree = await scan(FIXTURE, { maxDepth: 1 }) | |
| 107 | 89 | |
| 108 | // hyper-orgs depends on hyper-rooms | |
| 109 | const orgDeps = tree.system.dependencies['hyper-orgs'] | |
| 110 | if (orgDeps) { | |
| 111 | t.ok(orgDeps.includes('hyper-rooms'), 'hyper-orgs -> hyper-rooms') | |
| 112 | } | |
| 90 | // No `if (deps)` guard any more: the fixture cannot stop depending on this. | |
| 91 | t.alike(tree.system.dependencies['widget-store'], ['widget-rooms', 'b4a'], | |
| 92 | 'widget-store -> widget-rooms, b4a') | |
| 93 | t.alike(tree.system.dependencies['widget-rooms'], ['b4a'], | |
| 94 | 'widget-rooms -> b4a') | |
| 113 | 95 | }) |
| 114 | 96 | |
| 115 | 97 | test('tree: file roles are correctly classified', async (t) => { |
| 116 | const tree = await scan(resolve(HYPER_FLEEKY, 'hyper-orgs')) | |
| 117 | const files = tree.modules['hyper-orgs'].files | |
| 98 | const tree = await scan(resolve(FIXTURE, 'widget-store')) | |
| 99 | const files = tree.modules['widget-store'].files | |
| 118 | 100 | |
| 119 | 101 | t.is(files['index.js'].role, 'entry', 'index.js = entry') |
| 120 | t.is(files['lib/hyper-orgs.js'].role, 'lib', 'lib/*.js = lib') | |
| 121 | t.is(files['lib/schema.js'].role, 'schema', 'schema.js = schema') | |
| 122 | ||
| 123 | // Check for test files if they exist | |
| 124 | const testFiles = Object.entries(files).filter(([_, f]) => f.role === 'test') | |
| 125 | t.comment(`test files: ${testFiles.length}`) | |
| 102 | t.is(files['lib/widget-store.js'].role, 'lib', 'lib/*.js = lib') | |
| 103 | t.is(files['lib/schema.js'].role, 'schema', 'anything named schema = schema') | |
| 104 | t.is(files['lib/handlers/widgets.js'].role, 'lib', 'nested under lib/ = lib') | |
| 105 | t.is(files['test/store.test.js'].role, 'test', 'test/*.test.js = test') | |
| 126 | 106 | }) |