diff --git a/tests/ApiTest.php b/tests/ApiTest.php
index 7b6f14ae2..cd7d7f0b5 100644
--- a/tests/ApiTest.php
+++ b/tests/ApiTest.php
@@ -369,9 +369,9 @@ public function testDraftLifecycleEndpoints(): void
$getDeletedResponse = $this->client->get($getUrl);
$this->assertEquals(
- 404,
+ 204,
$getDeletedResponse->getStatusCode(),
- 'Expected deleted draft to return 404. Response: ' . $getDeletedResponse->getBody()
+ 'Expected deleted draft to return 204. Response: ' . $getDeletedResponse->getBody()
);
}
}
diff --git a/tests/DraftControllerTest.php b/tests/DraftControllerTest.php
index 91a129d55..f3024793f 100644
--- a/tests/DraftControllerTest.php
+++ b/tests/DraftControllerTest.php
@@ -274,4 +274,15 @@ public function testDifferentSessionCannotReadDraft(): void
$this->assertSame(403, $forbiddenStatus);
}
+
+ public function testGetNonExistentDraftReturns204(): void
+ {
+ $controller = new DraftController();
+ [$status, $data] = $this->captureResponse(function () use ($controller) {
+ $controller->get(['id' => 'aaaabbbbccccdddd1111222233334444']);
+ });
+
+ $this->assertSame(204, $status);
+ $this->assertNull($data);
+ }
}
\ No newline at end of file
diff --git a/tests/js/autosaveService.test.js b/tests/js/autosaveService.test.js
index e1d42780b..f7cea9331 100644
--- a/tests/js/autosaveService.test.js
+++ b/tests/js/autosaveService.test.js
@@ -429,4 +429,114 @@ describe('autosaveService', () => {
expect(fetchMock.mock.calls[0][0]).toBe('/mde-msl/api/v2/drafts');
expect(service.apiBaseUrl).toBe('/mde-msl/api/v2');
});
+
+ test('clears stale draftId from localStorage when checkForExistingDraft gets 204', async () => {
+ window.localStorage.setItem('elmo.autosave.draftId', 'stale-draft-id');
+
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 204,
+ json: () => Promise.resolve(null)
+ });
+
+ const service = new AutosaveService('form-mde', {
+ fetch: fetchMock,
+ throttleMs: 0,
+ statusElementId: 'autosave-status',
+ statusTextId: 'autosave-status-text',
+ restoreModalId: 'modal-restore-draft'
+ });
+
+ service.start();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(window.localStorage.getItem('elmo.autosave.draftId')).toBeNull();
+ expect(service.draftId).toBeNull();
+ });
+
+ test('surfaces error when checkForExistingDraft gets 404 with stored draftId', async () => {
+ window.localStorage.setItem('elmo.autosave.draftId', 'old-draft-id');
+
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: () => Promise.resolve({ error: 'Draft not found' }),
+ text: () => Promise.resolve('{"error":"Draft not found"}')
+ });
+
+ const service = new AutosaveService('form-mde', {
+ fetch: fetchMock,
+ throttleMs: 0,
+ statusElementId: 'autosave-status',
+ statusTextId: 'autosave-status-text',
+ restoreModalId: 'modal-restore-draft'
+ });
+
+ service.start();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // 404 should surface as error (API returns 204 for missing drafts)
+ const statusEl = document.getElementById('autosave-status');
+ expect(statusEl.dataset.state).toBe('error');
+ // draftId should NOT be silently cleared
+ expect(window.localStorage.getItem('elmo.autosave.draftId')).toBe('old-draft-id');
+ });
+
+ test('does not clear draftId when 204 response is for session/latest (no stored draft)', async () => {
+ // No stored draftId => service queries session/latest
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 204,
+ json: () => Promise.resolve(null)
+ });
+
+ const service = new AutosaveService('form-mde', {
+ fetch: fetchMock,
+ throttleMs: 0,
+ statusElementId: 'autosave-status',
+ statusTextId: 'autosave-status-text',
+ restoreModalId: 'modal-restore-draft'
+ });
+
+ service.start();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // draftId should remain null (was never set)
+ expect(service.draftId).toBeNull();
+ expect(fetchMock.mock.calls[0][0]).toBe('./api/v2/drafts/session/latest');
+ });
+
+ test('surfaces error when 404 on session/latest (misconfigured route)', async () => {
+ // No stored draftId => service queries session/latest
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: () => Promise.resolve({ error: 'Route not found' }),
+ text: () => Promise.resolve('Route not found')
+ });
+
+ const service = new AutosaveService('form-mde', {
+ fetch: fetchMock,
+ throttleMs: 0,
+ statusElementId: 'autosave-status',
+ statusTextId: 'autosave-status-text',
+ restoreModalId: 'modal-restore-draft'
+ });
+
+ service.start();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Should surface as error, not silently ignored
+ expect(fetchMock.mock.calls[0][0]).toBe('./api/v2/drafts/session/latest');
+ const statusEl = document.getElementById('autosave-status');
+ expect(statusEl.dataset.state).toBe('error');
+ });
});
\ No newline at end of file
diff --git a/tests/js/descriptionTypes.test.js b/tests/js/descriptionTypes.test.js
index 2a6ae33ce..5519af528 100644
--- a/tests/js/descriptionTypes.test.js
+++ b/tests/js/descriptionTypes.test.js
@@ -214,6 +214,39 @@ describe('descriptionTypes.js', () => {
expect(slugs).toEqual([]);
});
+ test('resolves even when applyTranslations throws', async () => {
+ window.applyTranslations = jest.fn(() => {
+ throw new TypeError("Cannot read properties of undefined (reading 'logoTitle')");
+ });
+
+ $.ajax.mockImplementation(function (options) {
+ options.success([
+ { id: 1, name: 'Abstract', slug: 'Abstract' },
+ { id: 2, name: 'Methods', slug: 'Methods' }
+ ]);
+ });
+
+ const slugs = await module.initDescriptionTypes();
+
+ expect(slugs).toEqual(['Methods']);
+ expect(window.ELMO_ACTIVE_DESCRIPTION_TYPES).toEqual(['Methods']);
+ });
+
+ test('resolves even when updateHelpStatus throws', async () => {
+ window.updateHelpStatus = undefined;
+ global.updateHelpStatus = jest.fn(() => {
+ throw new Error('updateHelpStatus explosion');
+ });
+
+ $.ajax.mockImplementation(function (options) {
+ options.success([{ id: 2, name: 'Methods', slug: 'Methods' }]);
+ });
+
+ const slugs = await module.initDescriptionTypes();
+
+ expect(slugs).toEqual(['Methods']);
+ });
+
test('renders all 5 dynamic types correctly', async () => {
$.ajax.mockImplementation(function (options) {
options.success([
diff --git a/tests/js/freekeywordTags.test.js b/tests/js/freekeywordTags.test.js
index d8492d5b6..d6f1170a4 100644
--- a/tests/js/freekeywordTags.test.js
+++ b/tests/js/freekeywordTags.test.js
@@ -81,13 +81,13 @@ describe('freekeywordTags.js', () => {
errSpy.mockRestore();
});
- test('logs when no keywords returned', () => {
+ test('does not log curated keywords message when empty array returned', () => {
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
loadScript(() => ({
done(cb) { cb([]); return { fail: jest.fn() }; },
fail: jest.fn()
}));
- expect(logSpy).toHaveBeenCalledWith('ELMO currently has no curated keywords.');
+ expect(logSpy).not.toHaveBeenCalled();
const input = document.getElementById('input-freekeyword');
expect(input._tagify.settings.whitelist).toEqual([]);
logSpy.mockRestore();
diff --git a/tests/js/language.module.test.js b/tests/js/language.module.test.js
index ae94560e4..d2143ed07 100644
--- a/tests/js/language.module.test.js
+++ b/tests/js/language.module.test.js
@@ -207,6 +207,27 @@ describe('language module coverage', () => {
// applyTranslations should work without throwing
expect(() => languageModule.applyTranslations()).not.toThrow();
});
+
+ test('returns early without errors when translations are empty object', () => {
+ window.setTranslations({});
+ const originalTitle = document.title;
+ expect(() => languageModule.applyTranslations()).not.toThrow();
+ expect(document.title).toBe(originalTitle);
+ expect(window.resizeTitle).not.toHaveBeenCalled();
+ });
+
+ test('returns early without errors when translations.general is undefined', () => {
+ window.setTranslations({ other: { key: 'value' } });
+ const originalTitle = document.title;
+ expect(() => languageModule.applyTranslations()).not.toThrow();
+ expect(document.title).toBe(originalTitle);
+ expect(window.adjustButtons).not.toHaveBeenCalled();
+ });
+
+ test('returns early without errors when translations are null', () => {
+ window.setTranslations(null);
+ expect(() => languageModule.applyTranslations()).not.toThrow();
+ });
});
describe('loadTranslations', () => {
diff --git a/tests/playwright/flows/stage-diagnosis.spec.ts b/tests/playwright/flows/stage-diagnosis.spec.ts
new file mode 100644
index 000000000..6fb2831aa
--- /dev/null
+++ b/tests/playwright/flows/stage-diagnosis.spec.ts
@@ -0,0 +1,206 @@
+/**
+ * Regression test for console errors and XML upload field population.
+ *
+ * Verifies that the following issues do not regress:
+ * - Race condition in applyTranslations() when descriptionTypes AJAX resolves first
+ * - Abstract and Funder fields not populated during XML upload
+ * - Duplicate modal IDs in HTML
+ *
+ * Uses the Playwright baseURL fixture (from playwright.config.ts or --base-url).
+ * Override via Playwright config, e.g.:
+ * npx playwright test stage-diagnosis --project=chromium
+ */
+import { test, expect } from '@playwright/test';
+import { navigateToHome } from '../utils';
+
+const SAMPLE_XML = `
+
+ 10.1234/elmo.test
+ 2024
+ en
+
+ Stage Diagnosis Test
+
+
+
+ Doe, Jane
+ Jane
+ Doe
+
+
+
+ This is a test abstract for diagnosis.
+
+ Dataset
+
+ CC BY 4.0
+
+
+
+ Deutsche Forschungsgemeinschaft
+ 501100001659
+ TEST123
+ Test Grant Title
+
+
+`;
+
+test.describe('Console errors regression', () => {
+ test('no JavaScript errors on initial page load', async ({ page }) => {
+ const jsErrors: string[] = [];
+ const consoleErrors: string[] = [];
+
+ // Register listeners BEFORE navigation to capture initialization-time errors
+ page.on('pageerror', (err) => {
+ jsErrors.push(err.message);
+ });
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ await navigateToHome(page);
+
+ // Wait for async initialization via concrete conditions, not fixed sleeps
+ await page.waitForFunction(
+ () => (window as any).descriptionTypesReady instanceof Promise,
+ { timeout: 10_000 },
+ );
+ await page.evaluate(() => (window as any).descriptionTypesReady);
+
+ // Filter out known external/non-critical warnings
+ const criticalJsErrors = jsErrors.filter(
+ (e) => !e.includes('google.maps') && !e.includes('installHook'),
+ );
+ expect(criticalJsErrors).toEqual([]);
+
+ // Assert no unexpected console.error messages
+ const realConsoleErrors = consoleErrors.filter(
+ (e) =>
+ !e.includes('favicon.ico') &&
+ !e.includes('API key not found') &&
+ !e.includes('503') &&
+ !e.includes('thesauri availability'),
+ );
+ expect(realConsoleErrors).toEqual([]);
+ });
+
+ test('descriptionTypesReady promise resolves', async ({ page }) => {
+ await navigateToHome(page);
+
+ // Wait for the promise to exist on window
+ await page.waitForFunction(
+ () => (window as any).descriptionTypesReady instanceof Promise,
+ { timeout: 10_000 },
+ );
+
+ const promiseState = await page.evaluate(() => {
+ return new Promise
((resolve) => {
+ if (!(window as any).descriptionTypesReady) {
+ resolve('NOT_SET');
+ return;
+ }
+ let resolved = false;
+ (window as any).descriptionTypesReady.then(() => {
+ resolved = true;
+ resolve('RESOLVED');
+ });
+ setTimeout(() => {
+ if (!resolved) resolve('STUCK');
+ }, 10000);
+ });
+ });
+
+ expect(promiseState).toBe('RESOLVED');
+ });
+
+ test('no duplicate HTML IDs on upload modal', async ({ page }) => {
+ await navigateToHome(page);
+
+ // Wait for the upload modal element to exist in the DOM (it is hidden until opened)
+ await page.locator('#modal-uploadxml').waitFor({ state: 'attached', timeout: 10_000 });
+
+ // Verify the upload modal ID is unique (was duplicated on both div and h5)
+ const uploadModalCount = await page.evaluate(() => {
+ return document.querySelectorAll('#modal-uploadxml').length;
+ });
+
+ expect(uploadModalCount).toBe(1);
+
+ // Verify the label element now has a distinct ID
+ const labelExists = await page.evaluate(() => {
+ return document.querySelector('#modal-uploadxml-label') !== null;
+ });
+
+ expect(labelExists).toBe(true);
+ });
+});
+
+test.describe('XML Upload field population regression', () => {
+ test('Abstract and Funder fields are populated after XML upload', async ({ page }) => {
+ const jsErrors: string[] = [];
+
+ // Register pageerror listener BEFORE navigation
+ page.on('pageerror', (err) => {
+ jsErrors.push(err.message);
+ });
+
+ await navigateToHome(page);
+
+ // Wait for async initialization via concrete condition
+ await page.waitForFunction(
+ () => (window as any).descriptionTypesReady instanceof Promise,
+ { timeout: 10_000 },
+ );
+
+ // Click Load button
+ const loadButton = page.locator('#button-form-load');
+ await expect(loadButton).toBeVisible({ timeout: 10_000 });
+ await loadButton.click();
+
+ // Wait for upload modal
+ const modal = page.locator('div#modal-uploadxml');
+ await expect(modal).toBeVisible({ timeout: 5_000 });
+
+ // Upload XML file
+ await page.setInputFiles('#input-uploadxml-file', {
+ name: 'regression-test.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(SAMPLE_XML, 'utf-8'),
+ });
+
+ // Wait for title to be populated (indicates loadXmlToForm completed initial steps)
+ await expect(page.locator('#input-resourceinformation-title')).toHaveValue(
+ 'Stage Diagnosis Test',
+ { timeout: 15_000 },
+ );
+
+ // Wait for descriptionTypesReady to resolve (gates Funder/Abstract population)
+ await page.evaluate(() => (window as any).descriptionTypesReady);
+
+ // Assert Abstract was populated
+ await expect(page.locator('#input-abstract')).toHaveValue(
+ 'This is a test abstract for diagnosis.',
+ { timeout: 10_000 },
+ );
+
+ // Assert Funder fields were populated
+ await expect(page.locator('input[name="funder[]"]').first()).toHaveValue(
+ 'Deutsche Forschungsgemeinschaft',
+ { timeout: 10_000 },
+ );
+ await expect(page.locator('input[name="grantNummer[]"]').first()).toHaveValue(
+ 'TEST123',
+ );
+ await expect(page.locator('input[name="grantName[]"]').first()).toHaveValue(
+ 'Test Grant Title',
+ );
+
+ // No uncaught JS exceptions during upload
+ const criticalJsErrors = jsErrors.filter(
+ (e) => !e.includes('google.maps') && !e.includes('installHook'),
+ );
+ expect(criticalJsErrors).toEqual([]);
+ });
+});
diff --git a/tests/playwright/flows/xml-upload-datacite47.spec.ts b/tests/playwright/flows/xml-upload-datacite47.spec.ts
new file mode 100644
index 000000000..f20ba2525
--- /dev/null
+++ b/tests/playwright/flows/xml-upload-datacite47.spec.ts
@@ -0,0 +1,286 @@
+import { test, expect } from '@playwright/test';
+import { navigateToHome } from '../utils';
+
+/**
+ * Full DataCite 4.7 XML with nearly all properties that ELMO supports:
+ * - Personal + Organizational creator
+ * - Multiple title types
+ * - Description (Abstract, Methods, TechnicalInfo)
+ * - Funding reference
+ * - Related identifiers
+ * - Dates (Created)
+ * - Free keyword subjects
+ * - Rights / License
+ * - Contact Person contributor
+ */
+const DATACITE_47_XML = `
+
+ 10.82433/B09Z-4K37
+
+
+ Müller, Erika
+ Erika
+ Müller
+ https://orcid.org/0000-0001-5727-2427
+ Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ
+
+
+ ACME Research Corporation
+
+
+
+ Full DataCite 4.7 Upload Test
+
+ GFZ Data Services
+ 2025
+ Dataset
+
+ geophysics
+ seismology
+
+
+
+ Müller, Erika
+ Erika
+ Müller
+ https://orcid.org/0000-0001-5727-2427
+ Helmholtz-Zentrum Potsdam Deutsches GeoForschungsZentrum GFZ
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+ https://orcid.org/0000-0002-9876-5432
+
+
+
+ 2025-03-15
+
+ en
+
+ 10.5555/example-supplement
+
+
+ Creative Commons Attribution 4.0 International
+
+
+ This is a comprehensive test abstract for the DataCite 4.7 upload flow. It verifies that all major metadata fields are correctly mapped from XML to the ELMO form.
+ Seismic data was collected using broadband stations deployed across the study area.
+ Data format: MiniSEED. Sampling rate: 100 Hz. Station network: GE.
+
+
+
+ Deutsche Forschungsgemeinschaft
+ https://doi.org/10.13039/501100001659
+ DFG-12345
+ Seismic Monitoring Network Expansion
+
+
+`;
+
+test.describe('DataCite 4.7 Full XML Upload (Docker E2E)', () => {
+ /** Console errors and JS exceptions collected across the test. */
+ let consoleErrors: string[];
+ let jsErrors: string[];
+
+ test.beforeEach(async ({ page }) => {
+ consoleErrors = [];
+ jsErrors = [];
+
+ // Register listeners BEFORE navigation to capture initialization-time errors
+ page.on('pageerror', (err) => {
+ jsErrors.push(err.message);
+ });
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ await navigateToHome(page);
+
+ // Wait for the page to be fully loaded: dropdowns populated, description types loaded
+ await expect(page.locator('#input-resourceinformation-doi')).toBeVisible({ timeout: 15_000 });
+
+ // Wait for language dropdown to have meaningful options
+ await page.waitForFunction(() => {
+ const s = document.querySelector('#input-resourceinformation-language');
+ return s != null && s.options.length > 2;
+ }, { timeout: 30_000 });
+
+ // Wait for resource type dropdown to have options
+ await page.waitForFunction(() => {
+ const s = document.querySelector('#input-resourceinformation-resourcetype');
+ return s != null && s.options.length > 1;
+ }, { timeout: 15_000 });
+ });
+
+ test('uploads DataCite 4.7 XML and verifies all major fields are populated', async ({ page }) => {
+
+ // ── Step 1: Open upload modal and load XML ─────────────────────────
+ await page.locator('#button-form-load').click();
+ const modal = page.locator('div#modal-uploadxml');
+ await expect(modal).toBeVisible({ timeout: 5_000 });
+
+ await page.setInputFiles('#input-uploadxml-file', {
+ name: 'datacite47-full.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(DATACITE_47_XML, 'utf-8'),
+ });
+
+ // Wait for title to be populated (indicates XML processing is done)
+ await expect(page.locator('#input-resourceinformation-title')).toHaveValue(
+ 'Full DataCite 4.7 Upload Test',
+ { timeout: 20_000 },
+ );
+
+ // ── Step 2: Resource Information ───────────────────────────────────
+ await expect(page.locator('#input-resourceinformation-doi')).toHaveValue('10.82433/B09Z-4K37');
+ await expect(page.locator('#input-resourceinformation-publicationyear')).toHaveValue('2025');
+
+ // Language: the uploaded XML specifies en;
+ // dropdown values are numeric IDs, so check the selected option's visible text
+ const langText = await page.locator('#input-resourceinformation-language option:checked').textContent();
+ expect(langText?.trim().toLowerCase()).toContain('english');
+
+ // Resource type: expect "Dataset" is selected (value may be an ID)
+ const rtText = await page.locator('#input-resourceinformation-resourcetype option:checked').textContent();
+ expect(rtText?.trim().toLowerCase()).toContain('dataset');
+
+ // ── Step 3: Author (Personal) ──────────────────────────────────────
+ const authorRows = page.locator('div[data-creator-row]');
+ await expect(authorRows.first()).toBeVisible();
+
+ const firstAuthor = authorRows.first();
+ await expect(firstAuthor.locator('[id^="input-author-lastname"]')).toHaveValue('Müller');
+ await expect(firstAuthor.locator('[id^="input-author-firstname"]')).toHaveValue('Erika');
+
+ // ── Step 4: Author Institution (Organizational) ────────────────────
+ const instRows = page.locator('div[data-authorinstitution-row]');
+ await expect(instRows.first()).toBeVisible();
+ await expect(instRows.first().locator('[id^="input-authorinstitution-name"]')).toHaveValue(
+ 'ACME Research Corporation',
+ );
+
+ // ── Step 5: Contributor Person (DataCollector) ──────────────────────
+ // Note: ContactPerson is mapped via ISO pointOfContact only, not from
+ // pure DataCite XML. We verify the DataCollector contributor instead.
+ // Scope by attribute (not #group-contributorperson which is a duplicate ID in the DOM).
+ const contributorPersonRows = page.locator('div[contributor-person-row]');
+ const cpLastName = contributorPersonRows.first().locator('[id^="input-contributor-lastname"]');
+ await expect(cpLastName).toHaveValue('Schmidt', { timeout: 10_000 });
+ const cpFirstName = contributorPersonRows.first().locator('[id^="input-contributor-firstname"]');
+ await expect(cpFirstName).toHaveValue('Thomas');
+
+ // ── Step 6: Abstract ───────────────────────────────────────────────
+ await expect(page.locator('#input-abstract')).toHaveValue(/comprehensive test abstract/);
+
+ // ── Step 7: Methods description (dynamically loaded accordion) ────
+ // Guard: ensure the promise exists before awaiting (avoids no-op on script failure)
+ await page.waitForFunction(
+ () => (window as any).descriptionTypesReady instanceof Promise,
+ { timeout: 10_000 },
+ );
+ await page.evaluate(() => (window as any).descriptionTypesReady);
+
+ const methodsInput = page.locator('#input-description-Methods');
+ if (await methodsInput.count() > 0) {
+ // Expand the Methods accordion if needed
+ const methodsCollapse = page.locator('#collapse-description-Methods');
+ if (!(await methodsCollapse.isVisible())) {
+ await page.locator('[data-bs-target="#collapse-description-Methods"]').click();
+ await methodsCollapse.waitFor({ state: 'visible' });
+ }
+ await expect(methodsInput).toHaveValue(/Seismic data was collected/);
+ }
+
+ // ── Step 8: TechnicalInfo description ──────────────────────────────
+ const techInput = page.locator('#input-description-TechnicalInfo');
+ if (await techInput.count() > 0) {
+ const techCollapse = page.locator('#collapse-description-TechnicalInfo');
+ if (!(await techCollapse.isVisible())) {
+ await page.locator('[data-bs-target="#collapse-description-TechnicalInfo"]').click();
+ await techCollapse.waitFor({ state: 'visible' });
+ }
+ await expect(techInput).toHaveValue(/MiniSEED/);
+ }
+
+ // ── Step 9: Date Created ───────────────────────────────────────────
+ await expect(page.locator('#input-date-created')).toHaveValue('2025-03-15');
+
+ // ── Step 10: Free Keywords ─────────────────────────────────────────
+ // Keywords are stored as Tagify tags; check the underlying input value
+ const keywordValue = await page.locator('#input-freekeyword').inputValue();
+ expect(keywordValue.toLowerCase()).toContain('geophysics');
+ expect(keywordValue.toLowerCase()).toContain('seismology');
+
+ // ── Step 11: Funding Reference ─────────────────────────────────────
+ await expect(page.locator('input[name="funder[]"]').first()).toHaveValue('Deutsche Forschungsgemeinschaft');
+ await expect(page.locator('input[name="grantNummer[]"]').first()).toHaveValue('DFG-12345');
+ await expect(page.locator('input[name="grantName[]"]').first()).toHaveValue('Seismic Monitoring Network Expansion');
+
+ // ── Step 12: Related Work ──────────────────────────────────────────
+ await expect(page.locator('#input-relatedwork-identifier').first()).toHaveValue('10.5555/example-supplement');
+
+ // ── Step 13: License ───────────────────────────────────────────────
+ const licenseText = await page.locator('#input-rights-license option:checked').textContent();
+ expect(licenseText?.toLowerCase()).toContain('cc');
+
+ // ── Step 14: No console errors ─────────────────────────────────────
+ // Filter known CI-environment messages (no ERNIE API key / external services)
+ const realErrors = consoleErrors.filter(
+ (e) =>
+ !e.includes('favicon.ico') &&
+ !e.includes('google.maps') &&
+ !e.includes('installHook') &&
+ !e.includes('API key not found') &&
+ !e.includes('503') &&
+ !e.includes('thesauri availability'),
+ );
+ expect(realErrors, `Unexpected console errors: ${realErrors.join('\n')}`).toEqual([]);
+
+ // No uncaught JS exceptions (pageerror)
+ const criticalJsErrors = jsErrors.filter(
+ (e) => !e.includes('google.maps') && !e.includes('installHook'),
+ );
+ expect(criticalJsErrors, `Unexpected JS errors: ${criticalJsErrors.join('\n')}`).toEqual([]);
+ });
+
+ test('uploads envelope-wrapped XML and populates fields correctly', async ({ page }) => {
+ // Wrap the DataCite XML inside an element (the format produced by
+ // the ELMO API when exporting multiple schemas in a single file).
+ const envelopeXml = `\n\n${
+ DATACITE_47_XML.replace(/^<\?xml[^?]*\?>\s*/, '')
+ }\n`;
+
+ await page.locator('#button-form-load').click();
+ const modal = page.locator('div#modal-uploadxml');
+ await expect(modal).toBeVisible({ timeout: 5_000 });
+
+ await page.setInputFiles('#input-uploadxml-file', {
+ name: 'datacite47-envelope.xml',
+ mimeType: 'text/xml',
+ buffer: Buffer.from(envelopeXml, 'utf-8'),
+ });
+
+ // Title proves that loadXmlToForm found inside
+ await expect(page.locator('#input-resourceinformation-title')).toHaveValue(
+ 'Full DataCite 4.7 Upload Test',
+ { timeout: 20_000 },
+ );
+
+ // Wait for async processing to finish
+ await page.waitForFunction(
+ () => (window as any).descriptionTypesReady instanceof Promise,
+ { timeout: 10_000 },
+ );
+ await page.evaluate(() => (window as any).descriptionTypesReady);
+
+ // Spot-check a few fields to verify full mapping succeeded
+ await expect(page.locator('#input-abstract')).toHaveValue(/comprehensive test abstract/);
+ await expect(page.locator('input[name="funder[]"]').first()).toHaveValue('Deutsche Forschungsgemeinschaft');
+ await expect(page.locator('input[name="grantNummer[]"]').first()).toHaveValue('DFG-12345');
+ });
+});