mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 23:54:24 +00:00
373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const yaml = require('js-yaml');
|
|
const {
|
|
reorderWorkspaceCollections,
|
|
setCollectionGitRemote,
|
|
clearCollectionGitRemote,
|
|
getWorkspaceCollections
|
|
} = require('../../src/utils/workspace-config');
|
|
|
|
const collection = (name, pathSegment, extra = {}) => ({ name, path: pathSegment, ...extra });
|
|
|
|
describe('reorderWorkspaceCollections', () => {
|
|
let workspacePath;
|
|
|
|
/** Writes workspace.yml with the given collections (relative paths). */
|
|
const writeWorkspaceYml = (collections) => {
|
|
const content = [
|
|
'opencollection: 1.0.0',
|
|
'info:',
|
|
' name: Test',
|
|
' type: workspace',
|
|
'collections:',
|
|
...collections.flatMap((c) => [` - name: ${c.name}`, ` path: ${c.path}`]),
|
|
'specs: []',
|
|
'docs: \'\''
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), content);
|
|
};
|
|
|
|
/** Returns collection paths (relative) in order as stored in workspace.yml. */
|
|
const getCollectionPathsFromYml = () => {
|
|
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
|
const config = yaml.load(raw);
|
|
return (config.collections || []).map((c) => c.path);
|
|
};
|
|
|
|
/** Resolves a relative collection path segment to an absolute path under the current workspace. */
|
|
const absPath = (relativePath) => path.resolve(workspacePath, relativePath);
|
|
|
|
beforeEach(() => {
|
|
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('reorders collections to match given path list', async () => {
|
|
writeWorkspaceYml([
|
|
collection('API', 'collections/api'),
|
|
collection('Backend', 'collections/backend'),
|
|
collection('Frontend', 'collections/frontend')
|
|
]);
|
|
|
|
await reorderWorkspaceCollections(workspacePath, [
|
|
absPath('collections/frontend'),
|
|
absPath('collections/api'),
|
|
absPath('collections/backend')
|
|
]);
|
|
|
|
expect(getCollectionPathsFromYml()).toEqual(['collections/frontend', 'collections/api', 'collections/backend']);
|
|
});
|
|
|
|
test('deduplicates when reorder list contains duplicate paths', async () => {
|
|
writeWorkspaceYml([
|
|
collection('API', 'collections/api'),
|
|
collection('Backend', 'collections/backend')
|
|
]);
|
|
|
|
await reorderWorkspaceCollections(workspacePath, [
|
|
absPath('collections/api'),
|
|
absPath('collections/backend'),
|
|
absPath('collections/api'),
|
|
absPath('collections/api')
|
|
]);
|
|
|
|
expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']);
|
|
});
|
|
});
|
|
|
|
describe('Git remote on workspace collections', () => {
|
|
let workspacePath;
|
|
|
|
const writeYml = (collections) => {
|
|
const lines = [
|
|
'opencollection: 1.0.0',
|
|
'info:',
|
|
' name: Test',
|
|
' type: workspace',
|
|
'collections:'
|
|
];
|
|
for (const c of collections) {
|
|
lines.push(` - name: "${c.name}"`);
|
|
lines.push(` path: "${c.path}"`);
|
|
if (c.remote) lines.push(` remote: "${c.remote}"`);
|
|
}
|
|
lines.push('specs: []');
|
|
lines.push('docs: \'\'');
|
|
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), lines.join('\n'));
|
|
};
|
|
|
|
const readCollectionsFromYml = () => {
|
|
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
|
return (yaml.load(raw).collections || []);
|
|
};
|
|
|
|
const absPath = (relativePath) => path.resolve(workspacePath, relativePath);
|
|
|
|
const ensureCollectionDir = (relativePath) => {
|
|
const dir = path.join(workspacePath, relativePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' }));
|
|
};
|
|
|
|
beforeEach(() => {
|
|
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-git-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('setCollectionGitRemote sets remote on the matching entry only', async () => {
|
|
writeYml([
|
|
collection('API', 'collections/api'),
|
|
collection('Backend', 'collections/backend')
|
|
]);
|
|
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/backend'), 'https://github.com/x/backend');
|
|
|
|
const entries = readCollectionsFromYml();
|
|
expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' });
|
|
expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' });
|
|
});
|
|
|
|
test('setCollectionGitRemote rejects empty URL', async () => {
|
|
writeYml([collection('API', 'collections/api')]);
|
|
await expect(
|
|
setCollectionGitRemote(workspacePath, absPath('collections/api'), ' ')
|
|
).rejects.toThrow(/non-empty/i);
|
|
});
|
|
|
|
test('setCollectionGitRemote throws when collection is missing from workspace.yml', async () => {
|
|
writeYml([collection('API', 'collections/api')]);
|
|
await expect(
|
|
setCollectionGitRemote(workspacePath, absPath('collections/missing'), 'https://github.com/x/y')
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
test('clearCollectionGitRemote removes only the remote field', async () => {
|
|
writeYml([
|
|
collection('API', 'collections/api', { remote: 'https://github.com/x/api' }),
|
|
collection('Backend', 'collections/backend', { remote: 'https://github.com/x/backend' })
|
|
]);
|
|
|
|
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
|
|
|
const entries = readCollectionsFromYml();
|
|
expect(entries[0]).toEqual({ name: 'API', path: 'collections/api' });
|
|
expect(entries[1]).toEqual({ name: 'Backend', path: 'collections/backend', remote: 'https://github.com/x/backend' });
|
|
});
|
|
|
|
test('getWorkspaceCollections keeps git-backed entries even when local folder is missing', () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([
|
|
collection('API', 'collections/api'),
|
|
collection('Missing', 'collections/missing', { remote: 'https://github.com/x/missing' })
|
|
]);
|
|
|
|
const result = getWorkspaceCollections(workspacePath);
|
|
|
|
expect(result).toHaveLength(2);
|
|
const api = result.find((r) => r.name === 'API');
|
|
const missing = result.find((r) => r.name === 'Missing');
|
|
expect(api.notFoundLocally).toBeUndefined();
|
|
expect(missing.notFoundLocally).toBe(true);
|
|
expect(missing.remote).toBe('https://github.com/x/missing');
|
|
});
|
|
|
|
test('getWorkspaceCollections still drops missing entries that have no remote', () => {
|
|
writeYml([collection('Missing', 'collections/missing')]);
|
|
expect(getWorkspaceCollections(workspacePath)).toHaveLength(0);
|
|
});
|
|
|
|
test('setCollectionGitRemote adds the collection path to .gitignore', async () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([collection('API', 'collections/api')]);
|
|
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
|
|
|
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
|
expect(gitignore.split('\n')).toContain('collections/api/');
|
|
});
|
|
|
|
test('setCollectionGitRemote does not duplicate the .gitignore entry on repeated calls', async () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([collection('API', 'collections/api')]);
|
|
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api-renamed');
|
|
|
|
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
|
const matches = gitignore.split('\n').filter((line) => line.trim() === 'collections/api/');
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
test('setCollectionGitRemote preserves existing .gitignore content', async () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([collection('API', 'collections/api')]);
|
|
fs.writeFileSync(path.join(workspacePath, '.gitignore'), '# user notes\nnode_modules\n.env\n');
|
|
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
|
|
|
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
|
expect(gitignore).toContain('# user notes');
|
|
expect(gitignore).toContain('node_modules');
|
|
expect(gitignore).toContain('.env');
|
|
expect(gitignore).toContain('collections/api/');
|
|
});
|
|
|
|
test('clearCollectionGitRemote removes the collection path from .gitignore', async () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([collection('API', 'collections/api', { remote: 'https://github.com/x/api' })]);
|
|
fs.writeFileSync(path.join(workspacePath, '.gitignore'), [
|
|
'node_modules',
|
|
'# Bruno managed collection remotes',
|
|
'collections/api/',
|
|
'# End Bruno managed collection remotes',
|
|
''
|
|
].join('\n'));
|
|
|
|
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
|
|
|
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
|
expect(gitignore.split('\n')).not.toContain('collections/api/');
|
|
expect(gitignore).toContain('node_modules');
|
|
});
|
|
|
|
test('clearCollectionGitRemote preserves user-owned .gitignore entries', async () => {
|
|
ensureCollectionDir('collections/api');
|
|
writeYml([collection('API', 'collections/api')]);
|
|
fs.writeFileSync(path.join(workspacePath, '.gitignore'), 'node_modules\ncollections/api/\n');
|
|
|
|
await setCollectionGitRemote(workspacePath, absPath('collections/api'), 'https://github.com/x/api');
|
|
await clearCollectionGitRemote(workspacePath, absPath('collections/api'));
|
|
|
|
const gitignore = fs.readFileSync(path.join(workspacePath, '.gitignore'), 'utf8');
|
|
expect(gitignore.split('\n')).toContain('collections/api/');
|
|
expect(gitignore).toContain('node_modules');
|
|
});
|
|
|
|
test('setCollectionGitRemote skips .gitignore for collections outside the workspace', async () => {
|
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-outside-'));
|
|
fs.writeFileSync(path.join(outsideDir, 'bruno.json'), JSON.stringify({ name: 'x', version: '1', type: 'collection' }));
|
|
try {
|
|
writeYml([collection('External', outsideDir)]);
|
|
await setCollectionGitRemote(workspacePath, outsideDir, 'https://github.com/x/external');
|
|
|
|
const gitignorePath = path.join(workspacePath, '.gitignore');
|
|
if (fs.existsSync(gitignorePath)) {
|
|
const gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
|
expect(gitignore).not.toContain(outsideDir);
|
|
}
|
|
} finally {
|
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('workspace specs normalization', () => {
|
|
const {
|
|
readWorkspaceConfig,
|
|
addApiSpecToWorkspace,
|
|
removeApiSpecFromWorkspace
|
|
} = require('../../src/utils/workspace-config');
|
|
let workspacePath;
|
|
|
|
// Writes workspace.yml with a verbatim `specs:` block so we control its YAML shape.
|
|
const writeWorkspaceYml = (specsYaml) => {
|
|
const content = [
|
|
'opencollection: 1.0.0',
|
|
'info:',
|
|
' name: Test',
|
|
' type: workspace',
|
|
'collections: []',
|
|
specsYaml,
|
|
'docs: \'\''
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), content);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
});
|
|
|
|
// --- Regression guard: the `|| []` -> `Array.isArray(...) ? ... : []` swap must
|
|
// preserve behavior for every VALID shape, and only change non-array inputs. ---
|
|
describe('readWorkspaceConfig coerces specs to an array', () => {
|
|
const cases = [
|
|
{
|
|
name: 'valid populated list is preserved unchanged',
|
|
yaml: ['specs:', ' - name: foo', ' path: foo.yaml', ' - name: bar', ' path: bar.yaml'].join('\n'),
|
|
expected: [
|
|
{ name: 'foo', path: 'foo.yaml' },
|
|
{ name: 'bar', path: 'bar.yaml' }
|
|
]
|
|
},
|
|
{ name: 'empty list stays empty', yaml: 'specs: []', expected: [] },
|
|
{ name: 'missing specs key -> []', yaml: '# no specs key', expected: [] },
|
|
{ name: 'null specs -> []', yaml: 'specs: null', expected: [] },
|
|
{ name: 'map (object) specs -> []', yaml: ['specs:', ' brokenEntry: not a list'].join('\n'), expected: [] },
|
|
{ name: 'string specs -> []', yaml: 'specs: "oops a string"', expected: [] },
|
|
{ name: 'number specs -> []', yaml: 'specs: 42', expected: [] },
|
|
{ name: 'boolean specs -> []', yaml: 'specs: true', expected: [] },
|
|
{
|
|
// An array of junk is still an array: coercion preserves it (no crash on .map);
|
|
// invalid entries are dropped later by sanitizeSpecs on write, not here.
|
|
name: 'array with non-object elements is preserved as-is',
|
|
yaml: 'specs: [1, "two", null]',
|
|
expected: [1, 'two', null]
|
|
}
|
|
];
|
|
|
|
test.each(cases)('$name', ({ yaml, expected }) => {
|
|
writeWorkspaceYml(yaml);
|
|
const config = readWorkspaceConfig(workspacePath);
|
|
// Both the legacy `specs` field and the renderer-facing `apiSpecs` must be arrays.
|
|
expect(Array.isArray(config.specs)).toBe(true);
|
|
expect(Array.isArray(config.apiSpecs)).toBe(true);
|
|
expect(config.specs).toEqual(expected);
|
|
expect(config.apiSpecs).toEqual(expected);
|
|
// apiSpecs mirrors specs by value but is a distinct array, so an in-place
|
|
// mutation of one field can't silently change the other.
|
|
expect(config.apiSpecs).not.toBe(config.specs);
|
|
});
|
|
});
|
|
|
|
// --- Write paths must not throw on an already-malformed workspace.yml and must self-heal. ---
|
|
describe('write paths survive a malformed (non-array) specs', () => {
|
|
const malformedYaml = ['specs:', ' brokenEntry: not a list'].join('\n');
|
|
const specsInYml = () => {
|
|
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
|
|
return yaml.load(raw).specs;
|
|
};
|
|
|
|
test('addApiSpecToWorkspace does not throw and writes a valid list', async () => {
|
|
writeWorkspaceYml(malformedYaml);
|
|
const specPath = path.join(workspacePath, 'api.yaml');
|
|
await expect(
|
|
addApiSpecToWorkspace(workspacePath, { name: 'api', path: specPath })
|
|
).resolves.toBeDefined();
|
|
|
|
const stored = specsInYml();
|
|
expect(Array.isArray(stored)).toBe(true);
|
|
expect(stored).toEqual([{ name: 'api', path: 'api.yaml' }]);
|
|
});
|
|
|
|
test('removeApiSpecFromWorkspace does not throw on malformed specs', async () => {
|
|
writeWorkspaceYml(malformedYaml);
|
|
const result = await removeApiSpecFromWorkspace(workspacePath, path.join(workspacePath, 'whatever.yaml'));
|
|
expect(result.removedApiSpec).toBeNull();
|
|
// Round-trip through readWorkspaceConfig (which coerces) must yield a safe array.
|
|
expect(Array.isArray(readWorkspaceConfig(workspacePath).specs)).toBe(true);
|
|
});
|
|
});
|
|
});
|