Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

### Fixed

* 開票結果、投票結果、候選名單與選票編輯表格消除重複 HTML 編碼;自訂標題及欄 A 保留經淨化的換行與排版(管理端候選人管理列表的欄 A 一併適用),欄 B–F 維持純文字,既有 entity 不作資料轉換
* 英文介面的紙本選票自訂文字欄位誤讀多一個 E 的欄名而發生錯誤;保留中英並列與原有數字欄位的語言選擇
* Yii log 檢視頁的 UrlModal 資產指向未隨 Git 提供的目錄而發生 500;改為追蹤前端腳本,內容載入沿用目前頁面的協定與主機
* 殘留的 Bootstrap 4 樣式在 Bootstrap 5 下失效:輪次與投票狀態徽章看不見、對話框關閉鈕錯位,核取方塊、輸入群組、間距與邊框也一併改為 Bootstrap 5 寫法
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ php tests/e2e/run.php --base=http://localhost:18080

| 組別 | 內容 |
|---|---|
| `result-fields` | 中英文結果頁、名單與選票編輯的安全換行及純文字;攔截 GridView 產生的 CSV/Excel/HTML 匯出內容,檢查編碼與淨化,並檢查紙本選票。測試資料與欄位設定於 finally 還原;不包含 LibreOffice 開檔驗證 |
| `pages` | 全畫面巡檢(投票者 6 頁、管理介面 56 頁),每頁檢查:HTTP 狀態、未被導向登入、無 PHP 錯誤訊息、無未捕捉的 JS 例外、無 Bootstrap 4 殘留 class、徽章看得見(非白字透明底)、無破圖,並截圖 |
| `header` | 頁首輪次/投票狀態徽章、捲動後的投票名稱徽章 |
| `modals` | 對話框 × 位置、Esc/點背景不關閉(static)、× 可關閉;重新載入 8 次確認遮罩不會蓋住對話框;新增群組成員對話框 |
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* --base 受測網站(預設 E2E_BASE_URL 或 http://localhost:8080),須為測試入口並已載入 fixture
* --cdp 既有 Chrome 遠端除錯位址(預設 E2E_CDP_URL 或 http://127.0.0.1:9222);無法連線時自動啟動 Chrome
* --chrome Chrome/Edge 執行檔(預設 E2E_CHROME 或自動偵測)
* --suite 只跑指定組別:pages, header, modals, attachments, photos, results, voting, anon, layout
* --suite 只跑指定組別:pages, header, modals, attachments, photos, results, result-fields, voting, anon, layout
*
* 結束代碼:全部通過為 0,否則為 1。截圖與 results.json 輸出至 tests/_output/e2e/
*/
Expand All @@ -18,7 +18,7 @@
$opt = getopt('', ['base:', 'cdp:', 'chrome:', 'suite:']);
$base = $opt['base'] ?? getenv('E2E_BASE_URL') ?: 'http://localhost:8080';
$cdpUrl = rtrim($opt['cdp'] ?? getenv('E2E_CDP_URL') ?: 'http://127.0.0.1:9222', '/');
$allSuites = ['pages', 'header', 'modals', 'attachments', 'photos', 'results', 'voting', 'anon', 'layout'];
$allSuites = ['pages', 'header', 'modals', 'attachments', 'photos', 'results', 'result-fields', 'voting', 'anon', 'layout'];
$suites = isset($opt['suite']) ? array_intersect(explode(',', $opt['suite']), $allSuites) : $allSuites;
$outDir = dirname(__DIR__).'/_output/e2e';
@mkdir($outDir.'/files', 0777, true);
Expand Down
139 changes: 139 additions & 0 deletions tests/e2e/suites/result-fields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php
/** Result rendering and export generation, using only fixture data and UI forms. */
$admin->section('result-fields');
$vote = 'AnonPartyTest';
$resultUrl = "/count/result?voteID=$vote&hl=zh-TW";
$readFields = function ($url, $ids) use ($admin) {
$admin->go($url);
return $admin->js('(() => { const out = {}; for (const id of '.json_encode($ids).') {
const e = document.getElementById(id); if (!e) throw new Error("Missing field: " + id);
out[id] = e.value;
} return out; })()');
};
$saveFields = function ($url, $values) use ($admin) {
$admin->go($url);
$admin->js('(() => { for (const [id, value] of Object.entries('.json_encode($values).')) {
const e = document.getElementById(id); if (!e) throw new Error("Missing field: " + id);
e.value = value;
} })()');
$admin->click("[...document.querySelectorAll('button[type=submit]')].find(e => e.innerText.trim() === '修改')");
$admin->waitLoad();
if (!$admin->waitFor("![...document.querySelectorAll('.invalid-feedback,.help-block')].some(e => e.offsetParent !== null && e.innerText.trim())", 1000)) {
throw new RuntimeException('Form validation failed: '.implode(' | ', $admin->flashes()));
}
};
$originalResults = $readFields($resultUrl, ['formresultsconfig-showfieldsort']);
$count = "[...document.querySelectorAll('button[type=submit]')].find(e => e.innerText.trim() === '開票')";
if ($admin->js("!!($count)")) {
$admin->click($count);
$admin->waitLoad();
}
$admin->go('/ballot-work/result?voteID='.$vote.'&hl=zh-TW');
$candidateId = $admin->js('document.querySelector("tbody tr[data-key]")?.getAttribute("data-key")');
if (!$candidateId) {
throw new RuntimeException('No visible result candidate');
}
$manualUrl = "/candi/manual?voteID=$vote&action=update&id=$candidateId&hl=zh-TW";
$admin->go($manualUrl);
$questionId = $admin->js('document.querySelector("#formcandidata-questionid").value');
$configUrl = "/candi/config?voteID=$vote&questionID=$questionId&hl=zh-TW";
$originalConfigs = [];
// Result grids use the first question's labels; candidate lists and print use their own question.
foreach (array_unique([$configUrl, "/candi/config?voteID=$vote&questionID=1&hl=zh-TW"]) as $url) {
$originalConfigs[$url] = $readFields($url, ['formcandiconfig-showfieldsort', 'formcandiconfig-othercolnamea', 'formcandiconfig-othercolnameae']);
}
$candidateOriginal = null;
try {
foreach ($originalConfigs as $url => $originalConfig) {
$fields = json_decode($originalConfig['formcandiconfig-showfieldsort'], true);
foreach (['otherColA', 'otherColB'] as $field) {
if (!in_array($field, array_column($fields, 'id'))) {
$fields[] = ['id' => $field, 'children' => []];
}
}
$saveFields($url, [
'formcandiconfig-showfieldsort' => json_encode($fields),
'formcandiconfig-othercolnamea' => 'P16LabelZh<br><b>R&D</b>',
'formcandiconfig-othercolnameae' => 'P16LabelEn<br><b>R&D</b>',
]);
}
$candidateOriginal = $readFields($manualUrl, ['formcandidata-othercola', 'formcandidata-othercolae', 'formcandidata-othercolb', 'formcandidata-othercolbe']);
$saveFields($manualUrl, [
'formcandidata-othercola' => 'P16Zh<br><b onclick="window.p16attack=1">R&D</b>',
'formcandidata-othercolae' => 'P16En<br><b onclick="window.p16attack=1">R&D</b>',
'formcandidata-othercolb' => 'P16TextZh<br>R&D',
'formcandidata-othercolbe' => 'P16TextEn<br>R&D',
]);
$saved = $readFields($manualUrl, ['formcandidata-othercola', 'formcandidata-othercolb']);
if (!str_starts_with($saved['formcandidata-othercola'], 'P16Zh') || !str_starts_with($saved['formcandidata-othercolb'], 'P16TextZh')) {
throw new RuntimeException('Candidate markup was not saved');
}
$resultFields = explode(',', $originalResults['formresultsconfig-showfieldsort']);
$saveFields($resultUrl, ['formresultsconfig-showfieldsort' => implode(',', array_unique(array_merge($resultFields, ['otherColA', 'otherColB'])))]);
$admin->go($resultUrl);
$count = "[...document.querySelectorAll('button[type=submit]')].find(e => e.innerText.trim() === '開票')";
if ($admin->js("!!($count)")) {
$admin->click($count);
$admin->waitLoad();
}
// Administrative login and voter login are independent sessions in the app.
$admin->go('/site/password?voteID='.$vote);
$admin->js('document.querySelector("#passwd").value = "testN90"');
$admin->click("[...document.querySelectorAll('button[type=submit]')].find(e => e.innerText.includes('登入'))");
$admin->waitLoad();
foreach (['zh-TW' => 'Zh', 'en-US' => 'En'] as $language => $locale) {
foreach (['ballot-work/result', 'result/index', 'vote/result', 'vote/vote-detail', 'ballot/edit'] as $page) {
$extra = $page === 'ballot/edit' ? '&party=0&ballotID=1' : '';
if ($page === 'vote/vote-detail') {
$extra .= '&questionID='.$questionId;
}
$status = $admin->go('/'.$page.'?voteID='.$vote.$extra.'&hl='.$language);
$r = $admin->js('(() => {
const a = [...document.querySelectorAll("td")].find(e => e.textContent.includes("P16'.$locale.'"));
const b = [...document.querySelectorAll("td")].find(e => e.textContent.includes("P16Text'.$locale.'"));
const h = [...document.querySelectorAll("th")].find(e => e.textContent.includes("P16Label'.$locale.'"));
const range = document.createRange(); let lineBreak = false;
if (a && a.querySelector("br") && a.querySelector("b")) {
range.selectNodeContents(a.querySelector("b"));
const bottom = range.getBoundingClientRect().top;
const text = [...a.childNodes].find(n => n.nodeType === Node.TEXT_NODE && n.textContent.includes("P16"));
if (text) { range.selectNodeContents(text); lineBreak = bottom > range.getBoundingClientRect().top; }
else { lineBreak = a.innerText.includes("\\n"); }
}
return {a: !!a && !!a.querySelector("br") && a.querySelector("b").textContent === "R&D",
lineBreak, header: !!h && !!h.querySelector("br") && h.querySelector("b").textContent === "R&D",
plain: !!b && !b.querySelector("br") && b.textContent.trim() === "P16Text'.$locale.'<br>R&D",
safe: !window.p16attack && ![...document.querySelectorAll("td [onclick]")].some(e => e.getAttribute("onclick").includes("p16attack"))};
})()');
$admin->check("$page $language: safe markup and plain text", $status === 200 && !in_array(false, $r, true), json_encode($r));
$admin->shot('result-fields-'.str_replace('/', '-', $page).'-'.$language);
if (in_array($page, ['ballot-work/result', 'result/index'])) {
foreach (['csv' => 'exportTEXT', 'xls' => 'exportEXCEL', 'html' => 'exportHTML'] as $type => $method) {
$export = $admin->js('(() => {
const link = document.querySelector("a.export-'.$type.'");
const plugin = link && jQuery(link).data("gridexport");
if (!plugin) throw new Error("Export plugin missing: '.$type.'");
let out; const original = plugin.download;
try { plugin.download = (type, content) => { out = content; }; plugin.'.$method.'("'.$type.'"); }
finally { plugin.download = original; }
return out;
})()');
$admin->check("$page $language: $type export content", is_string($export) && str_contains($export, 'P16'.$locale) && !str_contains($export, 'p16attack') && !str_contains($export, 'R&amp;amp;D') && ($type !== 'csv' || str_contains($export, 'R&D')));
$file('result-fields-'.str_replace('/', '-', $page).'-'.$language.'.'.$type, $export ?? '');
}
}
}
}
$admin->go('/ballot/print?voteID='.$vote.'&questionID='.$questionId.'&hl=zh-TW');
$admin->check('Printed ballot keeps both languages and safe breaks', $admin->js('document.body.innerText.includes("P16Zh") && document.body.innerText.includes("P16En") && !document.querySelector("b[onclick*=p16attack]") && !!document.querySelector("td br")'));
$admin->shot('result-fields-print');
} finally {
// Restore values before removing the temporary fields from the configuration.
if ($candidateOriginal !== null) {
$saveFields($manualUrl, $candidateOriginal);
}
$saveFields($resultUrl, $originalResults);
foreach ($originalConfigs as $url => $originalConfig) {
$saveFields($url, $originalConfig);
}
}
Loading