Skip to content

Commit 65f4665

Browse files
committed
2 parents 479d321 + 8fe3e9b commit 65f4665

72 files changed

Lines changed: 2367 additions & 449 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/jest.config.cjs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
const baseConfig = require('../../jest.config.cjs');
2+
13
module.exports = {
2-
testEnvironment: "node",
3-
testMatch: ["<rootDir>/src/*.test.js"],
4-
transform: {},
4+
...baseConfig,
5+
testEnvironment: 'node',
6+
rootDir: '../..',
7+
testMatch: ['**/apps/api/src/*.test.js'],
58
};

apps/cli/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ Write to an output file:
6161
anywaydata generate -i exampleTestDataSpec.txt -n 3 -o output.csv
6262
```
6363

64+
Write CRLF line endings and a UTF-8 BOM to an output file:
65+
66+
```bash
67+
anywaydata generate -i exampleTestDataSpec.txt -n 3 -o output.csv --line-endings crlf --bom
68+
```
69+
6470
Redirect stdout:
6571

6672
```bash
@@ -82,6 +88,8 @@ anywaydata amend --schema-file schema.txt --data-file input.csv --input-format c
8288
- `-n, --numberOfLines` row count (required, overridden to `1` by `--testMode`)
8389
- `-f, --format` output format (e.g. `csv`, `json`, `jsonl`, `xml`, `sql`)
8490
- `-o, --outputfile` optional output file path
91+
- `--line-endings` file-output line endings: `lf` or `crlf` (defaults to the current OS)
92+
- `--bom` write a UTF-8 BOM when output is written to a file
8593
- `-t, --testMode` generate one row and print diagnostics/example output
8694
- `--show-progress` force progress logs on/off
8795
- `--stream` enable stream mode when supported

apps/cli/jest.config.cjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
const baseConfig = require('../../jest.config.cjs');
2+
13
module.exports = {
2-
verbose: true,
4+
...baseConfig,
35
testEnvironment: 'node',
4-
transform: {
5-
'^.+\\.js$': 'babel-jest',
6-
},
7-
testMatch: ['<rootDir>/src/tests/**/*.test.js'],
6+
rootDir: '../..',
7+
testMatch: ['**/apps/cli/src/tests/**/*.test.js'],
88
};

