diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b691d955..166c901d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,14 @@ ones are marked like "v1.0.0-fork".
### Fixed
+* **Selecting several words in the reader produced a term named after hashes**:
+ creating a multi-word term captured each word's `data_hex` identity token
+ instead of its text, so the term came out as e.g.
+ "e6967a5826fe441a 75e3090289e2955d" and the example sentence lost its
+ `{...}` markers. `data_hex` used to be a reversible hex encoding of the word,
+ which is why reading it once worked; it became a SHA-256 derived token in
+ 3.2.0 (#237) and this call site was never updated.
+
* **The "edit term" button in the review table went nowhere** (#266): it pointed
at `edit_tword.php`, a filename that stopped being routed in v3, and opened it
in a frame the reader no longer renders. It now links to `/word/edit-term`,
diff --git a/cypress/e2e/01-setup.cy.ts b/cypress/e2e/01-setup.cy.ts
index 551a0e746..b4f527508 100644
--- a/cypress/e2e/01-setup.cy.ts
+++ b/cypress/e2e/01-setup.cy.ts
@@ -9,10 +9,19 @@ describe('Database Setup', () => {
it('should install demo database', () => {
cy.visit('/admin/install-demo');
cy.get('form').should('exist');
- // Check the confirmation checkbox first (required to enable the install button)
+
+ // The checkbox is server-rendered, so it is clickable before Alpine has
+ // bound `x-model` to it — and a click that lands first is dropped
+ // silently. Alpine applying `:disabled="!confirmed"` to the install button
+ // is proof it has processed this tree, so gate on that before ticking.
+ cy.get('button[type="submit"], input[type="submit"]').should('be.disabled');
cy.get('input[type="checkbox"]').check();
- // Now click the install button
- cy.get('button[type="submit"], input[type="submit"]').click();
+
+ // Then let Cypress retry until the tick has propagated; it retries
+ // assertions but never retries the click itself.
+ cy.get('button[type="submit"], input[type="submit"]')
+ .should('not.be.disabled')
+ .click();
// Wait for install to complete and page to reload
cy.url().should('include', '/admin/install-demo');
// Should show success message or remain on page
@@ -23,7 +32,7 @@ describe('Database Setup', () => {
cy.visit('/languages');
// Check that the languages page loads and has content
// The page uses Alpine.js with card-based layout
- cy.get('[x-data="languageList"], .language-card, .action-card').should('exist');
+ cy.get('[x-data="languageList"]').should('exist');
});
it('should have demo texts after install', () => {
diff --git a/cypress/e2e/04-languages.cy.ts b/cypress/e2e/04-languages.cy.ts
index fb564c179..705861ac0 100644
--- a/cypress/e2e/04-languages.cy.ts
+++ b/cypress/e2e/04-languages.cy.ts
@@ -1,5 +1,16 @@
///
+/**
+ * One row per language in the "All Languages" table.
+ *
+ * The list has been through two redesigns (action card -> header button, cards
+ * -> table). The old `.language-card` selector outlived the markup and, because
+ * most assertions here are wrapped in `if (find('.language-card').length > 0)`,
+ * the tests kept passing while asserting nothing. Keep the row selector defined
+ * once so the next redesign is a one-line fix rather than silent rot.
+ */
+const LANG_ROW = 'table.is-hoverable tbody tr';
+
describe('Languages Management', () => {
beforeEach(() => {
cy.visit('/languages');
@@ -18,21 +29,16 @@ describe('Languages Management', () => {
cy.get('[x-data="languageList"]').should('exist');
});
- it('should display language cards or empty state after loading', () => {
- // Wait for Alpine.js to initialize and load data
+ it('should display the language table or the empty state after loading', () => {
cy.get('[x-data="languageList"]').should('exist');
- // Wait for loading indicator to disappear (give it time for API calls)
- cy.wait(500);
- // Should have either language cards or an action card
- cy.get('.language-card, .action-card, p').should('exist');
+ cy.get(`${LANG_ROW}, a[href*="/languages/new"]`).should('exist');
});
it('should display demo languages when installed', () => {
- // Skip if no languages installed
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
+ if ($body.find(LANG_ROW).length > 0) {
cy.fixture('test-data').then((data) => {
- cy.get('.language-card').should('contain', data.demoLanguages[0]);
+ cy.get(LANG_ROW).should('contain', data.demoLanguages[0]);
});
} else {
cy.log('No languages installed - skipping demo language check');
@@ -40,10 +46,11 @@ describe('Languages Management', () => {
});
});
- it('should have action buttons for each language card when languages exist', () => {
+ it('should have action buttons on each language row when languages exist', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-card .card-footer-item').should('exist');
+ if ($body.find(LANG_ROW).length > 0) {
+ // Actions are icon-only buttons/links in the trailing cell.
+ cy.get(LANG_ROW).first().find('.buttons a, .buttons button').should('exist');
} else {
cy.log('No languages installed - skipping action buttons check');
}
@@ -52,56 +59,49 @@ describe('Languages Management', () => {
it('should have edit links for languages when they exist', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-card a[href*="/edit"]').should('exist');
+ if ($body.find(LANG_ROW).length > 0) {
+ cy.get(`${LANG_ROW} a[href*="/edit"]`).should('exist');
} else {
cy.log('No languages installed - skipping edit links check');
}
});
});
- it('should display language statistics when languages exist', () => {
+ it('should display per-language counts when languages exist', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-stats').should('exist');
+ if ($body.find(LANG_ROW).length > 0) {
+ // Texts / archived / terms / feeds counts each link out to their list.
+ cy.get(LANG_ROW).first().find('td.has-text-centered a').should('have.length.at.least', 3);
} else {
- cy.log('No languages installed - skipping stats check');
+ cy.log('No languages installed - skipping counts check');
}
});
});
- it('should have "New Language" button in action card', () => {
- cy.get('.action-card a[href*="/languages/new"]').should('exist');
- });
-
- it('should have "Quick Setup Wizard" button', () => {
- cy.get('.action-card a').contains('Quick Setup Wizard').should('exist');
+ it('should have a "New Language" button', () => {
+ // The list used to carry an .action-card; the button now lives in the
+ // page header (and in the empty state when there are no languages).
+ cy.get('a[href*="/languages/new"]').should('exist');
});
});
- describe('Language Card Actions', () => {
- it('should show Set as Default button for non-default languages', () => {
+ describe('Language Row Actions', () => {
+ it('should show a "Set as Current" button for non-current languages', () => {
cy.get('body').then(($body) => {
- const $cards = $body.find('.language-card');
- if ($cards.length > 0) {
- // Find a card that's not current (doesn't have is-current class)
- const nonCurrentCard = $cards.filter(':not(.is-current)').first();
- if (nonCurrentCard.length) {
- cy.wrap(nonCurrentCard)
- .find('button')
- .contains('Set as Default')
- .should('exist');
- }
- } else {
- cy.log('No languages installed - skipping default button check');
+ if ($body.find(LANG_ROW).length === 0) {
+ cy.log('No languages installed - skipping set-current button check');
+ return;
}
+ // The button is icon-only and rendered only for rows that are not the
+ // current language, so identify it by its title attribute.
+ cy.get(`${LANG_ROW} button[title="Set as Current"]`).should('exist');
});
});
it('should navigate to edit page when Edit is clicked', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card a[href*="/edit"]').length > 0) {
- cy.get('.language-card a[href*="/edit"]').first().click();
+ if ($body.find(`${LANG_ROW} a[href*="/edit"]`).length > 0) {
+ cy.get(`${LANG_ROW} a[href*="/edit"]`).first().click();
cy.url().should('match', /\/languages\/\d+\/edit/);
cy.get('form').should('exist');
} else {
@@ -112,11 +112,13 @@ describe('Languages Management', () => {
});
describe('Delete Confirmation Modal', () => {
+ // Delete is only offered for languages with no texts, words or feeds.
+ const DELETE_BTN = `${LANG_ROW} button[title="Delete"]`;
+
it('should show delete confirmation when delete is clicked', () => {
cy.get('body').then(($body) => {
- const $deleteBtn = $body.find('.language-card .card-footer-item:contains("Delete")');
- if ($deleteBtn.length > 0) {
- cy.get('.language-card .card-footer-item').contains('Delete').first().click();
+ if ($body.find(DELETE_BTN).length > 0) {
+ cy.get(DELETE_BTN).first().click();
cy.get('.modal.is-active').should('exist');
cy.get('.modal-card-title').should('contain', 'Confirm Delete');
} else {
@@ -127,9 +129,8 @@ describe('Languages Management', () => {
it('should close modal when Cancel is clicked', () => {
cy.get('body').then(($body) => {
- const $deleteBtn = $body.find('.language-card .card-footer-item:contains("Delete")');
- if ($deleteBtn.length > 0) {
- cy.get('.language-card .card-footer-item').contains('Delete').first().click();
+ if ($body.find(DELETE_BTN).length > 0) {
+ cy.get(DELETE_BTN).first().click();
cy.get('.modal.is-active').should('exist');
cy.get('.modal-card-foot button').contains('Cancel').click();
cy.get('.modal.is-active').should('not.exist');
@@ -140,144 +141,6 @@ describe('Languages Management', () => {
});
});
- describe('Wizard Modal', () => {
- it('should open wizard modal when Quick Setup Wizard is clicked', () => {
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
-
- // Modal should appear
- cy.get('.modal.is-active').should('exist');
- cy.get('.modal-card-title').should('contain', 'Quick Language Setup');
- });
-
- it('should have L1 and L2 language dropdowns', () => {
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
-
- cy.get('.modal.is-active select').should('have.length', 2);
- });
-
- it('should close wizard modal when Cancel is clicked', () => {
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
- cy.get('.modal.is-active').should('exist');
-
- // Click the Cancel button in the wizard modal (not the delete modal)
- cy.get('.modal.is-active .modal-card-foot button').contains('Cancel').click();
-
- cy.get('.modal.is-active').should('not.exist');
- });
-
- it('should have Create button in wizard modal', () => {
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
-
- // Wait for modal to appear
- cy.get('.modal.is-active').should('exist');
-
- // The Create Language button should exist in the modal footer
- // It may be disabled initially via Alpine.js :disabled binding
- cy.get('.modal-card-foot').should('contain', 'Create Language');
- });
-
- it('should apply wizard preset values when creating a language', () => {
- // Wait for definitions to load
- cy.wait(1000);
-
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
- cy.get('.modal.is-active').should('exist');
-
- // Select L1 (native language) - English (first dropdown)
- cy.get('.modal.is-active select').eq(0).select('English');
-
- // Select L2 (study language) - Latvian (second dropdown)
- cy.get('.modal.is-active select').eq(1).select('Latvian');
-
- // Click Create Language button
- cy.get('.modal-card-foot button').contains('Create Language').click();
-
- // Should navigate to the language form with wizard=1 param
- cy.url().should('include', '/languages/new');
- cy.url().should('include', 'wizard=1');
-
- // Wait for preset to be applied
- cy.wait(500);
-
- // Verify preset values are applied
- cy.get('input[name="LgName"]').should('have.value', 'Latvian');
-
- // Expand Advanced Settings to check parsing settings
- cy.contains('Advanced Settings').click();
-
- // For Latvian: rightToLeft should be false (unchecked)
- cy.get('input[name="LgRightToLeft"]').should('not.be.checked');
-
- // For Latvian: word characters regex should be set
- cy.get('input[name="LgRegexpWordCharacters"]').invoke('val').should('not.be.empty');
-
- // For Latvian: sentence split regex should be set
- cy.get('input[name="LgRegexpSplitSentences"]').invoke('val').should('not.be.empty');
-
- // Dictionary should be populated with Glosbe URL
- cy.get('input[name="LgDict1URI"]').invoke('val').should('include', 'glosbe.com');
- });
-
- it('should create language with correct settings from wizard', () => {
- // Wait for definitions to load
- cy.wait(1000);
-
- cy.get('.action-card a').contains('Quick Setup Wizard').click();
- cy.get('.modal.is-active').should('exist');
-
- // Select L1 (native language) - English (first dropdown)
- cy.get('.modal.is-active select').eq(0).select('English');
-
- // Use Danish - less commonly used in tests
- const baseLangName = 'Danish';
-
- // Select L2 (study language) - Danish (second dropdown) to avoid existing languages
- cy.get('.modal.is-active select').eq(1).select(baseLangName);
-
- // Click Create Language button
- cy.get('.modal-card-foot button').contains('Create Language').click();
-
- // Wait for navigation
- cy.url().should('include', '/languages/new');
- cy.url().should('include', 'wizard=1');
-
- // Wait for preset to be applied
- cy.wait(500);
-
- // Make the language name unique by adding a timestamp
- const uniqueLangName = `${baseLangName} Test ${Date.now()}`;
- cy.get('input[name="LgName"]').clear().type(uniqueLangName);
-
- // Submit the form
- cy.get('button[type="submit"]').click();
-
- // Should redirect to texts/new after successful creation
- cy.url().should('include', '/texts/new');
-
- // Now verify the language was created with correct settings
- // Navigate to languages list
- cy.visit('/languages');
- cy.wait(1000);
-
- // Find the language card and go to edit
- cy.get('.language-card').contains(uniqueLangName).closest('.language-card').within(() => {
- cy.get('a[href*="/edit"]').click();
- });
-
- // Verify the settings were saved correctly
- cy.get('input[name="LgName"]').should('have.value', uniqueLangName);
-
- // Expand Advanced Settings
- cy.contains('Advanced Settings').click();
-
- // Danish should NOT be right-to-left
- cy.get('input[name="LgRightToLeft"]').should('not.be.checked');
-
- // Word characters should be set (not empty)
- cy.get('input[name="LgRegexpWordCharacters"]').invoke('val').should('not.be.empty');
- });
- });
-
describe('Embedded Wizard', () => {
it('should apply settings when selecting language from embedded wizard', () => {
cy.visit('/languages/new');
@@ -322,6 +185,49 @@ describe('Languages Management', () => {
// Sentence split regex should be set
cy.get('input[name="LgRegexpSplitSentences"]').invoke('val').should('not.be.empty');
});
+
+ it('should persist wizard-derived settings after saving', () => {
+ cy.visit('/languages/new');
+ cy.wait(500);
+
+ cy.get('input#l2').closest('.searchable-select').within(() => {
+ cy.get('.searchable-select__trigger').click();
+ cy.get('.searchable-select__dropdown input[type="text"]').type('Danish');
+ });
+ cy.get('.searchable-select__options li').contains('Danish').click();
+ cy.wait(300);
+
+ const uniqueLangName = `Danish Test ${Date.now()}`;
+ cy.get('input[name="LgName"]').clear().type(uniqueLangName);
+
+ // The embedded wizard fills the parsing settings but not the dictionary
+ // URI, which is required — supply it the way a user would before saving.
+ cy.contains('Advanced Settings').click();
+ cy.get('input[name="LgDict1URI"]').then(($input) => {
+ if (!$input.val()) {
+ cy.wrap($input).type('https://example.com/###');
+ }
+ });
+
+ cy.get('form[name="lg_form"] button[type="submit"]').click();
+
+ // Creating a language now hands off to the starter-vocabulary step.
+ cy.url().should('match', /\/languages\/\d+\/starter-vocab/);
+
+ // The list is client-rendered from /api/v1/languages, so wait for the
+ // Alpine root and then for the card itself rather than a fixed delay.
+ cy.visit('/languages');
+ cy.get('[x-data="languageList"]').should('exist');
+ cy.contains(LANG_ROW, uniqueLangName, { timeout: 10000 })
+ .within(() => {
+ cy.get('a[href*="/edit"]').click();
+ });
+
+ cy.get('input[name="LgName"]').should('have.value', uniqueLangName);
+ cy.contains('Advanced Settings').click();
+ cy.get('input[name="LgRightToLeft"]').should('not.be.checked');
+ cy.get('input[name="LgRegexpWordCharacters"]').invoke('val').should('not.be.empty');
+ });
});
describe('Create Language', () => {
@@ -377,8 +283,9 @@ describe('Languages Management', () => {
// Submit the form
cy.get('button[type="submit"]').click();
- // After creating a new language, it redirects to texts/new to add first text
- cy.url().should('include', '/texts/new');
+ // Creating a language hands off to the starter-vocabulary step, which
+ // offers to seed the new language before you write your first text.
+ cy.url().should('match', /\/languages\/\d+\/starter-vocab/);
});
});
@@ -394,8 +301,8 @@ describe('Languages Management', () => {
it('should load edit form for existing language', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-card').first().within(() => {
+ if ($body.find(LANG_ROW).length > 0) {
+ cy.get(LANG_ROW).first().within(() => {
cy.get('a[href*="/edit"]').click();
});
cy.get('form[name="lg_form"]').should('exist');
@@ -408,8 +315,8 @@ describe('Languages Management', () => {
it('should have populated fields', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-card').first().find('a[href*="/edit"]').click();
+ if ($body.find(LANG_ROW).length > 0) {
+ cy.get(LANG_ROW).first().find('a[href*="/edit"]').click();
cy.get('input[name="LgName"]').invoke('val').should('not.be.empty');
} else {
cy.log('No languages installed - skipping populated fields test');
@@ -419,8 +326,8 @@ describe('Languages Management', () => {
it('should have cancel link that returns to list', () => {
cy.get('body').then(($body) => {
- if ($body.find('.language-card').length > 0) {
- cy.get('.language-card').first().find('a[href*="/edit"]').click();
+ if ($body.find(LANG_ROW).length > 0) {
+ cy.get(LANG_ROW).first().find('a[href*="/edit"]').click();
// Cancel is a link, not a button
cy.contains('a', 'Cancel').click();
cy.url().should('eq', Cypress.config().baseUrl + '/languages');
diff --git a/cypress/e2e/05-texts.cy.ts b/cypress/e2e/05-texts.cy.ts
index 9c032d00a..19278edab 100644
--- a/cypress/e2e/05-texts.cy.ts
+++ b/cypress/e2e/05-texts.cy.ts
@@ -1,5 +1,24 @@
///
+/**
+ * Open /texts/new and advance the two-step wizard to the review step.
+ *
+ * Step 1 picks where the text comes from; the title/body fields only live in
+ * step 2. The source tiles are plain server-rendered markup, so waiting for
+ * `[x-data="textNewForm"]` proves nothing — it is present before Alpine runs,
+ * and a click that lands first is dropped silently by the CSP build (no error,
+ * no effect). Alpine strips `x-cloak` once it has initialised the tree, so the
+ * absence of that attribute is the real "handlers are bound" signal.
+ */
+function startPastedText(): void {
+ cy.visit('/texts/new');
+ cy.get('div[x-show="step === 2"]').should('not.have.attr', 'x-cloak');
+ cy.contains('.is-clickable', /paste text/i)
+ .should('be.visible')
+ .click();
+ cy.get('input[name="TxTitle"]').should('be.visible');
+}
+
describe('Texts Management', () => {
beforeEach(() => {
cy.visit('/text/edit');
@@ -64,26 +83,13 @@ describe('Texts Management', () => {
});
it('should create a new text', () => {
- cy.visit('/texts/new');
-
const uniqueTitle = `Test Text ${Date.now()}`;
- // Fill in required fields
- cy.get('input[name="TxTitle"]').type(uniqueTitle);
-
- // Select first available language using the searchable-select component
- // The component uses Alpine.js and stores options in x-data
- cy.get('.searchable-select').first().as('langSelect');
+ startPastedText();
- // Click to open the dropdown
- cy.get('@langSelect').find('.searchable-select__trigger').click();
-
- // Wait for dropdown to be visible and select first non-placeholder option
- cy.get('@langSelect')
- .find('.searchable-select__options li:not(.searchable-select__empty)')
- .should('have.length.at.least', 2)
- .eq(1) // Skip the [Choose...] placeholder
- .click();
+ // The language is inherited from the navbar's current-language selection
+ // and submitted as a hidden TxLgID input.
+ cy.get('input[name="TxTitle"]').type(uniqueTitle);
// Add text content
cy.get('textarea[name="TxText"]').type(
@@ -98,26 +104,13 @@ describe('Texts Management', () => {
});
it('should create a new text and open it for reading with Save & Open', () => {
- cy.visit('/texts/new');
-
const uniqueTitle = `Save Open Test ${Date.now()}`;
+ startPastedText();
+
// Fill in required fields
cy.get('input[name="TxTitle"]').type(uniqueTitle);
- // Select first available language using the searchable-select component
- cy.get('.searchable-select').first().as('langSelect');
-
- // Click to open the dropdown
- cy.get('@langSelect').find('.searchable-select__trigger').click();
-
- // Wait for dropdown to be visible and select first non-placeholder option
- cy.get('@langSelect')
- .find('.searchable-select__options li:not(.searchable-select__empty)')
- .should('have.length.at.least', 2)
- .eq(1) // Skip the [Choose...] placeholder
- .click();
-
// Add text content
cy.get('textarea[name="TxText"]').type(
'This is a save and open test. It should redirect to the reading page.'
@@ -197,10 +190,13 @@ describe('Texts Management', () => {
it('should have required fields for text creation', () => {
cy.visit('/texts/new');
- // Language selector - now uses searchable-select component
- cy.get('.searchable-select, select[name="TxLgID"]').should('exist');
- // Text input area
- cy.get('textarea[name="TxText"]').should('exist');
+ // The language is no longer picked here — it comes from the navbar's
+ // current-language selection and rides along as a hidden input.
+ cy.get('input[name="TxLgID"]').should('exist').and('not.have.value', '');
+
+ // The title/body fields live in step 2, reached by choosing a source.
+ startPastedText();
+ cy.get('textarea[name="TxText"]').should('be.visible');
});
});
});
diff --git a/cypress/e2e/06-words.cy.ts b/cypress/e2e/06-words.cy.ts
index 58652e51a..b569f82e0 100644
--- a/cypress/e2e/06-words.cy.ts
+++ b/cypress/e2e/06-words.cy.ts
@@ -19,13 +19,13 @@ describe('Words Management', () => {
it('should have language filter', () => {
// Words page uses Alpine.js with x-model bindings
- cy.get('[x-data="wordListApp()"]').should('exist');
+ cy.get('[x-data="wordListApp"]').should('exist');
cy.get('select').should('exist');
});
it('should have status filter', () => {
// The Alpine.js app has filter options including status
- cy.get('[x-data="wordListApp()"] select').should('have.length.at.least', 1);
+ cy.get('[x-data="wordListApp"] select').should('have.length.at.least', 1);
});
it('should have search/query input', () => {
@@ -35,7 +35,7 @@ describe('Words Management', () => {
it('should have filter controls', () => {
// The page has filter dropdowns (language, status, etc.)
- cy.get('[x-data="wordListApp()"]').should('exist');
+ cy.get('[x-data="wordListApp"]').should('exist');
cy.get('select').should('have.length.at.least', 1);
});
});
diff --git a/cypress/e2e/07-admin.cy.ts b/cypress/e2e/07-admin.cy.ts
index 25186a08c..92fa4f4b2 100644
--- a/cypress/e2e/07-admin.cy.ts
+++ b/cypress/e2e/07-admin.cy.ts
@@ -30,20 +30,21 @@ describe('Admin Pages', () => {
});
describe('Statistics', () => {
- it('should load statistics page', () => {
+ it('should redirect the legacy /admin/statistics URL to the per-user page', () => {
+ // Statistics became per-user; /admin/statistics is kept as a redirect.
cy.visit('/admin/statistics');
- cy.url().should('include', '/admin/statistics');
+ cy.url().should('include', '/profile/statistics');
cy.get('body').should('be.visible');
});
it('should show statistics sections', () => {
- cy.visit('/admin/statistics');
+ cy.visit('/profile/statistics');
// The statistics page uses Chart.js with box/card sections
cy.get('.box, [x-data="statisticsApp()"]').should('exist');
});
it('should show statistics data', () => {
- cy.visit('/admin/statistics');
+ cy.visit('/profile/statistics');
// Statistics page has JSON data for charts with language statistics
cy.get('#statistics-intensity-data, #statistics-frequency-data, canvas').should('exist');
});
diff --git a/cypress/e2e/08-api.cy.ts b/cypress/e2e/08-api.cy.ts
index e83e0a853..d01e6dc1b 100644
--- a/cypress/e2e/08-api.cy.ts
+++ b/cypress/e2e/08-api.cy.ts
@@ -96,7 +96,7 @@ describe('REST API', () => {
describe('Settings Endpoint', () => {
it('should accept POST to save setting', () => {
- cy.request({
+ cy.apiRequest({
method: 'POST',
url: `${apiBase}/settings`,
form: true,
@@ -131,7 +131,7 @@ describe('REST API', () => {
});
it('should return 405 for unsupported methods', () => {
- cy.request({
+ cy.apiRequest({
method: 'DELETE',
url: `${apiBase}/version`,
failOnStatusCode: false,
@@ -178,7 +178,7 @@ describe('REST API', () => {
});
it('should set reading position', () => {
- cy.request({
+ cy.apiRequest({
method: 'POST',
url: `${apiBase}/texts/1/reading-position`,
form: true,
diff --git a/cypress/e2e/09-reading.cy.ts b/cypress/e2e/09-reading.cy.ts
index f99b17afe..0eb71edd1 100644
--- a/cypress/e2e/09-reading.cy.ts
+++ b/cypress/e2e/09-reading.cy.ts
@@ -27,8 +27,10 @@ describe('Reading Interface', () => {
}
});
- // Wait for page to load
- cy.url().should('include', '/text/read');
+ // Accept both the RESTful `/text/{id}/read` and the legacy
+ // `/text/read?start={id}` form — which of the two we land on depends on
+ // whether the list had a text to click or we used the fallback below.
+ cy.url().should('match', /\/text\/(\d+\/)?read/);
cy.wait(500);
};
@@ -58,6 +60,49 @@ describe('Reading Interface', () => {
});
describe('Multi-Word Selection', () => {
+ // These tests select the first two words of the first sentence and turn
+ // them into a multi-word term. That term persists, and on the next run the
+ // reader renders it as one span covering both words — so the "first two
+ // words" become an overlapping pair and the selection yields nothing.
+ // Without this reset the suite passes exactly once per fresh database.
+ //
+ // Deleting the term is not sufficient, and checking for one is not a valid
+ // guard: the multi-word *span* lives in the text's parsed items and
+ // survives the term with wordId=null, so a run can have zero multi-word
+ // terms and still render the span. Only a reparse drops it.
+ //
+ // Runs once rather than per test, because these tests deliberately chain —
+ // one creates the term and a later one re-selects it to exercise the
+ // existing-term path, which per-test cleanup would break.
+ before(() => {
+ cy.request({ url: '/api/v1/terms/list', qs: { count: 500 }, failOnStatusCode: false })
+ .then((res) => {
+ const words = (res.body?.words ?? []) as Array<{ id: number; text: string }>;
+ words
+ .filter((w) => /\s/.test(w.text ?? ''))
+ .forEach((w) => {
+ cy.apiRequest({
+ method: 'DELETE',
+ url: `/api/v1/terms/${w.id}`,
+ failOnStatusCode: false
+ });
+ });
+ });
+
+ cy.request({ url: '/api/v1/languages', failOnStatusCode: false }).then((langRes) => {
+ const langs = (langRes.body?.languages ?? []) as Array<{ id: number; textCount: number }>;
+ langs
+ .filter((l) => l.textCount > 0)
+ .forEach((l) => {
+ cy.apiRequest({
+ method: 'POST',
+ url: `/api/v1/languages/${l.id}/refresh`,
+ failOnStatusCode: false
+ });
+ });
+ });
+ });
+
beforeEach(() => {
visitReadingPage();
// Wait for text to be fully rendered
@@ -268,14 +313,21 @@ describe('Reading Interface', () => {
});
it('should show multi-word text with spaces in modal', () => {
- // Get a sentence with at least 2 words
cy.get('#thetext [id^="sent_"]').first().as('sentence');
- cy.get('@sentence').find('.wsty').should('have.length.at.least', 2);
- // Get the first two words
- cy.get('@sentence').find('.wsty').then(($words) => {
- const firstWord = $words[0];
- const secondWord = $words[1];
+ // By the time this runs the earlier tests have created a multi-word term
+ // over the first two words, so `.wsty` index 0 is that combined span and
+ // index 1 is its own first word — an overlapping pair that selects
+ // nothing. Restrict to plain single-word spans (`data_code` is only set
+ // on multi-word spans) and take the last adjacent pair, which the
+ // earlier tests never touch.
+ cy.get('@sentence')
+ .find('.wsty:not([data_code])')
+ .should('have.length.at.least', 2);
+
+ cy.get('@sentence').find('.wsty:not([data_code])').then(($singles) => {
+ const firstWord = $singles[$singles.length - 2];
+ const secondWord = $singles[$singles.length - 1];
// Create a text selection spanning both words
cy.window().then((win) => {
diff --git a/cypress/e2e/10-auth.cy.ts b/cypress/e2e/10-auth.cy.ts
index a912205fb..4a6e1f441 100644
--- a/cypress/e2e/10-auth.cy.ts
+++ b/cypress/e2e/10-auth.cy.ts
@@ -58,12 +58,14 @@ describe('Authentication', () => {
});
});
- it('should have a link to WordPress login when multi-user enabled', () => {
+ it('should link to WordPress login when the WordPress integration is enabled', () => {
+ // The link is gated on WORDPRESS_ENABLED, not on multi-user mode — the two
+ // are independent, so presence of the login form says nothing about it.
cy.get('body').then(($body) => {
- if ($body.find('form[action="/login"]').length > 0) {
- cy.get('a[href="/wordpress/start"]').should('exist');
+ if ($body.find('a[href="/wordpress/start"]').length > 0) {
+ cy.get('a[href="/wordpress/start"]').should('be.visible');
} else {
- cy.log('Multi-user mode disabled - skipping WordPress login test');
+ cy.log('WORDPRESS_ENABLED unset - no WordPress login link expected');
}
});
});
@@ -82,7 +84,7 @@ describe('Authentication', () => {
cy.get('body').then(($body) => {
if ($body.find('form[action="/login"]').length > 0) {
// Submit empty form
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// HTML5 validation should prevent submission or show error
cy.get('input#username:invalid').should('exist');
} else {
@@ -100,7 +102,7 @@ describe('Authentication', () => {
cy.get('input#username').type('nonexistent_user');
cy.get('input#password').type('wrongpassword');
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for navigation/response
cy.wait(500);
@@ -120,7 +122,7 @@ describe('Authentication', () => {
const testUsername = 'test_preserved_user';
cy.get('input#username').type(testUsername);
cy.get('input#password').type('wrongpassword');
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for navigation/response
cy.wait(500);
@@ -267,7 +269,7 @@ describe('Authentication', () => {
cy.get('input#email').type(testUser.email);
cy.get('input#password').type(testUser.password);
cy.get('input#password_confirm').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -298,7 +300,7 @@ describe('Authentication', () => {
cy.get('input#email').type(`different_${testUser.email}`);
cy.get('input#password').type(testUser.password);
cy.get('input#password_confirm').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -322,7 +324,7 @@ describe('Authentication', () => {
cy.get('input#email').type(testUser.email);
cy.get('input#password').type(testUser.password);
cy.get('input#password_confirm').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -345,7 +347,7 @@ describe('Authentication', () => {
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -369,7 +371,7 @@ describe('Authentication', () => {
cy.get('input#username').type(testUser.email);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -393,7 +395,7 @@ describe('Authentication', () => {
// First login
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -420,7 +422,7 @@ describe('Authentication', () => {
// Login
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Wait for response
cy.wait(500);
@@ -451,7 +453,7 @@ describe('Authentication', () => {
// Multi-user mode is enabled - login required
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// After login, should not be on login page
cy.url().should('not.include', '/login');
@@ -481,12 +483,12 @@ describe('Authentication', () => {
// Multi-user mode - try to login first
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
cy.wait(500);
}
// Check settings endpoint works (should work in both modes)
- cy.request({
+ cy.apiRequest({
method: 'POST',
url: `${apiBase}/settings`,
form: true,
@@ -566,7 +568,7 @@ describe('Authentication', () => {
cy.get('input#username').type(testUser.username);
cy.get('input#password').type(testUser.password);
- cy.get('button[type="submit"]').click();
+ cy.get('form[method="POST"] button[type="submit"]').click();
// Password should never appear in URL
cy.url().should('not.include', testUser.password);
@@ -578,8 +580,17 @@ describe('Authentication', () => {
cy.get('body').then(($body) => {
if ($body.find('form[action="/login"]').length > 0) {
- // Form should use POST method
- cy.get('form[action="/login"]').should('have.attr', 'method', 'POST');
+ // Assert on the form that actually carries credentials rather than on
+ // `form[action="/login"]`, which also matches the page's language
+ // switcher (a legitimate GET form). The property that matters is that
+ // no password is ever submitted via GET, where it would land in the
+ // URL, browser history and access logs.
+ cy.get('form')
+ .filter((_i, form) => form.querySelector('input[type="password"]') !== null)
+ .should('have.length.at.least', 1)
+ .each(($form) => {
+ expect($form.attr('method')).to.match(/^post$/i);
+ });
} else {
cy.log('Multi-user mode disabled - skipping login form security test');
}
@@ -589,7 +600,12 @@ describe('Authentication', () => {
cy.get('body').then(($body) => {
if ($body.find('form[action="/register"]').length > 0) {
- cy.get('form[action="/register"]').should('have.attr', 'method', 'POST');
+ cy.get('form')
+ .filter((_i, form) => form.querySelector('input[type="password"]') !== null)
+ .should('have.length.at.least', 1)
+ .each(($form) => {
+ expect($form.attr('method')).to.match(/^post$/i);
+ });
} else {
cy.log('Multi-user mode disabled - skipping register form security test');
}
diff --git a/cypress/e2e/99-screenshots.cy.ts b/cypress/e2e/99-screenshots.cy.ts
index 080a6d0ae..82c5f200b 100644
--- a/cypress/e2e/99-screenshots.cy.ts
+++ b/cypress/e2e/99-screenshots.cy.ts
@@ -52,49 +52,25 @@ describe('Documentation Screenshots', () => {
it('adding-text - Text creation form', () => {
cy.visit('/texts/new');
- cy.wait(500);
- // Wait for form to load
+ // /texts/new is a two-step wizard. The source tiles are server-rendered,
+ // so wait until Alpine has stripped x-cloak before clicking one —
+ // otherwise the click is dropped silently and step 2 never appears.
+ cy.get('div[x-show="step === 2"]').should('not.have.attr', 'x-cloak');
+ cy.contains('.is-clickable', /paste text/i).should('be.visible').click();
+
cy.get('form').should('exist');
cy.get('input[name="TxTitle"]').should('be.visible');
- // Fill in some example data for a nicer screenshot
+ // Fill in some example data for a nicer screenshot. The language is no
+ // longer chosen here — it comes from the navbar's current language.
cy.get('input[name="TxTitle"]').type('Le Petit Prince - Chapitre 1');
- // Select French if available using the searchable-select component
- cy.get('.searchable-select').first().as('langSelect');
- cy.get('@langSelect').find('.searchable-select__trigger').click();
- // Wait for dropdown to be visible
- cy.get('@langSelect')
- .find('.searchable-select__options')
- .should('be.visible');
- // Try to find French, otherwise select the first non-placeholder option
- cy.get('@langSelect')
- .find('.searchable-select__options li:not(.searchable-select__empty)')
- .then(($options) => {
- let found = false;
- $options.each((i, opt) => {
- const text = opt.textContent?.toLowerCase() || '';
- if (text.includes('french') || text.includes('français')) {
- cy.wrap(opt).click();
- found = true;
- return false; // break loop
- }
- });
- if (!found) {
- // Select first non-placeholder option
- cy.get('@langSelect')
- .find('.searchable-select__options li:not(.searchable-select__empty)')
- .eq(1)
- .click();
- }
- });
-
// Add sample French text
cy.get('textarea[name="TxText"]').type(
- `Lorsque j'avais six ans j'ai vu, une fois, une magnifique image, dans un livre sur la Forêt Vierge qui s'appelait "Histoires Vécues". Ça représentait un serpent boa qui avalait un fauve.
+ `Lorsque j'avais six ans j'ai vu, une fois, une magnifique image, dans un livre sur la For\u00eat Vierge qui s'appelait "Histoires V\u00e9cues". \u00c7a repr\u00e9sentait un serpent boa qui avalait un fauve.
-On disait dans le livre: "Les serpents boas avalent leur proie tout entière, sans la mâcher. Ensuite ils ne peuvent plus bouger et ils dorment pendant les six mois de leur digestion".`
+On disait dans le livre: "Les serpents boas avalent leur proie tout enti\u00e8re, sans la m\u00e2cher. Ensuite ils ne peuvent plus bouger et ils dorment pendant les six mois de leur digestion".`
);
cy.wait(300);
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index 25ebc8e86..6e01eaff5 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -17,10 +17,31 @@ declare global {
* Check that a form field with validation class exists and is required
*/
checkRequiredField(selector: string): Chainable>;
+
+ /**
+ * Issue an API request the way the real client does.
+ *
+ * State-changing verbs (POST/PUT/DELETE/PATCH) are rejected with 403 by
+ * CsrfMiddleware unless they carry either a Bearer token or the
+ * `X-CSRF-TOKEN` header. `@shared/api/client` reads that token from
+ * ``; this command does the same, so a bare
+ * `cy.request()` in a spec is almost always a bug.
+ */
+ apiRequest(
+ options: Partial & { url: string }
+ ): Chainable>;
+
+ /**
+ * Read the current session's CSRF token from a rendered page.
+ */
+ csrfToken(): Chainable;
}
}
}
+/** Verbs CsrfMiddleware guards; mirrors its PROTECTED_METHODS. */
+const CSRF_PROTECTED = ['POST', 'PUT', 'DELETE', 'PATCH'];
+
// Database reset via demo install
Cypress.Commands.add('installDemo', () => {
cy.visit('/admin/install-demo');
@@ -39,4 +60,32 @@ Cypress.Commands.add('checkRequiredField', (selector: string) => {
return cy.get(selector).should('exist').and('be.visible');
});
+// Read the CSRF token for the current session from a rendered page.
+// `cy.request` shares the browser's cookie jar, so the token minted here
+// belongs to the same session the subsequent request will authenticate under.
+Cypress.Commands.add('csrfToken', () => {
+ return cy.request('/').then((response) => {
+ const match = / {
+ const method = String(options.method ?? 'GET').toUpperCase();
+
+ if (!CSRF_PROTECTED.includes(method)) {
+ return cy.request(options);
+ }
+
+ return cy.csrfToken().then((token) => {
+ return cy.request({
+ ...options,
+ headers: { ...(options.headers ?? {}), 'X-CSRF-TOKEN': token },
+ });
+ });
+});
+
export {};
diff --git a/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts b/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts
index 1003e2bb7..6cfd4b9fa 100644
--- a/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts
+++ b/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts
@@ -75,12 +75,17 @@ function getSelectedWords(container: HTMLElement): HTMLElement[] {
* Get the surface text of a single word span, ignoring any inline annotation
* children (e.g. that renders the translation hint).
*
- * Prefers data_hex (canonical surface form set server-side); falls back to
- * the first text-node child when missing.
+ * Do NOT read `data_hex` here. It used to be a reversible hex encoding of the
+ * surface, which made it a legitimate source; since #237 it is a SHA-256
+ * derived identity token, so using it yielded terms literally named
+ * "e6967a5826fe441a 75e3090289e2955d".
+ *
+ * `data_text` carries the surface for multi-word spans, whose own text content
+ * is the word count in "show all" mode. Everything else reads its text nodes.
*/
function getWordSurface(el: HTMLElement): string {
- const hex = el.getAttribute('data_hex');
- if (hex !== null && hex !== '') return hex;
+ const dataText = el.getAttribute('data_text');
+ if (dataText !== null && dataText !== '') return dataText;
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent;
diff --git a/tests/frontend/reading/text_multiword_selection.test.ts b/tests/frontend/reading/text_multiword_selection.test.ts
index ebd4c4079..6c37e1ab4 100644
--- a/tests/frontend/reading/text_multiword_selection.test.ts
+++ b/tests/frontend/reading/text_multiword_selection.test.ts
@@ -143,6 +143,41 @@ describe('text_multiword_selection.ts', () => {
);
});
+ it('uses the visible surface, not the data_hex identity token', () => {
+ // Regression: data_hex used to be a reversible hex encoding of the word,
+ // so reading it yielded the surface. Since #237 it is a SHA-256 derived
+ // identity token, and reading it produced terms literally named
+ // "e6967a5826fe441a 75e3090289e2955d".
+ document.body.innerHTML = `
+
+
+ Hello
+ World
+
+
+ `;
+ const container = document.getElementById('thetext')!;
+ const word1 = container.querySelector('[data_order="1"]')!;
+ const word2 = container.querySelector('[data_order="2"]')!;
+
+ const mockRange = {
+ intersectsNode: (node: Node) => node === word1 || node === word2
+ };
+ const mockSelection = {
+ isCollapsed: false,
+ rangeCount: 1,
+ getRangeAt: vi.fn().mockReturnValue(mockRange),
+ removeAllRanges: vi.fn()
+ };
+ vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection as unknown as Selection);
+
+ handleTextSelection(container);
+
+ expect(mockStore.loadForEdit).toHaveBeenCalledWith(1, 1, 'Hello World', 2);
+ });
+
it('shows alert when selected text is too long', () => {
// Words are stored consecutively without space elements
document.body.innerHTML = `