emv
Version:
EMV / Chip and PIN CLI and library for PC/SC card readers
239 lines • 13.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { useState } from 'react';
import { render } from 'ink-testing-library';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import { PinScreen } from './interactive.js';
import { WelcomeScreen } from './interactive/screens/WelcomeScreen.js';
import { ReadersScreen } from './interactive/screens/ReadersScreen.js';
import { WaitingScreen } from './interactive/screens/WaitingScreen.js';
import { AppsScreen } from './interactive/screens/AppsScreen.js';
import { SelectedAppScreen } from './interactive/screens/SelectedAppScreen.js';
import { PinResultScreen } from './interactive/screens/PinResultScreen.js';
import { ErrorScreen } from './interactive/screens/ErrorScreen.js';
import { Header } from './interactive/components/Header.js';
describe('Interactive CLI', () => {
describe('module exports', () => {
it('should export runInteractive function', async () => {
const module = await import('./interactive.js');
assert.strictEqual(typeof module.runInteractive, 'function');
});
it('should export PinScreen component', async () => {
const module = await import('./interactive.js');
assert.strictEqual(typeof module.PinScreen, 'function');
});
});
describe('PinScreen', () => {
const noop = () => { };
it('should render PIN input when raw mode is supported', () => {
const { lastFrame, unmount } = render(_jsx(PinScreen, { onSubmit: noop, onBack: noop, loading: false, attemptsLeft: 3, isRawModeSupported: true }));
const frame = lastFrame() ?? '';
unmount();
// Should show PIN input field (not the fallback message)
assert.ok(frame.includes('PIN:'), 'Should display PIN label');
assert.ok(!frame.includes('raw mode not supported'), 'Should not show raw mode fallback');
});
it('should show fallback message when raw mode is not supported', () => {
const { lastFrame, unmount } = render(_jsx(PinScreen, { onSubmit: noop, onBack: noop, loading: false, attemptsLeft: 3, isRawModeSupported: false }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('raw mode not supported'), 'Should show raw mode not supported message');
});
it('should show loading state', () => {
const { lastFrame, unmount } = render(_jsx(PinScreen, { onSubmit: noop, onBack: noop, loading: true, attemptsLeft: 3, isRawModeSupported: true }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Verifying PIN'), 'Should show verifying message when loading');
});
it('should show warning when attempts are low', () => {
const { lastFrame, unmount } = render(_jsx(PinScreen, { onSubmit: noop, onBack: noop, loading: false, attemptsLeft: 1, isRawModeSupported: true }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('1 attempt'), 'Should show attempts remaining warning');
});
});
describe('WelcomeScreen', () => {
it('should render welcome message', () => {
const { lastFrame, unmount } = render(_jsx(WelcomeScreen, { onContinue: () => { } }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Welcome'));
});
});
describe('ReadersScreen', () => {
it('should show loading state', () => {
const { lastFrame, unmount } = render(_jsx(ReadersScreen, { readers: [], onSelect: () => { }, onRefresh: () => { }, loading: true }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Scanning'));
});
it('should show no readers message', () => {
const { lastFrame, unmount } = render(_jsx(ReadersScreen, { readers: [], onSelect: () => { }, onRefresh: () => { }, loading: false }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('No card readers'));
});
it('should list readers', () => {
const readers = [{ name: 'Test Reader', state: 0, atr: null }];
const { lastFrame, unmount } = render(_jsx(ReadersScreen, { readers: readers, onSelect: () => { }, onRefresh: () => { }, loading: false }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Test Reader'));
});
});
describe('WaitingScreen', () => {
it('should show reader name', () => {
const { lastFrame, unmount } = render(_jsx(WaitingScreen, { readerName: "My Reader" }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('My Reader'));
});
});
describe('AppsScreen', () => {
it('should show loading state', () => {
const { lastFrame, unmount } = render(_jsx(AppsScreen, { apps: [], readerName: "Reader", atr: "3B00", onSelect: () => { }, onBack: () => { }, loading: true }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Reading'));
});
it('should show no apps message', () => {
const { lastFrame, unmount } = render(_jsx(AppsScreen, { apps: [], readerName: "Reader", atr: "3B00", onSelect: () => { }, onBack: () => { }, loading: false }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('No applications'));
});
it('should list apps', () => {
const apps = [{ aid: 'A0000000041010', label: 'Mastercard' }];
const { lastFrame, unmount } = render(_jsx(AppsScreen, { apps: apps, readerName: "Reader", atr: "3B00", onSelect: () => { }, onBack: () => { }, loading: false }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Mastercard'));
});
});
describe('SelectedAppScreen', () => {
it('should show app details', () => {
const app = { aid: 'A0000000041010', label: 'Mastercard', priority: 1 };
const { lastFrame, unmount } = render(_jsx(SelectedAppScreen, { app: app, onVerifyPin: () => { }, onExplore: () => { }, onBack: () => { } }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Mastercard'));
assert.ok(frame.includes('A0000000041010'));
});
});
describe('PinResultScreen', () => {
it('should show success', () => {
const { lastFrame, unmount } = render(_jsx(PinResultScreen, { success: true, message: "PIN OK", attemptsLeft: undefined, onContinue: () => { } }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('PIN OK'));
});
it('should show failure with attempts', () => {
const { lastFrame, unmount } = render(_jsx(PinResultScreen, { success: false, message: "Wrong PIN", attemptsLeft: 2, onContinue: () => { } }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Wrong PIN'));
assert.ok(frame.includes('2'));
});
});
describe('ErrorScreen', () => {
it('should show error message', () => {
const { lastFrame, unmount } = render(_jsx(ErrorScreen, { message: "Something broke", onBack: () => { } }));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Something broke'));
});
});
describe('Header', () => {
// Helper to strip ANSI escape codes from string
const stripAnsi = (str) =>
// eslint-disable-next-line no-control-regex
str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
it('should render ASCII art with correct EMV letter spacing and border', () => {
const { lastFrame, unmount } = render(_jsx(Header, {}));
const frame = lastFrame() ?? '';
unmount();
// Strip ANSI codes and normalize whitespace
// ink-testing-library may include color codes and varying indentation
const normalizedFrame = stripAnsi(frame)
.split('\n')
.map((line) => line.trimStart())
.join('\n');
// These exact strings define the EMV ASCII art structure with borders
// The spaces are critical for proper letter alignment
// prettier-ignore
const expectedAsciiPatterns = [
'║ ███████╗███╗ ███╗██╗ ██╗ ║', // Row 1
'║ ██╔════╝████╗ ████║██║ ██║', // Row 2 (has title text after)
'║ █████╗ ██╔████╔██║██║ ██║', // Row 3 (has title text after)
'║ ██╔══╝ ██║╚██╔╝██║╚██╗ ██╔╝ ║', // Row 4
'║ ███████╗██║ ╚═╝ ██║ ╚████╔╝', // Row 5 (has version after)
'║ ╚══════╝╚═╝ ╚═╝ ╚═══╝ ║', // Row 6
];
for (const pattern of expectedAsciiPatterns) {
assert.ok(normalizedFrame.includes(pattern), `ASCII art is malformed - missing pattern: "${pattern}". ` +
'The spacing in the EMV logo has been corrupted.\n' +
`Actual frame (normalized):\n${normalizedFrame}`);
}
});
it('should render title text', () => {
const { lastFrame, unmount } = render(_jsx(Header, {}));
const frame = lastFrame() ?? '';
unmount();
assert.ok(frame.includes('Chip'), 'Should include "Chip" in title');
assert.ok(frame.includes('PIN'), 'Should include "PIN" in title');
assert.ok(frame.includes('Explorer'), 'Should include "Explorer" in title');
assert.ok(frame.includes('Interactive Mode'), 'Should include "Interactive Mode"');
});
});
describe('TextInput masking behavior', () => {
// These tests verify the core masking behavior that was broken
// when we had double masking (rendering both manual mask + TextInput mask)
it('should display single mask character per input digit', () => {
function TestComponent() {
const [value, setValue] = useState('1234');
return (_jsxs(Box, { children: [_jsx(Text, { children: "PIN: " }), _jsx(TextInput, { value: value, onChange: setValue, mask: "\u2022" })] }));
}
const { lastFrame } = render(_jsx(TestComponent, {}));
const frame = lastFrame() ?? '';
// Count mask characters - should be exactly 4 for a 4-digit value
const maskCount = (frame.match(/•/g) ?? []).length;
assert.strictEqual(maskCount, 4, `Expected 4 mask characters for 4-digit PIN, but found ${maskCount}. ` +
'The mask prop should produce exactly one mask character per input digit.');
});
it('should not have duplicate mask output when using both manual mask and TextInput mask', () => {
// This simulates what the bug looked like - rendering both manual mask AND TextInput mask
function BuggyComponent() {
const value = '1234';
const maskedPin = '•'.repeat(value.length); // This was the bug - manual masking
return (_jsxs(Box, { children: [_jsx(Text, { children: "PIN: " }), _jsx(Text, { children: maskedPin }), _jsx(TextInput, { value: value, onChange: () => { }, mask: "\u2022" })] }));
}
function FixedComponent() {
const value = '1234';
return (_jsxs(Box, { children: [_jsx(Text, { children: "PIN: " }), _jsx(TextInput, { value: value, onChange: () => { }, mask: "\u2022" })] }));
}
const buggyFrame = render(_jsx(BuggyComponent, {})).lastFrame() ?? '';
const fixedFrame = render(_jsx(FixedComponent, {})).lastFrame() ?? '';
const buggyMaskCount = (buggyFrame.match(/•/g) ?? []).length;
const fixedMaskCount = (fixedFrame.match(/•/g) ?? []).length;
// Buggy version has 8 masks (4 manual + 4 from TextInput)
assert.strictEqual(buggyMaskCount, 8, 'Buggy component should show 8 masks (double masking)');
// Fixed version has 4 masks (just from TextInput)
assert.strictEqual(fixedMaskCount, 4, 'Fixed component should show 4 masks (single masking)');
});
it('should mask different PIN lengths correctly', () => {
function TestComponent({ pinLength }) {
const value = '1'.repeat(pinLength);
return (_jsx(Box, { children: _jsx(TextInput, { value: value, onChange: () => { }, mask: "\u2022" }) }));
}
for (const length of [4, 6, 8, 12]) {
const { lastFrame } = render(_jsx(TestComponent, { pinLength: length }));
const frame = lastFrame() ?? '';
const maskCount = (frame.match(/•/g) ?? []).length;
assert.strictEqual(maskCount, length, `Expected ${length} mask characters for ${length}-digit PIN, but found ${maskCount}.`);
}
});
});
});
//# sourceMappingURL=interactive.test.js.map