apps/cli/src/cli-options.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export function parseCliOptions(argvInput = process.argv) {
4949
default: false,
5050
describe: 'Allow expression-style faker arguments (unsafe for untrusted input)',
5151
})
52+
.option('line-endings', {
53+
type: 'string',
54+
choices: ['lf', 'crlf'],
55+
describe: 'Line endings for file output: lf or crlf (defaults to the current OS)',
56+
})
57+
.option('bom', {
58+
type: 'boolean',
59+
default: false,
60+
describe: 'Write a UTF-8 BOM when output is written to a file',
61+
})
5262
.option('pairwise', {
5363
type: 'boolean',
5464
default: false,
@@ -79,6 +89,8 @@ export function parseCliOptions(argvInput = process.argv) {
7989
testMode: parsed.testMode === true,
8090
showProgress,
8191
shouldStream,
92+
lineEndings: parsed['line-endings'] ? String(parsed['line-endings']).toLowerCase() : undefined,
93+
bom: parsed.bom === true,
8294
unsafeFakerExpressions: parsed['unsafe-faker-expressions'] === true,
8395
pairwise: parsed.pairwise === true,
8496
};

apps/cli/src/platform/bun-platform.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
1+
import { applyExportTextEncoding, resolveExportTextEncodingSettings } from '@anywaydata/core';
2+
13
export function createBunPlatform() {
24
const bunRef = globalThis.Bun;
35
return {
46
name: 'bun',
7+
getRuntimePlatform() {
8+
return process.platform;
9+
},
510
async readText(path) {
611
const file = bunRef.file(path);
712
return file.text();
813
},
9-
async writeText(path, content) {
10-
await bunRef.write(path, content);
14+
async writeText(path, content, exportEncodingSettings = {}) {
15+
await bunRef.write(
16+
path,
17+
applyExportTextEncoding(content, exportEncodingSettings, { platform: process.platform })
18+
);
1119
},
12-
createLineWriter(path) {
20+
createLineWriter(path, exportEncodingSettings = {}) {
21+
const resolvedEncodingSettings = resolveExportTextEncodingSettings(exportEncodingSettings, {
22+
platform: process.platform,
23+
});
24+
const lineSuffix = resolvedEncodingSettings.lineEnding === 'crlf' ? '\r\n' : '\n';
1325
const writer = bunRef.file(path).writer();
1426
let writerError = null;
1527
let closed = false;
28+
let wroteBom = false;
1629
return {
1730
async writeLine(line) {
1831
if (closed) {
@@ -22,7 +35,9 @@ export function createBunPlatform() {
2235
throw writerError;
2336
}
2437
try {
25-
writer.write(`${line}\n`);
38+
const prefix = !wroteBom && resolvedEncodingSettings.includeBom ? '\uFEFF' : '';
39+
writer.write(`${prefix}${String(line ?? '')}${lineSuffix}`);
40+
wroteBom = true;
2641
} catch (error) {
2742
writerError = error;
2843
throw error;

apps/cli/src/platform/node-platform.js

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,63 @@
11
import fs from 'node:fs/promises';
22
import { createWriteStream } from 'node:fs';
3+
import { applyExportTextEncoding, resolveExportTextEncodingSettings } from '@anywaydata/core';
4+
5+
function waitForStreamEvent(stream, eventName, startOperation, { shouldResolveImmediately = () => false } = {}) {
6+
return new Promise((resolve, reject) => {
7+
let settled = false;
8+
9+
const cleanup = () => {
10+
stream.off(eventName, handleEvent);
11+
stream.off('error', handleError);
12+
};
13+
14+
const settle = (callback, value) => {
15+
if (settled) {
16+
return;
17+
}
18+
settled = true;
19+
cleanup();
20+
callback(value);
21+
};
22+
23+
const handleEvent = () => settle(resolve);
24+
const handleError = (error) => settle(reject, error);
25+
26+
stream.once(eventName, handleEvent);
27+
stream.once('error', handleError);
28+
29+
try {
30+
const result = startOperation();
31+
if (shouldResolveImmediately(result)) {
32+
settle(resolve);
33+
}
34+
} catch (error) {
35+
settle(reject, error);
36+
}
37+
});
38+
}
339

440
export function createNodePlatform() {
541
return {
642
name: 'node',
43+
getRuntimePlatform() {
44+
return process.platform;
45+
},
746
async readText(path) {
847
return fs.readFile(path, 'utf8');
948
},
10-
async writeText(path, content) {
11-
await fs.writeFile(path, content, 'utf8');
49+
async writeText(path, content, exportEncodingSettings = {}) {
50+
const encodedContent = applyExportTextEncoding(content, exportEncodingSettings, { platform: process.platform });
51+
await fs.writeFile(path, encodedContent, 'utf8');
1252
},
13-
createLineWriter(path) {
53+
createLineWriter(path, exportEncodingSettings = {}) {
54+
const resolvedEncodingSettings = resolveExportTextEncodingSettings(exportEncodingSettings, {
55+
platform: process.platform,
56+
});
57+
const lineSuffix = resolvedEncodingSettings.lineEnding === 'crlf' ? '\r\n' : '\n';
1458
const stream = createWriteStream(path, { encoding: 'utf8' });
1559
let streamError = null;
60+
let wroteBom = false;
1661
stream.on('error', (error) => {
1762
streamError = error;
1863
});
@@ -21,31 +66,27 @@ export function createNodePlatform() {
2166
if (streamError) {
2267
throw streamError;
2368
}
24-
await new Promise((resolve, reject) => {
25-
const ok = stream.write(`${line}\n`);
26-
if (ok) {
27-
resolve();
28-
return;
69+
await waitForStreamEvent(
70+
stream,
71+
'drain',
72+
() => {
73+
const prefix = !wroteBom && resolvedEncodingSettings.includeBom ? '\uFEFF' : '';
74+
const ok = stream.write(`${prefix}${String(line ?? '')}${lineSuffix}`);
75+
wroteBom = true;
76+
return ok;
77+
},
78+
{
79+
shouldResolveImmediately(result) {
80+
return result !== false;
81+
},
2982
}
30-
stream.once('drain', () => {
31-
if (streamError) {
32-
reject(streamError);
33-
return;
34-
}
35-
resolve();
36-
});
37-
});
83+
);
3884
},
3985
async close() {
4086
if (streamError) {
4187
throw streamError;
4288
}
43-
await new Promise((resolve, reject) => {
44-
stream.end(() => resolve());
45-
if (streamError) {
46-
reject(streamError);
47-
}
48-
});
89+
await waitForStreamEvent(stream, 'finish', () => stream.end());
4990
},
5091
};
5192
},

apps/cli/src/run-cli.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
amendFromTextSpecAndData,
33
generateFromTextSpec,
4+
resolveExportTextEncodingSettings,
45
streamFromTextSpec,
56
SUPPORTED_FORMATS,
67
} from '@anywaydata/core';
@@ -25,6 +26,13 @@ function formatCanonicalErrors(errors = []) {
2526
}
2627

2728
export async function runCliCommand({ options, platform }) {
29+
const outputEncodingSettings = resolveExportTextEncodingSettings(
30+
{
31+
lineEnding: options.lineEndings,
32+
includeBom: options.bom === true,
33+
},
34+
{ platform: platform.getRuntimePlatform?.() }
35+
);
2836
const progress = (message) => {
2937
if (options.showProgress) {
3038
writeLine(platform.stdout, message);
@@ -102,7 +110,7 @@ export async function runCliCommand({ options, platform }) {
102110
}
103111
}
104112
if (options.outputFile) {
105-
await platform.writeText(options.outputFile, result.rendered);
113+
await platform.writeText(options.outputFile, result.rendered, outputEncodingSettings);
106114
progress(`> Writing output to ${options.outputFile}`);
107115
} else {
108116
platform.stdout(`${result.rendered}\n`);
@@ -122,7 +130,7 @@ export async function runCliCommand({ options, platform }) {
122130

123131
if (useStreamMode && ['csv', 'jsonl', 'dsv', 'json', 'xml'].includes(outputFormat)) {
124132
const streamedLines = [];
125-
const writer = options.outputFile ? platform.createLineWriter(options.outputFile) : null;
133+
const writer = options.outputFile ? platform.createLineWriter(options.outputFile, outputEncodingSettings) : null;
126134
let writerClosed = false;
127135
try {
128136
const streamResult = await streamFromTextSpec({
@@ -206,7 +214,7 @@ export async function runCliCommand({ options, platform }) {
206214
}
207215

208216
if (options.outputFile) {
209-
await platform.writeText(options.outputFile, result.rendered);
217+
await platform.writeText(options.outputFile, result.rendered, outputEncodingSettings);
210218
progress(`> Writing output to ${options.outputFile}`);
211219
} else {
212220
platform.stdout(`${result.rendered}\n`);

apps/cli/src/tests/cli-options.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,21 @@ test('amend command options are parsed', () => {
6464
expect(opts.rowCount).toBe(3);
6565
expect(opts.shouldStream).toBe(false);
6666
});
67+
68+
test('export encoding flags are parsed', () => {
69+
const opts = parseCliOptions([
70+
'node',
71+
'cli',
72+
'generate',
73+
'-i',
74+
'spec.txt',
75+
'-n',
76+
'3',
77+
'--line-endings',
78+
'crlf',
79+
'--bom',
80+
]);
81+
82+
expect(opts.lineEndings).toBe('crlf');
83+
expect(opts.bom).toBe(true);
84+
});

apps/cli/src/tests/integration.cli-output.test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const thisFilePath = fileURLToPath(import.meta.url);
88
const thisDir = path.dirname(thisFilePath);
99
const repoRoot = path.resolve(thisDir, '../../../../');
1010
const cliEntry = path.join(repoRoot, 'apps', 'cli', 'src', 'node-entry.js');
11+
const utf8BomBytes = [0xef, 0xbb, 0xbf];
1112

1213
function runCli(args) {
1314
return spawnSync('node', [cliEntry, 'generate', ...args], {
@@ -16,6 +17,23 @@ function runCli(args) {
1617
});
1718
}
1819

20+
async function withTempOutputPath(name, run) {
21+
const outputPath = path.join(os.tmpdir(), `anywaydata-cli-${name}-${Date.now()}.csv`);
22+
try {
23+
await run(outputPath);
24+
} finally {
25+
try {
26+
await fs.unlink(outputPath);
27+
} catch {
28+
// Ignore missing file cleanup races.
29+
}
30+
}
31+
}
32+
33+
function hasUtf8Bom(buffer) {
34+
return utf8BomBytes.every((byte, index) => buffer[index] === byte);
35+
}
36+
1937
test('integration: writes generated content to stdout when no output file is provided', () => {
2038
const inputPath = path.join(repoRoot, 'apps', 'cli', 'examples', 'company-literal.txt');
2139
const result = runCli(['-i', inputPath, '-n', '2', '-f', 'csv']);
@@ -50,3 +68,49 @@ test('integration: writes generated content to file and keeps stdout progress-on
5068
}
5169
}
5270
});
71+
72+
test('integration: writes a UTF-8 BOM when --bom is true', async () => {
73+
const inputPath = path.join(repoRoot, 'apps', 'cli', 'examples', 'company-literal.txt');
74+
await withTempOutputPath('bom-only', async (outputPath) => {
75+
const result = runCli(['-i', inputPath, '-n', '2', '-f', 'csv', '-o', outputPath, '--bom']);
76+
77+
expect(result.status).toBe(0);
78+
const writtenBuffer = await fs.readFile(outputPath);
79+
expect(hasUtf8Bom(writtenBuffer)).toBe(true);
80+
});
81+
});
82+
83+
test('integration: does not write a UTF-8 BOM by default', async () => {
84+
const inputPath = path.join(repoRoot, 'apps', 'cli', 'examples', 'company-literal.txt');
85+
await withTempOutputPath('default-no-bom', async (outputPath) => {
86+
const result = runCli(['-i', inputPath, '-n', '2', '-f', 'csv', '-o', outputPath]);
87+
88+
expect(result.status).toBe(0);
89+
const writtenBuffer = await fs.readFile(outputPath);
90+
expect(hasUtf8Bom(writtenBuffer)).toBe(false);
91+
});
92+
});
93+
94+
test('integration: writes CRLF line endings only when requested', async () => {
95+
const inputPath = path.join(repoRoot, 'apps', 'cli', 'examples', 'company-literal.txt');
96+
await withTempOutputPath('crlf-only', async (outputPath) => {
97+
const result = runCli(['-i', inputPath, '-n', '2', '-f', 'csv', '-o', outputPath, '--line-endings', 'crlf']);
98+
99+
expect(result.status).toBe(0);
100+
const written = await fs.readFile(outputPath, 'utf8');
101+
expect(written).toContain('\r\n');
102+
expect(written.replace(/\r\n/g, '')).not.toContain('\n');
103+
});
104+
});
105+
106+
test('integration: writes LF line endings only when requested', async () => {
107+
const inputPath = path.join(repoRoot, 'apps', 'cli', 'examples', 'company-literal.txt');
108+
await withTempOutputPath('lf-only', async (outputPath) => {
109+
const result = runCli(['-i', inputPath, '-n', '2', '-f', 'csv', '-o', outputPath, '--line-endings', 'lf']);
110+
111+
expect(result.status).toBe(0);
112+
const written = await fs.readFile(outputPath, 'utf8');
113+
expect(written).toContain('\n');
114+
expect(written).not.toContain('\r\n');
115+
});
116+
});

0 commit comments

Comments
 (0)