Files
bruno/packages/bruno-electron/tests/utils/workspace-config.spec.js

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);
});
});
});