import { describe, it, expect } from 'vitest'; import { parseJcl } from '../../src/core/ingestion/cobol/jcl-parser.js'; describe('parseJcl', () => { // ── JOB statements ────────────────────────────────────────────────── describe('JOB statements', () => { it('extracts job name', () => { const jcl = `//MYJOB JOB (ACCT),'MY JOB'`; const r = parseJcl(jcl, 'test.jcl'); expect(r.jobs).toHaveLength(1); expect(r.jobs[0].name).toBe('MYJOB'); expect(r.jobs[0].line).toBe(1); }); it('extracts CLASS and MSGCLASS parameters', () => { const jcl = `//PAYJOB JOB (ACCT),'PAYROLL',CLASS=A,MSGCLASS=X`; const r = parseJcl(jcl, 'test.jcl'); expect(r.jobs).toHaveLength(1); expect(r.jobs[0].name).toBe('PAYJOB'); expect(r.jobs[0].class).toBe('A'); expect(r.jobs[0].msgclass).toBe('X'); }); it('handles job with no CLASS or MSGCLASS', () => { const jcl = `//BAREJOB JOB (ACCT),'BARE'`; const r = parseJcl(jcl, 'test.jcl'); expect(r.jobs).toHaveLength(1); expect(r.jobs[0].name).toBe('BAREJOB'); expect(r.jobs[0].class).toBeUndefined(); expect(r.jobs[0].msgclass).toBeUndefined(); }); }); // ── EXEC statements ───────────────────────────────────────────────── describe('EXEC statements', () => { it('extracts step with PGM=program', () => { const jcl = ['//MYJOB JOB (ACCT)', '//STEP1 EXEC PGM=IEFBR14'].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.steps).toHaveLength(1); expect(r.steps[0].name).toBe('STEP1'); expect(r.steps[0].program).toBe('IEFBR14'); expect(r.steps[0].proc).toBeUndefined(); }); it('extracts step with proc name (no PGM= keyword)', () => { const jcl = ['//MYJOB JOB (ACCT)', '//STEP1 EXEC MYPROC'].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.steps).toHaveLength(1); expect(r.steps[0].name).toBe('STEP1'); expect(r.steps[0].program).toBeUndefined(); expect(r.steps[0].proc).toBe('MYPROC'); }); it('associates step with current job', () => { const jcl = [ '//JOB1 JOB (ACCT)', '//STEPA EXEC PGM=PROG1', '//JOB2 JOB (ACCT)', '//STEPB EXEC PGM=PROG2', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.steps).toHaveLength(2); expect(r.steps[0].jobName).toBe('JOB1'); expect(r.steps[1].jobName).toBe('JOB2'); }); }); // ── DD statements ─────────────────────────────────────────────────── describe('DD statements', () => { it('extracts DD name and dataset (DSN=)', () => { const jcl = [ '//MYJOB JOB (ACCT)', '//STEP1 EXEC PGM=IEFBR14', '//INPUT DD DSN=MY.DATA.SET,DISP=SHR', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.ddStatements).toHaveLength(1); expect(r.ddStatements[0].ddName).toBe('INPUT'); expect(r.ddStatements[0].dataset).toBe('MY.DATA.SET'); }); it('extracts DISP parameter', () => { const jcl = [ '//MYJOB JOB (ACCT)', '//STEP1 EXEC PGM=IEFBR14', '//OUTPUT DD DSN=MY.OUT,DISP=(NEW,CATLG,DELETE)', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.ddStatements).toHaveLength(1); expect(r.ddStatements[0].disp).toBe('NEW'); }); it('associates DD with current step', () => { const jcl = [ '//MYJOB JOB (ACCT)', '//STEP1 EXEC PGM=PROG1', '//DD1 DD DSN=DS1,DISP=SHR', '//STEP2 EXEC PGM=PROG2', '//DD2 DD DSN=DS2,DISP=SHR', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.ddStatements).toHaveLength(2); expect(r.ddStatements[0].stepName).toBe('STEP1'); expect(r.ddStatements[1].stepName).toBe('STEP2'); }); }); // ── PROC definitions ──────────────────────────────────────────────── describe('PROC definitions', () => { it('extracts in-stream PROC with name', () => { const jcl = ['//MYPROC PROC', '//STEP1 EXEC PGM=IEFBR14', '// PEND'].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.procs).toHaveLength(1); expect(r.procs[0].name).toBe('MYPROC'); expect(r.procs[0].isInStream).toBe(true); }); it('handles PROC/PEND pairs', () => { const jcl = [ '//PROC1 PROC', '//S1 EXEC PGM=PROG1', '// PEND', '//PROC2 PROC', '//S2 EXEC PGM=PROG2', '// PEND', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.procs).toHaveLength(2); expect(r.procs[0].name).toBe('PROC1'); expect(r.procs[1].name).toBe('PROC2'); }); }); // ── INCLUDE / SET ─────────────────────────────────────────────────── describe('INCLUDE and SET', () => { it('extracts INCLUDE MEMBER=name', () => { const jcl = `// INCLUDE MEMBER=MYINCL`; const r = parseJcl(jcl, 'test.jcl'); expect(r.includes).toHaveLength(1); expect(r.includes[0].member).toBe('MYINCL'); expect(r.includes[0].line).toBe(1); }); it('extracts SET variable=value', () => { const jcl = `// SET ENV=PROD`; const r = parseJcl(jcl, 'test.jcl'); expect(r.sets).toHaveLength(1); expect(r.sets[0].variable).toBe('ENV'); expect(r.sets[0].value).toBe('PROD'); }); }); // ── Conditionals ──────────────────────────────────────────────────── describe('Conditionals', () => { it('extracts IF condition THEN', () => { const jcl = `// IF STEP1.RC = 0 THEN`; const r = parseJcl(jcl, 'test.jcl'); expect(r.conditionals).toHaveLength(1); expect(r.conditionals[0].type).toBe('IF'); expect(r.conditionals[0].condition).toBe('STEP1.RC = 0'); }); it('extracts ELSE and ENDIF', () => { const jcl = [ '// IF STEP1.RC = 0 THEN', '//GOOD EXEC PGM=GOODPGM', '// ELSE', '//BAD EXEC PGM=BADPGM', '// ENDIF', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.conditionals).toHaveLength(3); expect(r.conditionals[0].type).toBe('IF'); expect(r.conditionals[1].type).toBe('ELSE'); expect(r.conditionals[1].condition).toBeUndefined(); expect(r.conditionals[2].type).toBe('ENDIF'); expect(r.conditionals[2].condition).toBeUndefined(); }); }); // ── JCLLIB ────────────────────────────────────────────────────────── describe('JCLLIB', () => { it('extracts JCLLIB ORDER=(lib1,lib2)', () => { const jcl = `// JCLLIB ORDER=(SYS1.PROCLIB,USER.PROCLIB)`; const r = parseJcl(jcl, 'test.jcl'); expect(r.jcllib).toHaveLength(1); expect(r.jcllib[0].order).toEqual(['SYS1.PROCLIB', 'USER.PROCLIB']); expect(r.jcllib[0].line).toBe(1); }); }); // ── Continuation lines ────────────────────────────────────────────── describe('Continuation lines', () => { it('joins continuation lines (col 72 non-blank + next line starts with //)', () => { // Build a DD line that is exactly 72 chars with non-blank at col 72 (index 71). // The continuation line provides the DISP parameter. // "//DD1 DD DSN=MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING," is 60 chars. // Pad to 71 then add non-blank at col 72. const base = '//DD1 DD DSN=MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING,'; const padding = ' '.repeat(71 - base.length); const line1 = base + padding + 'X'; // col 72 is 'X' (non-blank) -> continuation const line2 = '// DISP=SHR'; const jcl = ['//MYJOB JOB (ACCT)', '//STEP1 EXEC PGM=IEFBR14', line1, line2].join('\n'); const r = parseJcl(jcl, 'test.jcl'); // The continuation should join the DD line so both DSN and DISP are parsed expect(r.ddStatements).toHaveLength(1); expect(r.ddStatements[0].ddName).toBe('DD1'); expect(r.ddStatements[0].dataset).toBe('MY.VERY.LONG.DATASET.NAME.THAT.KEEPS.GOING'); expect(r.ddStatements[0].disp).toBe('SHR'); }); }); // ── Edge cases ────────────────────────────────────────────────────── describe('Edge cases', () => { it('skips JCL comments (//*)', () => { const jcl = ['//* This is a comment', '//MYJOB JOB (ACCT)', '//* Another comment'].join( '\n', ); const r = parseJcl(jcl, 'test.jcl'); expect(r.jobs).toHaveLength(1); expect(r.jobs[0].name).toBe('MYJOB'); }); it('skips non-JCL lines', () => { const jcl = [ 'This is not a JCL line', '//MYJOB JOB (ACCT)', ' Some data', '//STEP1 EXEC PGM=IEFBR14', ].join('\n'); const r = parseJcl(jcl, 'test.jcl'); expect(r.jobs).toHaveLength(1); expect(r.steps).toHaveLength(1); }); it('empty input returns empty results', () => { const r = parseJcl('', 'test.jcl'); expect(r.jobs).toEqual([]); expect(r.steps).toEqual([]); expect(r.ddStatements).toEqual([]); expect(r.procs).toEqual([]); expect(r.includes).toEqual([]); expect(r.sets).toEqual([]); expect(r.jcllib).toEqual([]); expect(r.conditionals).toEqual([]); }); it('complete JCL job with multiple steps and DDs', () => { const jcl = [ '//* Complete payroll job', "//PAYJOB JOB (ACCT123),'PAYROLL RUN',CLASS=A,MSGCLASS=X", '// JCLLIB ORDER=(PAY.PROCLIB,SYS1.PROCLIB)', '// SET ENV=PROD', '// INCLUDE MEMBER=STDPARMS', '//*', '// IF 1 = 1 THEN', '//STEP01 EXEC PGM=PAYEXT', '//INPUT DD DSN=PAY.MASTER,DISP=SHR', '//OUTPUT DD DSN=PAY.EXTRACT,DISP=(NEW,CATLG,DELETE)', '//SYSPRINT DD SYSOUT=*', '//*', '//STEP02 EXEC PAYCALC', '//INFILE DD DSN=PAY.EXTRACT,DISP=SHR', '// ELSE', '//STEP03 EXEC PGM=IEFBR14', '// ENDIF', ].join('\n'); const r = parseJcl(jcl, 'payroll.jcl'); // Jobs expect(r.jobs).toHaveLength(1); expect(r.jobs[0]).toEqual({ name: 'PAYJOB', line: 2, class: 'A', msgclass: 'X', }); // JCLLIB expect(r.jcllib).toHaveLength(1); expect(r.jcllib[0].order).toEqual(['PAY.PROCLIB', 'SYS1.PROCLIB']); // SET expect(r.sets).toHaveLength(1); expect(r.sets[0]).toEqual({ variable: 'ENV', value: 'PROD', line: 4 }); // INCLUDE expect(r.includes).toHaveLength(1); expect(r.includes[0].member).toBe('STDPARMS'); // Conditionals expect(r.conditionals).toHaveLength(3); expect(r.conditionals[0].type).toBe('IF'); expect(r.conditionals[1].type).toBe('ELSE'); expect(r.conditionals[2].type).toBe('ENDIF'); // Steps expect(r.steps).toHaveLength(3); expect(r.steps[0]).toMatchObject({ name: 'STEP01', program: 'PAYEXT', jobName: 'PAYJOB' }); expect(r.steps[1]).toMatchObject({ name: 'STEP02', proc: 'PAYCALC', jobName: 'PAYJOB' }); expect(r.steps[2]).toMatchObject({ name: 'STEP03', program: 'IEFBR14', jobName: 'PAYJOB' }); // DD statements expect(r.ddStatements).toHaveLength(4); expect(r.ddStatements[0]).toMatchObject({ ddName: 'INPUT', stepName: 'STEP01', dataset: 'PAY.MASTER', disp: 'SHR', }); expect(r.ddStatements[1]).toMatchObject({ ddName: 'OUTPUT', stepName: 'STEP01', disp: 'NEW', }); expect(r.ddStatements[2]).toMatchObject({ ddName: 'SYSPRINT', stepName: 'STEP01' }); expect(r.ddStatements[3]).toMatchObject({ ddName: 'INFILE', stepName: 'STEP02', dataset: 'PAY.EXTRACT', }); }); }); });