diff --git a/.babelrc b/.babelrc index 8aa924d..1320b9a 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { "presets": ["@babel/preset-env"] -} \ No newline at end of file +} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..537c7c9 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,51 @@ +name: E2E Tests + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + e2e: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + name: E2E Tests - ${{ matrix.browser }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Run E2E tests + run: npx playwright test --project=${{ matrix.browser }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ matrix.browser }} + path: playwright-report/ + retention-days: 30 + + - name: Upload test videos + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: test-videos-${{ matrix.browser }} + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml new file mode 100644 index 0000000..a48c8d1 --- /dev/null +++ b/.github/workflows/quality-check.yml @@ -0,0 +1,27 @@ +name: Quality Check + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Check Prettier formatting + run: npm run format:check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b5c97e6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Run Tests + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage reports + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 0bd0f7f..b8800a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ node_modules *.log .vscode +coverage/ + +# Excel files (to prevent accidental commits of sensitive data) +*.xls +*.xlsx +*.xlsm +*.xlsb + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c27d889 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..004c7fd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +coverage +*.log +.vscode +dist +build +tmp +*.min.js +*.min.css +playwright-report +test-results \ No newline at end of file diff --git a/README.md b/README.md index 4cbb81b..a2157ac 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ # ynab-csv +Tool for making your CSV and Excel files ready to import into YNAB. -Tool for making your CSV files ready to import into YNAB. - -http://aniav.github.io/ynab-csv/ - +http://toshi38.github.io/ynab-csv/ ## How to Use 1. Visit the link above. -2. Drop or select the the csv file you want to make ready for YNAB. -3. For each column in the YNAB data file, choose which column you want to pull from your source data file. -4. Save the new YNAB file and you are ready to import that right into YNAB! +2. Drop or select the CSV or Excel file you want to make ready for YNAB. +3. For Excel files with multiple worksheets, select the desired worksheet from the dropdown. +4. For each column in the YNAB data file, choose which column you want to pull from your source data file. +5. Save the new YNAB file and you are ready to import that right into YNAB! + +## Supported File Formats + +- **CSV files** (.csv) - All standard CSV formats with configurable delimiters and encodings +- **Excel files** (.xlsx, .xls, .xlsm, .xlsb) - Full support for Excel spreadsheets including multi-sheet workbooks ## Multiple Profiles @@ -37,7 +41,6 @@ To run the project locally using Docker Compose: Your data never leaves your computer. All the processing happens locally. - ## Reporting Issues If you have any other issues or suggestions, go to https://github.com/aniav/ynab-csv/issues and create an issue if one doesn't already exist. If the issue has to do with your csv file, please create a new gist (https://gist.github.com/) with the content of the CSV file and share the link in the issue. If you tweak the CSV file before sharing, just make sure whatever version you end up sharing still causes the problem you describe. @@ -46,10 +49,11 @@ If you have any other issues or suggestions, go to https://github.com/aniav/ynab 1. Fork and clone the project 2. `cd` into project -3. Run `npm install` # You will need to install node and npm if it is not already -4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" - * Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) +3. Run `npm install` # You will need to install node and npm if it is not already +4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" + +- Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) + 5. Make your changes locally and test them to make sure they work 6. Commit those changes and push to your forked repository 7. Make a new pull request - diff --git a/app.css b/app.css index 7dc7860..d8cefb8 100644 --- a/app.css +++ b/app.css @@ -1,28 +1,192 @@ -html, body, .dropzone { height: 100%; } +html, +body, +.dropzone { + height: 100%; +} /* Loading helpers*/ -body:not(.angular_loaded) .show_on_load { display: none; } -.angular_loaded .hide_on_load { display: none; } -#angular_is_loading { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0,0,0,.5); color: white; font-size: 70px; text-align: center; padding-top: 200px; box-shadow: 0 0 10px rgba(0,0,0,.5); transition: all 2s; -webkit-transition: all 2s; } -body.angular_loaded #angular_is_loading { display: none; top: 3000px; bottom: -3000px; } - +body:not(.angular_loaded) .show_on_load { + display: none; +} +.angular_loaded .hide_on_load { + display: none; +} +#angular_is_loading { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + font-size: 70px; + text-align: center; + padding-top: 200px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + transition: all 2s; + -webkit-transition: all 2s; +} +body.angular_loaded #angular_is_loading { + display: none; + top: 3000px; + bottom: -3000px; +} /* Header */ -#header_nav { position: fixed; top: 0; left: 0; right: 0; padding: 10px; background-color: rgb(240, 240, 240); z-index: 1; box-shadow: 0 0 10px rgba(0,0,0,.5); } -#header_nav h1 { display: inline-block; font-size: 20px; margin: 0; vertical-align: middle; margin-right: 15px;} - +#header_nav { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 10px; + background-color: rgb(240, 240, 240); + z-index: 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); +} +#header_nav h1 { + display: inline-block; + font-size: 20px; + margin: 0; + vertical-align: middle; + margin-right: 15px; +} /* Dropzone */ -.dropzone { padding-top: 54px; } -.dropzone.dragging { background-color: #D2E4F8; } +.dropzone { + padding-top: 54px; +} + +/* Responsive padding for fluid containers */ +.container-fluid { + padding-left: 15px; + padding-right: 15px; +} + +@media (min-width: 768px) { + .container-fluid { + padding-left: 30px; + padding-right: 30px; + } +} + +@media (min-width: 1200px) { + .container-fluid { + padding-left: 50px; + padding-right: 50px; + } +} +.dropzone.dragging { + background-color: #d2e4f8; +} + +#upload_wrapper { + text-align: center; + max-width: 600px; + margin: 0 auto; +} +#upload_wrapper #drop_text { + font-size: 60px; + padding-top: 150px; +} +#upload_wrapper .fileUpload { + position: relative; + overflow: hidden; + margin: 10px; +} +#upload_wrapper .fileUpload input { + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + font-size: 20px; + cursor: pointer; + opacity: 0; + filter: alpha(opacity=0); +} + +#tool_wrapper { + margin-bottom: 40px; +} +#tool_wrapper .table-container { + overflow: auto; +} + +.dropdown-form { + width: 200px; +} + +/* When file-parsing-settings is inside a dropdown */ +.dropdown-form .file-parsing-settings { + display: block; +} + +.dropdown-form .file-parsing-settings .form-group { + flex: none; + min-width: auto; + margin-bottom: 0.5rem; +} + +.dropdown-form .file-parsing-settings .form-control { + width: 100%; +} + +/* Settings section */ +.settings-section { + margin-top: 10px; +} + +.settings-content { + transition: all 0.3s ease; +} + +.file-parsing-settings { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.file-parsing-settings .form-group { + flex: 1; + min-width: 200px; + margin-bottom: 0; +} + +.file-parsing-settings .form-check { + flex: 1 1 100%; +} + +/* Auto-match notification styles */ +.alert-dismissible .close { + padding: 0.5rem 1rem; +} + +/* Saved configs card on landing page */ +#upload_wrapper .card { + max-width: 400px; + margin: 20px auto 0; + text-align: left; +} -#upload_wrapper { text-align: center;} -#upload_wrapper #drop_text { font-size: 60px; padding-top: 150px; } -#upload_wrapper .fileUpload { position: relative; overflow: hidden; margin: 10px; } -#upload_wrapper .fileUpload input { position: absolute; top: 0; right: 0; margin: 0; padding: 0; font-size: 20px; cursor: pointer; opacity: 0; filter: alpha(opacity=0); } +#upload_wrapper .list-group-item { + padding: 0.75rem 1rem; +} +/* Config save button group */ +[data-testid="config-save-group"] { + margin-left: 5px; +} -#tool_wrapper { margin-bottom: 40px; } -#tool_wrapper .table-container { overflow: auto; } +/* Match confidence badge */ +.badge-pill { + font-size: 0.75em; + vertical-align: middle; +} -.dropdown-form {width: 200px;} \ No newline at end of file +/* Make buttons wrap nicely on smaller screens */ +.card-header .float-right { + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: flex-end; +} diff --git a/docker-compose.yml b/docker-compose.yml index a291848..25e88bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ -version: '3' +version: "3" services: ynab-csv-app: build: . ports: - - ${PORT}:3000 \ No newline at end of file + - ${PORT}:3000 diff --git a/e2e/pages/ynab-converter.page.js b/e2e/pages/ynab-converter.page.js new file mode 100644 index 0000000..9e3372f --- /dev/null +++ b/e2e/pages/ynab-converter.page.js @@ -0,0 +1,361 @@ +export class YnabConverterPage { + constructor(page) { + this.page = page; + + // File upload elements + this.fileInput = page.locator('[data-testid="file-input"]'); + this.dropzone = page.locator('[data-testid="dropzone"]'); + this.uploadWrapper = page.locator('[data-testid="upload-wrapper"]'); + + // Data display elements + this.toolWrapper = page.locator('[data-testid="tool-wrapper"]'); + this.dataTable = page.locator('[data-testid="data-preview-table"]'); + this.fileTypeBadge = page.locator('[data-testid="file-type-badge"]'); + + // Excel worksheet selector + this.worksheetSelector = page.locator('[data-testid="worksheet-select"]'); + + // Configuration elements + this.encodingSelect = page.locator('[data-testid="encoding-select"]'); + this.delimiterSelect = page.locator('[data-testid="delimiter-select"]'); + this.startRowInput = page.locator('[data-testid="start-row-input"]'); + this.extraRowCheckbox = page.locator('[data-testid="extra-row-checkbox"]'); + this.configDropdown = page.locator('[data-testid="config-dropdown"]'); + + // Column mapping selects + this.columnMappingSelects = page.locator('[data-testid^="column-select-"]'); + + // Profile elements + this.profileSelect = page.locator('[data-testid="profile-select"]'); + + // Action buttons + this.downloadButton = page.locator('[data-testid="download-button"]'); + this.invertFlowsButton = page.locator( + '[data-testid="invert-flows-button"]', + ); + this.invertAmountButton = page.locator( + '[data-testid="invert-amount-button"]', + ); + this.toggleFormatButton = page.locator( + '[data-testid="toggle-format-button"]', + ); + this.reloadButton = page.locator('[data-testid="reload-button"]'); + + // Settings panel elements + this.settingsToggle = page.locator('[data-testid="settings-toggle"]'); + this.settingsContent = page.locator(".settings-content"); + + // Auto-matching configuration elements + this.autoApplyNotification = page.locator( + '[data-testid="auto-apply-notification"]', + ); + this.partialMatchSuggestion = page.locator( + '[data-testid="config-suggestion"]', + ); + this.savedConfigsList = page.locator('[data-testid="saved-configs-list"]'); + this.savedConfigsCard = page.locator('[data-testid="saved-configs-card"]'); + this.saveConfigButton = page.locator('[data-testid="save-config-btn"]'); + this.saveConfigDropdown = page.locator('[data-testid="config-save-group"]'); + } + + async goto() { + await this.page.goto("/"); + await this.page.waitForSelector(".show_on_load", { state: "visible" }); + } + + async uploadFile(filePath) { + await this.fileInput.setInputFiles(filePath); + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async dragAndDropFile(filePath) { + const dataTransfer = await this.page.evaluateHandle( + () => new DataTransfer(), + ); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragenter", { + dataTransfer, + }); + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragover", { + dataTransfer, + }); + + await this.fileInput.setInputFiles(filePath); + const files = await this.fileInput.inputValue(); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "drop", { + dataTransfer: await this.page.evaluateHandle((files) => { + const dt = new DataTransfer(); + // Simulate file drop + return dt; + }, files), + }); + + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async selectWorksheet(index) { + await this.worksheetSelector.selectOption({ index: index.toString() }); + } + + async setColumnMapping(ynabColumn, csvColumn) { + const select = this.page.locator( + `[data-testid="column-select-${ynabColumn}"]`, + ); + + // Wait for the select to have options available and find the option value + const optionValue = await this.page.evaluate( + ({ selectId, targetColumn }) => { + const select = document.querySelector(`[data-testid="${selectId}"]`); + if (!select) return false; + const options = Array.from(select.options); + + // Try to find option that matches the target column + const matchingOption = options.find( + (option) => + option.textContent.includes(targetColumn) || + option.value.includes(targetColumn) || + option.label === targetColumn || + option.label.startsWith(targetColumn), // Handle "Transaction Date (1)" format + ); + + return matchingOption ? matchingOption.value : false; + }, + { selectId: `column-select-${ynabColumn}`, targetColumn: csvColumn }, + ); + + if (optionValue) { + await select.selectOption(optionValue); + + // Wait for AngularJS to process the change and update the preview + await this.page.waitForFunction( + () => { + // Check if Angular scope exists and has processed the change + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + // Small additional wait to ensure DOM updates are complete + await this.page.waitForTimeout(100); + } else { + throw new Error( + `Could not find option for column: ${csvColumn} in ${ynabColumn} select`, + ); + } + } + + async getPreviewData() { + // Use data-testid selectors for reliable element targeting + const rows = await this.page + .locator( + '[data-testid="data-preview-table"] [data-testid^="preview-row-"]', + ) + .all(); + const data = []; + + for (const row of rows) { + const cells = await row + .locator('[data-testid^="preview-cell-"]') + .allTextContents(); + data.push(cells); + } + + return data; + } + + async downloadConvertedFile() { + // Ensure data is processed and download button is available + await this.downloadButton.waitFor({ state: "visible" }); + + // Wait for any pending Angular operations to complete + await this.page.waitForFunction( + () => { + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + const downloadPromise = this.page.waitForEvent("download"); + await this.downloadButton.click(); + const download = await downloadPromise; + return download; + } + + async openConfigDropdown() { + await this.configDropdown.click(); + await this.page.locator(".dropdown-menu").waitFor({ state: "visible" }); + } + + async setEncoding(encoding) { + await this.openConfigDropdown(); + await this.encodingSelect.selectOption(encoding); + } + + async setDelimiter(delimiter) { + await this.openConfigDropdown(); + await this.delimiterSelect.selectOption(delimiter); + } + + async setStartRow(row) { + await this.openConfigDropdown(); + await this.startRowInput.fill(row.toString()); + } + + async toggleExtraRow() { + await this.openConfigDropdown(); + await this.extraRowCheckbox.click(); + } + + async getCurrentProfile() { + return await this.profileSelect.inputValue(); + } + + async selectProfile(profileName) { + await this.profileSelect.selectOption(profileName); + } + + async toggleSettingsPanel() { + await this.settingsToggle.click(); + // Wait for animation to complete + await this.page.waitForTimeout(300); + } + + async isSettingsPanelVisible() { + return await this.settingsContent.isVisible(); + } + + async changeSettingAfterUpload(settingType, value) { + // Ensure settings panel is open + if (!(await this.isSettingsPanelVisible())) { + await this.toggleSettingsPanel(); + } + + // Find the setting element within the settings panel + const settingsContainer = this.settingsContent; + + switch (settingType) { + case "encoding": + await settingsContainer + .locator('[data-testid="encoding-select"]') + .selectOption(value); + break; + case "delimiter": + await settingsContainer + .locator('[data-testid="delimiter-select"]') + .selectOption(value); + break; + case "startRow": + await settingsContainer + .locator('[data-testid="start-row-input"]') + .fill(value.toString()); + break; + case "extraRow": + await settingsContainer + .locator('[data-testid="extra-row-checkbox"]') + .click(); + break; + case "worksheet": + await settingsContainer + .locator('[data-testid="worksheet-select"]') + .selectOption({ index: value.toString() }); + break; + default: + throw new Error(`Unknown setting type: ${settingType}`); + } + + // Wait for re-parse to complete + await this.page.waitForTimeout(500); + } + + // Auto-matching configuration methods + async isAutoApplied() { + return await this.autoApplyNotification.isVisible(); + } + + async getAutoApplyMessage() { + if (await this.autoApplyNotification.isVisible()) { + return await this.autoApplyNotification.textContent(); + } + return null; + } + + async dismissAutoApply() { + const closeButton = this.autoApplyNotification.locator("button.close"); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + } + + async saveConfigAs(name) { + // Handle the prompt dialog BEFORE triggering it + this.page.once("dialog", async (dialog) => { + await dialog.accept(name); + }); + + // Click the dropdown toggle to show options + const dropdownToggle = this.saveConfigDropdown.locator( + ".dropdown-toggle-split", + ); + await dropdownToggle.click(); + + // Click "Save as..." option + const saveAsButton = this.page.locator('[data-testid="save-config-as"]'); + await saveAsButton.click(); + } + + async getSavedConfigsCount() { + const items = this.page.locator('[data-testid="saved-config-item"]'); + return await items.count(); + } + + async getSavedConfigNames() { + const names = []; + const items = this.page.locator('[data-testid="saved-config-item"]'); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const nameEl = items.nth(i).locator("strong"); + const name = await nameEl.textContent(); + names.push(name.trim()); + } + + return names; + } + + async deleteConfigAt(index) { + const items = this.page.locator('[data-testid="saved-config-item"]'); + const deleteButton = items + .nth(index) + .locator('[data-testid="delete-config-btn"]'); + await deleteButton.click(); + } + + async renameConfigAt(index, newName) { + // Handle the prompt dialog BEFORE triggering it + this.page.once("dialog", async (dialog) => { + await dialog.accept(newName); + }); + + const items = this.page.locator('[data-testid="saved-config-item"]'); + const renameButton = items + .nth(index) + .locator('[data-testid="rename-config-btn"]'); + await renameButton.click(); + } + + async clearLocalStorage() { + await this.page.evaluate(() => { + localStorage.removeItem("knownConfigurations"); + }); + } + + async getLocalStorageConfigs() { + return await this.page.evaluate(() => { + const stored = localStorage.getItem("knownConfigurations"); + return stored ? JSON.parse(stored) : null; + }); + } +} diff --git a/e2e/specs/auto-config.spec.js b/e2e/specs/auto-config.spec.js new file mode 100644 index 0000000..ead794b --- /dev/null +++ b/e2e/specs/auto-config.spec.js @@ -0,0 +1,285 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; +import path from "path"; + +test.describe("Auto-matching Configuration", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + // Clear any saved configurations before each test + await ynabPage.clearLocalStorage(); + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { state: "visible" }); + }); + + test.describe("Auto-save on download", () => { + test("saves configuration when downloading converted file", async ({ + page, + }) => { + // Upload chase_statement_2024.csv + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download the file (which should auto-save config) + await ynabPage.downloadConvertedFile(); + + // Verify config was saved to localStorage + const configs = await ynabPage.getLocalStorageConfigs(); + expect(configs).not.toBeNull(); + expect(Object.keys(configs.configs).length).toBeGreaterThan(0); + }); + }); + + test.describe("Auto-apply on upload", () => { + test("auto-applies configuration when uploading file with matching headers", async ({ + page, + }) => { + // First, upload and configure chase_statement_2024.csv + const filePath2024 = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath2024); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save the config + await ynabPage.downloadConvertedFile(); + + // Reload the page to reset state + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Now upload chase_statement_2025.csv (same headers) + const filePath2025 = path.resolve("test_files/chase_statement_2025.csv"); + await ynabPage.uploadFile(filePath2025); + + // Should show auto-apply notification + await expect(ynabPage.autoApplyNotification).toBeVisible({ + timeout: 5000, + }); + + // The notification message should indicate auto-apply + const message = await ynabPage.getAutoApplyMessage(); + expect(message).toContain("auto"); + }); + + test("does not auto-apply when uploading file with different headers", async ({ + page, + }) => { + // First, upload and configure chase_statement_2024.csv + const chaseFile = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(chaseFile); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Download to save the config + await ynabPage.downloadConvertedFile(); + + // Reload the page to reset state + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Now upload wells_fargo file (different headers) + const wellsFargoFile = path.resolve( + "test_files/wells_fargo_checking.csv", + ); + await ynabPage.uploadFile(wellsFargoFile); + + // Should NOT show auto-apply notification + await expect(ynabPage.autoApplyNotification).not.toBeVisible(); + }); + }); + + test.describe("startAtRow handling", () => { + test("auto-applies configuration with correct startAtRow for files with metadata rows", async ({ + page, + }) => { + // Set start row to 3 BEFORE uploading (dropdown is only visible on landing page) + await ynabPage.openConfigDropdown(); + await ynabPage.startRowInput.fill("3"); + + // Wait for Angular to process + await page.waitForTimeout(300); + + // Close dropdown + await page.click("body"); + + // Upload file with headers on row 3 + const metadataFile = path.resolve("test_files/metadata_header_row3.csv"); + await ynabPage.uploadFile(metadataFile); + + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Payee"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save config + await ynabPage.downloadConvertedFile(); + + // Reload and upload second file with same structure + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + const metadataFileV2 = path.resolve( + "test_files/metadata_header_row3_v2.csv", + ); + await ynabPage.uploadFile(metadataFileV2); + + // Should auto-apply with correct startAtRow + await expect(ynabPage.autoApplyNotification).toBeVisible({ + timeout: 5000, + }); + + // Verify the preview data has correct content (startAtRow was applied) + const previewData = await ynabPage.getPreviewData(); + // The first row should have a date, not metadata text (trim whitespace) + expect(previewData[0][0].trim()).toMatch(/^\d{4}-\d{2}-\d{2}/); + }); + }); + + test.describe("Semicolon delimiter", () => { + test("preserves semicolon delimiter setting in saved config", async ({ + page, + }) => { + // Set delimiter to semicolon BEFORE uploading (dropdown is only visible on landing page) + await ynabPage.openConfigDropdown(); + await ynabPage.delimiterSelect.selectOption(";"); + + // Wait for Angular to process + await page.waitForTimeout(300); + + // Close dropdown + await page.click("body"); + + // Upload kontoutdrag file (semicolon delimited) + const kontoutdragFile = path.resolve( + "test_files/kontoutdrag 20251227-0654.csv", + ); + await ynabPage.uploadFile(kontoutdragFile); + + // Map columns + await ynabPage.setColumnMapping("Date", "Booking date"); + await ynabPage.setColumnMapping("Payee", "Text"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save config + await ynabPage.downloadConvertedFile(); + + // Verify config was saved with correct delimiter + const configs = await ynabPage.getLocalStorageConfigs(); + const configId = Object.keys(configs.configs)[0]; + const savedConfig = configs.configs[configId]; + + expect(savedConfig.chosenDelimiter).toBe(";"); + }); + }); + + test.describe("Saved configs management", () => { + test("shows saved configurations on landing page", async ({ page }) => { + // First, create a saved config + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + await ynabPage.downloadConvertedFile(); + + // Reload to go back to landing page + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Should show saved configs card + await expect(ynabPage.savedConfigsCard).toBeVisible(); + + // Should have at least one config + const count = await ynabPage.getSavedConfigsCount(); + expect(count).toBeGreaterThan(0); + }); + + test("can delete a saved configuration", async ({ page }) => { + // Create a saved config + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + await ynabPage.downloadConvertedFile(); + + // Reload to go back to landing page + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Get initial count + const initialCount = await ynabPage.getSavedConfigsCount(); + expect(initialCount).toBeGreaterThan(0); + + // Delete the config + await ynabPage.deleteConfigAt(0); + + // Wait for deletion to process + await page.waitForTimeout(500); + + // Count should be reduced + const finalCount = await ynabPage.getSavedConfigsCount(); + expect(finalCount).toBe(initialCount - 1); + }); + }); + + test.describe("Save with custom name", () => { + test("allows saving configuration with custom name via prompt", async ({ + page, + }) => { + // Upload a file + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Set up dialog handler BEFORE triggering the save + page.once("dialog", async (dialog) => { + await dialog.accept("My Custom Chase Config"); + }); + + // Click the dropdown toggle to show options + const dropdownToggle = ynabPage.saveConfigDropdown.locator( + ".dropdown-toggle-split", + ); + await dropdownToggle.click(); + + // Click "Save as..." option + const saveAsButton = page.locator('[data-testid="save-config-as"]'); + await saveAsButton.click(); + + // Wait for save to process + await page.waitForTimeout(500); + + // Verify config was saved with custom name + const configs = await ynabPage.getLocalStorageConfigs(); + const configId = Object.keys(configs.configs)[0]; + const savedConfig = configs.configs[configId]; + + expect(savedConfig.name).toBe("My Custom Chase Config"); + }); + }); +}); diff --git a/e2e/specs/column-mapping.spec.js b/e2e/specs/column-mapping.spec.js new file mode 100644 index 0000000..cae5180 --- /dev/null +++ b/e2e/specs/column-mapping.spec.js @@ -0,0 +1,254 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("Column Mapping", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Upload a test CSV file + const csvContent = `Transaction Date,Description,Debit,Credit,Balance +2024-01-01,Grocery Store,50.00,,1000.00 +2024-01-02,Salary,,2000.00,3000.00 +2024-01-03,Electric Bill,150.00,,2850.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-mapping.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("displays column mapping dropdowns", async ({ page }) => { + const mappingSelects = await ynabPage.columnMappingSelects.count(); + expect(mappingSelects).toBeGreaterThan(0); + + // Check for YNAB column headers using test IDs + await expect( + page.locator('[data-testid="column-header-Date"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Payee"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Memo"]'), + ).toBeVisible(); + }); + + test("allows mapping CSV columns to YNAB format", async ({ page }) => { + // Map Date column + await ynabPage.setColumnMapping("Date", "Transaction Date"); + + // Map Payee column + await ynabPage.setColumnMapping("Payee", "Description"); + + // Verify preview updates + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // First row should have date from Transaction Date column + expect(previewData[0][0]).toContain("2024-01-01"); + }); + + test("toggles between old and new YNAB format", async ({ page }) => { + // Check initial format using test IDs + const outflowExists = await page + .locator('[data-testid="column-header-Outflow"]') + .isVisible(); + const inflowExists = await page + .locator('[data-testid="column-header-Inflow"]') + .isVisible(); + + if (outflowExists && inflowExists) { + // Toggle to new format + await ynabPage.toggleFormatButton.click(); + + // Should now show Amount column + await expect( + page.locator('[data-testid="column-header-Amount"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).not.toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).not.toBeVisible(); + + // Toggle back + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } else { + // Started with new format, toggle to old + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } + }); + + test("inverts transaction flows", async ({ page }) => { + // Map columns first + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + // Map both Outflow and Inflow to the same field (Balance) to enable invert flows button + await ynabPage.setColumnMapping("Outflow", "Balance"); + await ynabPage.setColumnMapping("Inflow", "Balance"); + + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + + // Click invert flows button (should now be visible) + await ynabPage.invertFlowsButton.click(); + + // Get updated preview data + const invertedData = await ynabPage.getPreviewData(); + + // Data should be different after inversion + expect(invertedData).not.toEqual(initialData); + }); + + test("preserves column mappings when switching formats", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Get the selected values using test IDs + const dateMapping = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMapping = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + // Toggle format + await ynabPage.toggleFormatButton.click(); + + // Check that Date and Payee mappings are preserved + const dateMappingAfter = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMappingAfter = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + expect(dateMappingAfter).toBe(dateMapping); + expect(payeeMappingAfter).toBe(payeeMapping); + }); + + test("handles empty column mapping", async ({ page }) => { + // Leave some columns unmapped + await ynabPage.setColumnMapping("Date", "Transaction Date"); + // Leave Payee unmapped + + // Should still show preview + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // Unmapped columns should be empty (dots may be visual placeholders via CSS) + expect(previewData[0][1].trim()).toBe(""); // Payee should be empty + }); + + test("inverts amount sign when using new YNAB format", async ({ page }) => { + // Switch to new format (Amount column) + const amountColumnExists = await page + .locator('[data-testid="column-header-Amount"]') + .isVisible(); + + if (!amountColumnExists) { + // Toggle to new format if not already there + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Amount"]'), + ).toBeVisible(); + } + + // Map columns + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Amount", "Balance"); + + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + const initialAmountColumn = initialData.map((row) => row[3]); // Amount is 4th column in new format + + // Invert amount button should be visible + await expect(ynabPage.invertAmountButton).toBeVisible(); + + // Click invert amount button + await ynabPage.invertAmountButton.click(); + + // Get updated preview data + const invertedData = await ynabPage.getPreviewData(); + const invertedAmountColumn = invertedData.map((row) => row[3]); + + // Data should be different after inversion + expect(invertedAmountColumn).not.toEqual(initialAmountColumn); + + // Verify signs are actually inverted + // Initial positive becomes negative, initial negative becomes positive + for (let i = 0; i < initialAmountColumn.length; i++) { + const initial = initialAmountColumn[i].trim(); + const inverted = invertedAmountColumn[i].trim(); + + if (initial && inverted) { + if (initial.startsWith("-")) { + // Negative becomes positive + expect(inverted.startsWith("-")).toBe(false); + } else { + // Positive becomes negative + expect(inverted.startsWith("-")).toBe(true); + } + } + } + }); + + test("invert amount button only visible in new YNAB format", async ({ + page, + }) => { + // Check if we're in old format (Inflow/Outflow) + const outflowExists = await page + .locator('[data-testid="column-header-Outflow"]') + .isVisible(); + + if (outflowExists) { + // In old format - invert amount button should NOT be visible + await expect(ynabPage.invertAmountButton).not.toBeVisible(); + + // Toggle to new format + await ynabPage.toggleFormatButton.click(); + + // Map Amount column to make invert amount button appear + await ynabPage.setColumnMapping("Amount", "Balance"); + + // Now invert amount button should be visible + await expect(ynabPage.invertAmountButton).toBeVisible(); + } else { + // Already in new format - invert amount button should be visible + await ynabPage.setColumnMapping("Amount", "Balance"); + await expect(ynabPage.invertAmountButton).toBeVisible(); + + // Toggle to old format + await ynabPage.toggleFormatButton.click(); + + // Now invert amount button should NOT be visible + await expect(ynabPage.invertAmountButton).not.toBeVisible(); + } + }); +}); diff --git a/e2e/specs/export-download.spec.js b/e2e/specs/export-download.spec.js new file mode 100644 index 0000000..08295fe --- /dev/null +++ b/e2e/specs/export-download.spec.js @@ -0,0 +1,159 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; +import * as fs from "fs"; +import * as path from "path"; + +test.describe("Export and Download", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + + // Upload a test CSV file + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction 1,-50.00 +2024-01-02,Test Transaction 2,100.00 +2024-01-03,Test Transaction 3,-25.50`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-export.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("downloads converted YNAB file", async ({ page }) => { + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // For new format with Amount column + const amountColumnExists = + (await page.locator('[data-testid="column-select-Amount"]').count()) > 0; + if (amountColumnExists) { + await ynabPage.setColumnMapping("Amount", "Amount"); + } else { + // Old format - need to handle Outflow/Inflow + // This would require more complex logic to split positive/negative + } + + // Download the file + const download = await ynabPage.downloadConvertedFile(); + + // Verify download + expect(download).toBeTruthy(); + + // Check filename format (should be ynab_data_YYYYMMDD.csv) + const filename = download.suggestedFilename(); + expect(filename).toMatch(/^ynab_data_\d{8}\.csv$/); + + // Save and verify content + const downloadPath = await download.path(); + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // Should be valid CSV + expect(content).toContain(","); + + // Should contain our data + expect(content).toContain("Test Transaction 1"); + expect(content).toContain("Test Transaction 2"); + } + }); + + test("download button appears after file upload", async ({ page }) => { + await expect(ynabPage.downloadButton).toBeVisible(); + }); + + test("generates correct filename with current date", async ({ page }) => { + const download = await ynabPage.downloadConvertedFile(); + const filename = download.suggestedFilename(); + + // Get current date in YYYYMMDD format + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const expectedDate = `${year}${month}${day}`; + + expect(filename).toBe(`ynab_data_${expectedDate}.csv`); + }); + + test("exported file contains proper CSV format", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + const lines = content.trim().split("\n"); + + // Should have header row + expect(lines.length).toBeGreaterThan(1); + + // Check header contains YNAB columns + const header = lines[0]; + expect(header).toContain("Date"); + expect(header).toContain("Payee"); + + // Check data rows + expect(lines.length).toBe(4); // Header + 3 data rows + } + }); + + test("handles Excel file export", async ({ page }) => { + // Upload an Excel file instead + await page.reload(); + await ynabPage.goto(); + + await ynabPage.uploadFile("test_files/test.xlsx"); + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Download should work the same way + const download = await ynabPage.downloadConvertedFile(); + expect(download).toBeTruthy(); + + // Should still export as CSV + const filename = download.suggestedFilename(); + expect(filename).toMatch(/\.csv$/); + }); + + test("exported data respects column mappings", async ({ page }) => { + // Create specific mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Memo", "Amount"); // Intentionally wrong mapping + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // The Amount values should appear in Memo column + expect(content).toContain("-50.00"); + expect(content).toContain("100.00"); + + // Parse CSV to verify structure + const lines = content.trim().split("\n"); + const dataRow = lines[1].split(","); + + // Memo column (index 2) should have amount value + expect(dataRow[2]).toMatch(/[\-\d\.]+/); + } + }); +}); diff --git a/e2e/specs/file-upload.spec.js b/e2e/specs/file-upload.spec.js new file mode 100644 index 0000000..128bf25 --- /dev/null +++ b/e2e/specs/file-upload.spec.js @@ -0,0 +1,221 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("File Upload", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + }); + + test("shows upload interface on load", async ({ page }) => { + await expect(ynabPage.uploadWrapper).toBeVisible(); + await expect(ynabPage.dropzone).toBeVisible(); + await expect(page.getByText("Drop CSV or Excel file")).toBeVisible(); + }); + + test("accepts CSV file upload", async ({ page }) => { + // Create a test CSV file content + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction,-50.00 +2024-01-02,Another Transaction,100.00`; + + // Create a file from string + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await expect(ynabPage.toolWrapper).toBeVisible(); + await expect(ynabPage.dataTable).toBeVisible(); + await expect(ynabPage.settingsToggle).toBeVisible(); + }); + + // Test each Excel format + const excelFormats = [ + { ext: "xlsx", name: "Excel 2007+" }, + { ext: "xls", name: "Excel 97-2003" }, + { ext: "xlsm", name: "Excel with Macros" }, + { ext: "xlsb", name: "Binary Excel" }, + ]; + + for (const format of excelFormats) { + test(`uploads ${format.name} (${format.ext}) file successfully`, async ({ + page, + }) => { + const filePath = `test_files/test.${format.ext}`; + + await ynabPage.uploadFile(filePath); + + // Verify file was loaded + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 15000 }); + await expect(ynabPage.dataTable).toBeVisible(); + + // Verify file type badge shows correct format (wait longer for Excel processing) + await expect(ynabPage.fileTypeBadge).toBeVisible({ timeout: 15000 }); + const badgeText = await ynabPage.fileTypeBadge.textContent(); + expect(badgeText.toLowerCase()).toContain(format.ext); + }); + } + + test("shows worksheet selector for multi-sheet Excel files", async ({ + page, + }) => { + // Would need a multi-sheet test file + // For now, verify selector doesn't show for single-sheet files + await ynabPage.uploadFile("test_files/test.xlsx"); + + // Check if worksheet selector exists + const worksheetSelectorCount = await ynabPage.worksheetSelector.count(); + + // This test assumes test.xlsx has only one sheet + // Update when we have a multi-sheet test file + if (worksheetSelectorCount > 0) { + const worksheetOptions = await ynabPage.worksheetSelector + .locator("option") + .count(); + expect(worksheetOptions).toBeGreaterThan(0); + } + }); + + test("handles drag and drop file upload", async ({ page }) => { + // Create a test CSV for drag and drop + const csvContent = `Date,Payee,Amount +2024-01-01,Store A,-25.00 +2024-01-02,Store B,-30.00`; + + // Simulate drag and drop using test ID + await page.evaluate((content) => { + const dropzone = document.querySelector('[data-testid="dropzone"]'); + + // Create drag events + const dragEnterEvent = new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + }); + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true, + }); + + dropzone.dispatchEvent(dragEnterEvent); + dropzone.dispatchEvent(dragOverEvent); + + // Create file and drop event + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "drag-test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: dt, + }); + + dropzone.dispatchEvent(dropEvent); + }, csvContent); + + // Verify file was processed + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 5000 }); + }); + + test("shows error for invalid file types", async ({ page }) => { + // Create an invalid file type + await page.evaluate(() => { + const blob = new Blob(["invalid content"], { type: "text/plain" }); + const file = new File([blob], "test.txt", { type: "text/plain" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Wait a moment for the app to process the file + await page.waitForTimeout(1000); + + // The app should either: + // 1. Show the tool wrapper (if it parsed as CSV) + // 2. Stay on upload screen (if it failed) + // 3. Show some error state + const toolWrapperVisible = await ynabPage.toolWrapper.isVisible(); + const uploadWrapperVisible = await ynabPage.uploadWrapper.isVisible(); + + // At least one wrapper should be visible (upload or tool) + // If both are hidden, that indicates an unexpected error state + expect(toolWrapperVisible || uploadWrapperVisible).toBeTruthy(); + }); + + test("configuration dropdown is accessible", async ({ page }) => { + await ynabPage.openConfigDropdown(); + + await expect(ynabPage.encodingSelect).toBeVisible(); + await expect(ynabPage.delimiterSelect).toBeVisible(); + await expect(ynabPage.startRowInput).toBeVisible(); + await expect(ynabPage.extraRowCheckbox).toBeVisible(); + }); + + test("settings panel appears after successful file upload", async ({ + page, + }) => { + // Upload a valid CSV file + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction,-25.00 +2024-01-02,Another Transaction,100.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "settings-test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + // Wait for file processing + await expect(ynabPage.toolWrapper).toBeVisible(); + + // Verify settings panel toggle is visible + await expect(ynabPage.settingsToggle).toBeVisible(); + await expect(ynabPage.settingsToggle).toContainText( + "File parsing settings", + ); + + // Settings content should be hidden initially + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Toggle should work + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Should contain the same settings as pre-upload dropdown + const settingsContainer = ynabPage.settingsContent; + await expect( + settingsContainer.locator('[data-testid="encoding-select"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="delimiter-select"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="start-row-input"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="extra-row-checkbox"]'), + ).toBeVisible(); + }); +}); diff --git a/e2e/specs/settings-panel.spec.js b/e2e/specs/settings-panel.spec.js new file mode 100644 index 0000000..0473139 --- /dev/null +++ b/e2e/specs/settings-panel.spec.js @@ -0,0 +1,222 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("Settings Panel", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded + await page.waitForFunction(() => window.FileUtils !== undefined); + + // Upload a test CSV file to enable the settings panel + const csvContent = `Date,Description,Amount +2024-01-01,Grocery Store,-50.00 +2024-01-02,Salary,2000.00 +2024-01-03,Coffee Shop,-5.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("settings panel should be visible after file upload", async ({ + page, + }) => { + await expect(ynabPage.settingsToggle).toBeVisible(); + await expect(ynabPage.settingsToggle).toHaveText("File parsing settings"); + }); + + test("should toggle settings panel visibility", async ({ page }) => { + // Settings panel should be hidden by default + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Click to open + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Click to close + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).not.toBeVisible(); + }); + + test("should re-parse file when encoding changes", async ({ page }) => { + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + + // Change encoding + await ynabPage.changeSettingAfterUpload("encoding", "ISO-8859-1"); + + // Wait for re-parse + await page.waitForTimeout(1000); + + // Verify the encoding select in settings panel has the new value + const encodingSelect = ynabPage.settingsContent.locator( + '[data-testid="encoding-select"]', + ); + await expect(encodingSelect).toHaveValue("ISO-8859-1"); + }); + + test("should re-parse file when delimiter changes", async ({ page }) => { + // Upload a semicolon-delimited file + const csvContent = `Date;Description;Amount +2024-01-01;Store;-50.00 +2024-01-02;Income;1000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "semicolon.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Change delimiter to semicolon + await ynabPage.changeSettingAfterUpload("delimiter", ";"); + + // Verify data is parsed correctly + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + }); + + test("should re-parse file when start row changes", async ({ page }) => { + // Upload file with header rows + const csvContent = `Bank Statement +Generated on 2024-01-01 +Date,Description,Amount +2024-01-01,Store,-50.00 +2024-01-02,Income,1000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "header-rows.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Change start row to 3 to skip header lines + await ynabPage.changeSettingAfterUpload("startRow", 3); + + // Verify the input has the new value + const startRowInput = ynabPage.settingsContent.locator( + '[data-testid="start-row-input"]', + ); + await expect(startRowInput).toHaveValue("3"); + }); + + test("should toggle extra row checkbox", async ({ page }) => { + await ynabPage.changeSettingAfterUpload("extraRow", true); + + const checkbox = ynabPage.settingsContent.locator( + '[data-testid="extra-row-checkbox"]', + ); + await expect(checkbox).toBeChecked(); + + // Toggle again + await ynabPage.changeSettingAfterUpload("extraRow", false); + await expect(checkbox).not.toBeChecked(); + }); + + test("worksheet selector should appear for multi-sheet Excel files", async ({ + page, + }) => { + // This test would require a multi-sheet Excel file fixture + // For now, we'll verify that worksheet selector is not visible for CSV files + await ynabPage.toggleSettingsPanel(); + + const worksheetSelect = ynabPage.settingsContent.locator( + '[data-testid="worksheet-select"]', + ); + await expect(worksheetSelect).not.toBeVisible(); + }); + + test("settings should persist when toggling panel", async ({ page }) => { + // Change some settings + await ynabPage.changeSettingAfterUpload("delimiter", ";"); + await ynabPage.changeSettingAfterUpload("startRow", 2); + + // Close panel + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Reopen panel + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Verify settings are still the same + const delimiterSelect = ynabPage.settingsContent.locator( + '[data-testid="delimiter-select"]', + ); + const startRowInput = ynabPage.settingsContent.locator( + '[data-testid="start-row-input"]', + ); + + await expect(delimiterSelect).toHaveValue(";"); + await expect(startRowInput).toHaveValue("2"); + }); + + test("settings changes should update preview data", async ({ page }) => { + // Upload CSV with headers + const csvContent = `Transaction Date,Description,Debit,Credit +01/01/2024,Store,50.00, +02/01/2024,Salary,,2000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "transactions.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Get initial row count + const initialRows = await ynabPage.getPreviewData(); + const initialRowCount = initialRows.length; + + // Change start row to skip header + await ynabPage.changeSettingAfterUpload("startRow", 2); + + // Wait for re-parse and get new data + await page.waitForTimeout(1000); + const updatedRows = await ynabPage.getPreviewData(); + + // Should have different data after skipping header + expect(updatedRows).not.toEqual(initialRows); + }); +}); diff --git a/index.html b/index.html index 3da1e66..fdb307d 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - +
@@ -8,7 +8,7 @@ href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous" - > + /> - + - + + + + - - - +