mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
13b6acab16
* feat(stacks): copy stack anatomy as Markdown Add a "copy md" action to the Stack Anatomy panel that exports the parsed compose summary as a deterministic Markdown document: services, ports and volumes tables, restart policy, env file with its variable count, missing variables, network, and source. Useful for pasting a stack's wiring into a README, a Git repository, Obsidian, BookStack, or a support thread. The export carries only env variable names and counts, never the values stored in .env files. * fix(stacks): escape backslashes in anatomy Markdown table cells escapeCell escaped pipes but not the backslash itself, so a backslash in a cell value (for example a Windows path) could defeat the pipe escaping and break the rendered table row. Escape the backslash first, then pipes, then collapse newlines. Adds a regression test. * fix(stacks): correct git source attribution and CR handling in anatomy export Gate the Anatomy panel's git source on its owning stack so a slow git-source response for a previously selected stack can no longer render or be exported under a different stack. Also collapse lone carriage returns (not just CRLF and LF) in Markdown table cells so a stray CR cannot break a table row.
197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
|
|
|
|
const fullInput: AnatomyMarkdownInput = {
|
|
stackName: 'mediawiki',
|
|
services: ['web', 'db'],
|
|
ports: {
|
|
web: [
|
|
{ host: '8080', container: '80', proto: 'tcp' },
|
|
{ host: '8443', container: '443', proto: 'tcp' },
|
|
],
|
|
},
|
|
volumes: {
|
|
db: [{ host: './data', container: '/var/lib/mysql' }],
|
|
},
|
|
restart: 'unless-stopped',
|
|
envFile: '.env',
|
|
envVarCount: 12,
|
|
missingVars: ['DB_PASSWORD', 'SECRET_KEY'],
|
|
networkName: 'mediawiki_default',
|
|
gitSource: 'github.com/me/wiki#main',
|
|
};
|
|
|
|
const emptyInput: AnatomyMarkdownInput = {
|
|
stackName: 'blank',
|
|
services: [],
|
|
ports: {},
|
|
volumes: {},
|
|
restart: null,
|
|
envFile: null,
|
|
envVarCount: 0,
|
|
missingVars: [],
|
|
networkName: 'blank_default',
|
|
gitSource: null,
|
|
};
|
|
|
|
describe('buildStackAnatomyMarkdown', () => {
|
|
it('renders a full anatomy as tables, headings, and lists', () => {
|
|
expect(buildStackAnatomyMarkdown(fullInput)).toBe(
|
|
[
|
|
'# mediawiki',
|
|
'',
|
|
'## Services',
|
|
'- `web`',
|
|
'- `db`',
|
|
'',
|
|
'## Ports',
|
|
'| Service | Host | Container | Protocol |',
|
|
'| --- | --- | --- | --- |',
|
|
'| web | 8080 | 80 | tcp |',
|
|
'| web | 8443 | 443 | tcp |',
|
|
'',
|
|
'## Volumes',
|
|
'| Service | Host | Container |',
|
|
'| --- | --- | --- |',
|
|
'| db | ./data | /var/lib/mysql |',
|
|
'',
|
|
'## Restart policy',
|
|
'`unless-stopped`',
|
|
'',
|
|
'## Environment',
|
|
'- File: `.env`',
|
|
'- Variables: 12',
|
|
'- Missing: `DB_PASSWORD`, `SECRET_KEY`',
|
|
'',
|
|
'## Network',
|
|
'`mediawiki_default` (bridge)',
|
|
'',
|
|
'## Source',
|
|
'git · github.com/me/wiki#main',
|
|
].join('\n'),
|
|
);
|
|
});
|
|
|
|
it('renders clean empty-state placeholders', () => {
|
|
expect(buildStackAnatomyMarkdown(emptyInput)).toBe(
|
|
[
|
|
'# blank',
|
|
'',
|
|
'## Services',
|
|
'_none defined_',
|
|
'',
|
|
'## Ports',
|
|
'_none_',
|
|
'',
|
|
'## Volumes',
|
|
'_none_',
|
|
'',
|
|
'## Restart policy',
|
|
'_default_',
|
|
'',
|
|
'## Environment',
|
|
'- File: _none_',
|
|
'',
|
|
'## Network',
|
|
'`blank_default` (bridge)',
|
|
'',
|
|
'## Source',
|
|
'local',
|
|
].join('\n'),
|
|
);
|
|
});
|
|
|
|
it('keeps missing env vars visible', () => {
|
|
const md = buildStackAnatomyMarkdown(fullInput);
|
|
expect(md).toContain('- Missing: `DB_PASSWORD`, `SECRET_KEY`');
|
|
});
|
|
|
|
it('emits only the env variable count and key names, never values', () => {
|
|
const md = buildStackAnatomyMarkdown(fullInput);
|
|
expect(md).toContain('- Variables: 12');
|
|
expect(md).toContain('`DB_PASSWORD`');
|
|
// The builder has no access to env values, so no `KEY=value` assignment can appear.
|
|
expect(md).not.toMatch(/DB_PASSWORD\s*=/);
|
|
});
|
|
|
|
it('escapes pipe characters in volume table cells', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['svc'],
|
|
volumes: { svc: [{ host: '/data|weird', container: '/mnt' }] },
|
|
});
|
|
expect(md).toContain('| svc | /data\\|weird | /mnt |');
|
|
});
|
|
|
|
it('escapes pipe characters in port table cells', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['svc'],
|
|
ports: { svc: [{ host: '127.0.0.1|x', container: '80', proto: 'tcp' }] },
|
|
});
|
|
expect(md).toContain('| svc | 127.0.0.1\\|x | 80 | tcp |');
|
|
});
|
|
|
|
it('escapes backslashes before pipes so the escaping cannot be defeated', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['svc'],
|
|
// host `C:\data` -> `C:\\data`; container `a\|b` -> backslash doubled then pipe escaped -> `a\\\|b`
|
|
volumes: { svc: [{ host: 'C:\\data', container: 'a\\|b' }] },
|
|
});
|
|
expect(md).toContain('| svc | C:\\\\data | a\\\\\\|b |');
|
|
});
|
|
|
|
it('collapses newlines in a table cell to a single space', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['svc'],
|
|
volumes: { svc: [{ host: 'a\nb', container: '/mnt' }] },
|
|
});
|
|
expect(md).toContain('| svc | a b | /mnt |');
|
|
});
|
|
|
|
it('collapses CRLF and lone carriage returns in a table cell', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['svc'],
|
|
volumes: { svc: [{ host: 'a\r\nb', container: 'c\rd' }] },
|
|
});
|
|
expect(md).toContain('| svc | a b | c d |');
|
|
});
|
|
|
|
it('flattens rows from multiple services into one table', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
services: ['web', 'db'],
|
|
ports: {
|
|
web: [{ host: '8080', container: '80', proto: 'tcp' }],
|
|
db: [{ host: '5432', container: '5432', proto: 'tcp' }],
|
|
},
|
|
});
|
|
expect(md).toContain('| web | 8080 | 80 | tcp |');
|
|
expect(md).toContain('| db | 5432 | 5432 | tcp |');
|
|
});
|
|
|
|
it('omits the Missing line when an env file has no missing vars', () => {
|
|
const md = buildStackAnatomyMarkdown({
|
|
...emptyInput,
|
|
envFile: '.env',
|
|
envVarCount: 3,
|
|
missingVars: [],
|
|
});
|
|
expect(md).toContain('## Environment\n- File: `.env`\n- Variables: 3');
|
|
expect(md).not.toContain('- Missing:');
|
|
});
|
|
|
|
it('is deterministic across distinct but equal inputs', () => {
|
|
const a = buildStackAnatomyMarkdown(fullInput);
|
|
const b = buildStackAnatomyMarkdown({
|
|
...fullInput,
|
|
ports: { ...fullInput.ports },
|
|
volumes: { ...fullInput.volumes },
|
|
});
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|