T5.3 - B4 surface: a pipeline strip, four server counts, four shareable links
Four cells on the launcher - Work packages, Release ready, On hold, Overdue -
every number from /api/wps/metrics, which T4.1 built. There is deliberately no
cache fallback anywhere in renderPipeline(): a remembered number sitting beside
three live ones is the failure B4 exists to remove, wearing a different hat.
The cells are the four the endpoint already computes and the dashboard already
filters on, so they map 1:1 onto its flags rather than inventing a fifth slice
nothing downstream understands. wp-creation-app.js now names them (DASH_FLAGS)
for the same reason: a cell linking to a filter the board does not recognise is
a dead link that still looks live.
"Links to a filtered view via a shareable URL" needed the filter to BE URL state,
which it was not - the dashboard kept its flag in a variable. So:
- dashToggleFlag pushes ?flag=<f>, and clears it on the way out of the board
- the creator applies ?flag= before its first render, not after (applying it
after paints the whole board and throws it away)
- Back and Forward move through filters like any other state
- work-package-suite-app.js forwards the flag ACROSS the iframe boundary, since
the creator's src carries only the project. B7/T7.1 dissolves that frame and
this hand-off goes with it; it is commented as such.
Zero is a real answer for one slice of a project that has work in it. Four zeros
on a project with none is not a reading, it is a strip that looks broken - that
case gets a sentence and a way into the creator instead. A failed request gets an
explicit error naming the failure, and no cells at all.
html/index.html the strip, its states, PIPE_CELLS
html/wp-creation-app.js flag as URL state; DASH_FLAGS; dashApplyFlag
html/work-package-suite-app.js forward the flag into the frame; clear on exit
tests/pipeline_check.py new - 43 checks
Done when
[x] every number comes from a server endpoint - proved by poisoning localStorage
with 99 fake packages and demanding the strip still read the server's 4
[x] each cell links to a filtered view via a shareable URL - and the probe
FOLLOWS the link and reads the filter inside the frame rather than trusting
that a correct-looking URL was built
[x] a project with zero work packages renders a sensible empty state
[x] the strip announces updates via aria-live (polite - a count is not an
interruption) and reports aria-busy while it is counting
What the probe caught
The link landed on "Complete the SOP Configuration first". Not the strip's
fault: browser_check.py's fixture stores a bare {governance: …} blob as the SOP
data, where production stores {sop, state}. restoreSavedSOP() needs `state` and
bails without it, so sopComplete stays false and the WP tab shows its gate.
pipeline_check seeds the production shape. The underlying wart is real and is
logged rather than fixed - see BL-018.
Verified one at a time
pipeline_check 43/43 new
launcher_check 58/58
stepper_check 70/70
url_state 23/23 the dashboard's new flag state did not disturb it
aggregates 16/16
browser_check 71/71
a11y 22/22
autosave 34/34
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
No colour literal added: still 0 across all page sheets and inline blocks. The
four cells are told apart by a label, a sentence and an accent - three channels,
so colour is not carrying it alone (C1).
Raised, not fixed
BL-018 The WP tab's gate is the last localStorage-derived status in the app.
T4.1 moved the launcher's card to the server; the wizard page still
decides gate-or-creator from wp_suite_sop_complete plus a state blob.
pullProject refreshes both on load so a connected user is fine, but the
two answers come from different places and the fallback is silent.
Includes a second, sharper edge: project-data.js:210 writes that flag
for ANY row returned, including one with no `state` to restore - so the
flag is written and never read consistently. T7.1 owns it.
Question for the PR, per CLAUDE.md: the strip counts Overdue against `data.due`,
which is free text today. CR-004/CR-018 restructure location but not dates. If
"overdue" is going to drive anything beyond a launcher tile, that field needs a
type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -225,8 +225,14 @@ Wave 5 added more, for the same reason:
|
|||||||
```bash
|
```bash
|
||||||
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
|
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
|
||||||
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
|
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
|
||||||
|
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
|
||||||
|
globals: `dashFilter` and `currentView` are declared with `let` in a classic script, so
|
||||||
|
they are not properties of `window` and a cross-frame read of either comes back
|
||||||
|
`undefined` — which is indistinguishable from a filter that never applied.
|
||||||
|
|
||||||
`launcher_check.py` is the only probe that runs itself in **two subprocesses**, and both
|
`launcher_check.py` is the only probe that runs itself in **two subprocesses**, and both
|
||||||
reasons are worth knowing before writing a third:
|
reasons are worth knowing before writing a third:
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,32 @@ deliberately deferred.
|
|||||||
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
|
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
|
||||||
target against the stripped figure.
|
target against the stripped figure.
|
||||||
|
|
||||||
|
### BL-018 — The Work Package tab's gate is the last localStorage-derived status
|
||||||
|
|
||||||
|
- **Found during:** T5.3
|
||||||
|
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
|
||||||
|
`renderWPTab()` shows the gate or the creator on it
|
||||||
|
- **What:** `T4.1` moved the *launcher's* SOP status onto `/api/projects/{id}/summary`, and
|
||||||
|
`aggregates_check.py` proves the card reports the server's answer over a lying cache. The
|
||||||
|
SOP **wizard page** still decides whether to show the creator or the "complete the SOP
|
||||||
|
Configuration first" gate from `localStorage.wp_suite_sop_complete` plus a `wp_suite_state`
|
||||||
|
blob. `ProjectData.pullProject()` refreshes both from the server on load, so a signed-in
|
||||||
|
user with a working connection is fine — but the two answers come from different places,
|
||||||
|
and the fallback is silent rather than an error state, which is the shape `B4` objects to.
|
||||||
|
- **Also:** `project-data.js:210` writes `wp_suite_sop_complete = '1'` whenever
|
||||||
|
`/api/sops/latest` returns any row, including one whose `data` carries no `state`. The
|
||||||
|
wizard then holds a browser that believes the SOP is complete and has nothing to restore,
|
||||||
|
so `restoreSavedSOP()` bails and `sopComplete` stays false — the flag is written and never
|
||||||
|
read consistently. Found because `browser_check.py`'s fixture seeds exactly that shape, and
|
||||||
|
a pipeline-strip link consequently landed on the gate. `pipeline_check.py` seeds the
|
||||||
|
production shape (`{sop, state}`) instead.
|
||||||
|
- **Why not now:** `T5.3` is the strip. Changing which source the WP gate trusts changes what
|
||||||
|
the SOP wizard does when offline, and `B7`/`T7.1` dissolves that iframe and rewrites this
|
||||||
|
hand-off wholesale.
|
||||||
|
- **Suggested wave or follow-up:** `T7.1`, or wave 9 with `C2` if the gate survives the
|
||||||
|
rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}`
|
||||||
|
shape so it stops being the only place this discrepancy is visible.
|
||||||
|
|
||||||
### BL-014 — Four controls fall back to the browser's default focus ring
|
### BL-014 — Four controls fall back to the browser's default focus ring
|
||||||
|
|
||||||
- **Found during:** T3.4
|
- **Found during:** T3.4
|
||||||
|
|||||||
108
html/index.html
108
html/index.html
@@ -317,6 +317,39 @@
|
|||||||
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
|
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
|
||||||
.field-error:empty { display: none; }
|
.field-error:empty { display: none; }
|
||||||
|
|
||||||
|
/* PIPELINE STRIP — B4 surface (T5.3)
|
||||||
|
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
||||||
|
no localStorage fallback anywhere in here: a per-browser number that looks
|
||||||
|
authoritative is the thing B4 removes, and a stale four beside a live four
|
||||||
|
is worse than an error. */
|
||||||
|
.pipeline { margin-bottom: 1.5rem; }
|
||||||
|
.pipeline-head { font-size: 13px; font-weight: 600; text-transform: none;
|
||||||
|
color: var(--cds-text-secondary); margin-bottom: 0.6rem; }
|
||||||
|
.pipeline-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1px;
|
||||||
|
background: var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle); }
|
||||||
|
.pipe-cell { display: flex; flex-direction: column; gap: 0.15rem; padding: 1rem 1.1rem;
|
||||||
|
background: var(--cds-layer); text-decoration: none; color: var(--cds-text-primary);
|
||||||
|
border-left: 3px solid transparent; transition: background 0.15s; }
|
||||||
|
.pipe-cell:hover { background: var(--cds-layer-hover); }
|
||||||
|
.pipe-num { font-size: 1.75rem; font-weight: 300; line-height: 1.1; }
|
||||||
|
.pipe-label { font-size: 13px; font-weight: 600; }
|
||||||
|
.pipe-sub { font-size: 12px; color: var(--cds-text-secondary); }
|
||||||
|
/* Each cell's accent says which slice it is, and the label says it in words —
|
||||||
|
the number alone is the same shape in all four (C1). */
|
||||||
|
.pipe-cell.is-total { border-left-color: var(--cds-interactive-01); }
|
||||||
|
.pipe-cell.is-ready { border-left-color: var(--cds-support-success); }
|
||||||
|
.pipe-cell.is-onhold { border-left-color: var(--wp-status-warning-text); }
|
||||||
|
.pipe-cell.is-overdue { border-left-color: var(--cds-support-error); }
|
||||||
|
/* Zero is a real answer for one slice of a project that has work in it. Four
|
||||||
|
zeros on a project with no work packages at all is not a reading, it is a
|
||||||
|
broken-looking strip, so that case gets a sentence instead. */
|
||||||
|
.pipe-empty, .pipe-error { grid-column: 1 / -1; background: var(--cds-layer);
|
||||||
|
padding: 1.1rem 1.2rem; font-size: 13px; color: var(--cds-text-secondary); }
|
||||||
|
.pipe-error { color: var(--wp-status-warning-text); }
|
||||||
|
.pipe-empty a { color: var(--cds-link-primary); }
|
||||||
|
.pipe-loading { grid-column: 1 / -1; background: var(--cds-layer); padding: 1.1rem 1.2rem;
|
||||||
|
font-size: 13px; color: var(--cds-text-secondary); font-style: italic; }
|
||||||
|
|
||||||
/* FIRST RUN
|
/* FIRST RUN
|
||||||
A brand-new account has no projects, so it has no tool cards either — this
|
A brand-new account has no projects, so it has no tool cards either — this
|
||||||
is the whole page for that person, and it has to say what to do next. */
|
is the whole page for that person, and it has to say what to do next. */
|
||||||
@@ -437,6 +470,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- PIPELINE STRIP — B4 surface / T5.3
|
||||||
|
Four counts from /api/wps/metrics, each one a link into the dashboard
|
||||||
|
filtered to that slice. The URL carries the filter (?view=dashboard&
|
||||||
|
flag=…), so a cell can be sent to someone rather than only clicked.
|
||||||
|
aria-live because it refreshes in place when the project changes. -->
|
||||||
|
<section class="pipeline" id="pipeline" hidden aria-labelledby="pipeline-head">
|
||||||
|
<h2 class="pipeline-head" id="pipeline-head">Work package pipeline</h2>
|
||||||
|
<div class="pipeline-strip" id="pipeline-strip" aria-live="polite" aria-busy="true"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- TOOL CARDS (shown once a project is active) -->
|
<!-- TOOL CARDS (shown once a project is active) -->
|
||||||
<div class="cards-grid" id="overview" style="display:none">
|
<div class="cards-grid" id="overview" style="display:none">
|
||||||
|
|
||||||
@@ -712,6 +755,8 @@
|
|||||||
|
|
||||||
if(!active){
|
if(!active){
|
||||||
cards.style.display = 'none';
|
cards.style.display = 'none';
|
||||||
|
const strip = document.getElementById('pipeline');
|
||||||
|
if(strip) strip.hidden = true;
|
||||||
heroTitle.textContent = 'Work Package Suite';
|
heroTitle.textContent = 'Work Package Suite';
|
||||||
// Two different situations, and telling someone to "select a project"
|
// Two different situations, and telling someone to "select a project"
|
||||||
// when there are none to select is the thing B3 is about.
|
// when there are none to select is the thing B3 is about.
|
||||||
@@ -737,6 +782,69 @@
|
|||||||
// reflectSOPStatus.
|
// reflectSOPStatus.
|
||||||
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
|
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
|
||||||
reflectSOPStatus(active);
|
reflectSOPStatus(active);
|
||||||
|
renderPipeline(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PIPELINE STRIP (B4 surface / T5.3) ────────────────────────────────────
|
||||||
|
// Four counts, all four from /api/wps/metrics. There is deliberately no cache
|
||||||
|
// fallback: B4's whole point is that a per-browser number which looks
|
||||||
|
// authoritative is worse than no number, and the same is true of a remembered
|
||||||
|
// one shown next to live ones.
|
||||||
|
//
|
||||||
|
// The flags match wp-creation-app.js's DASH_FLAGS. A cell linking to a filter
|
||||||
|
// the dashboard does not recognise is a dead link that still looks live.
|
||||||
|
const PIPE_CELLS = [
|
||||||
|
{flag: '', cls: 'is-total', label: 'Work packages', key: 'total',
|
||||||
|
sub: 'everything on this project'},
|
||||||
|
{flag: 'ready', cls: 'is-ready', label: 'Release ready', key: 'release_ready',
|
||||||
|
sub: 'no open constraints, nothing waiting'},
|
||||||
|
{flag: 'onhold', cls: 'is-onhold', label: 'On hold', key: 'on_hold',
|
||||||
|
sub: 'raised as an issue'},
|
||||||
|
{flag: 'overdue', cls: 'is-overdue', label: 'Overdue', key: 'overdue',
|
||||||
|
sub: 'past due and not closed'},
|
||||||
|
];
|
||||||
|
|
||||||
|
function dashHref(projectId, flag){
|
||||||
|
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
|
||||||
|
if(flag) q.set('flag', flag);
|
||||||
|
return 'work-package-suite.html?' + q.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipeline(active){
|
||||||
|
const host = document.getElementById('pipeline');
|
||||||
|
const strip = document.getElementById('pipeline-strip');
|
||||||
|
if(!host || !strip) return;
|
||||||
|
host.hidden = false;
|
||||||
|
strip.setAttribute('aria-busy', 'true');
|
||||||
|
strip.innerHTML = '<div class="pipe-loading">Counting work packages…</div>';
|
||||||
|
|
||||||
|
fetch('/api/wps/metrics?project_id=' + encodeURIComponent(active.id),
|
||||||
|
{ headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(m => {
|
||||||
|
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
|
||||||
|
strip.setAttribute('aria-busy', 'false');
|
||||||
|
if(!m.total){
|
||||||
|
// Four zeros on a project with no work packages is not a reading, it
|
||||||
|
// is a strip that looks broken. Say the true thing instead.
|
||||||
|
strip.innerHTML = `<div class="pipe-empty">No work packages on this project yet.
|
||||||
|
<a href="${esc(dashHref(active.id, ''))}">Open the creator</a> to write the first one.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strip.innerHTML = PIPE_CELLS.map(c => `
|
||||||
|
<a class="pipe-cell ${c.cls}" href="${esc(dashHref(active.id, c.flag))}">
|
||||||
|
<span class="pipe-num">${Number(m[c.key] || 0)}</span>
|
||||||
|
<span class="pipe-label">${esc(c.label)}</span>
|
||||||
|
<span class="pipe-sub">${esc(c.sub)}</span>
|
||||||
|
</a>`).join('');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if(ProjectData.getActiveId() !== active.id) return;
|
||||||
|
strip.setAttribute('aria-busy', 'false');
|
||||||
|
strip.innerHTML = `<div class="pipe-error">⚠ Could not load work package counts —
|
||||||
|
${esc((err && err.message) || 'offline')}. These numbers come from the server;
|
||||||
|
nothing stale is shown in their place.</div>`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }
|
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }
|
||||||
|
|||||||
@@ -519,8 +519,12 @@ function switchTool(tool, opts){
|
|||||||
// restoring because the user pressed Back - recording that as a new entry would
|
// restoring because the user pressed Back - recording that as a new entry would
|
||||||
// make Back appear to do nothing.
|
// make Back appear to do nothing.
|
||||||
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
|
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
|
||||||
|
// `flag` is the dashboard's filter (T5.3). It rides in the URL so a pipeline
|
||||||
|
// cell is shareable, which also means it has to be cleared on the way out —
|
||||||
|
// otherwise leaving the board and coming back re-applies a filter nobody asked
|
||||||
|
// for a second time.
|
||||||
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
|
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
|
||||||
: { tab: tool, view: '' });
|
: { tab: tool, view: '', flag: '' });
|
||||||
}
|
}
|
||||||
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||||
// creator) but opens it straight to the dashboard view.
|
// creator) but opens it straight to the dashboard view.
|
||||||
@@ -641,7 +645,16 @@ function renderWPTab(wantDash){
|
|||||||
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
|
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
|
||||||
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
|
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
|
||||||
}
|
}
|
||||||
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
|
if(dash && typeof cw.showDashboard === 'function'){
|
||||||
|
// T5.3: the launcher's pipeline cells link to a filtered board. The
|
||||||
|
// creator is an iframe child whose src carries only the project, so the
|
||||||
|
// filter has to be handed across rather than read from the child's own
|
||||||
|
// URL — and BEFORE showDashboard(), which renders. B7/T7.1 dissolves
|
||||||
|
// this frame and this hand-off goes with it.
|
||||||
|
const flag = new URLSearchParams(window.location.search).get('flag') || '';
|
||||||
|
if(typeof cw.dashApplyFlag === 'function') cw.dashApplyFlag(flag);
|
||||||
|
cw.showDashboard();
|
||||||
|
}
|
||||||
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
|
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
|
||||||
} catch(e){ /* cross-document timing; nothing useful to do */ }
|
} catch(e){ /* cross-document timing; nothing useful to do */ }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1828,10 +1828,39 @@ function dashRefreshAfterWrite(){
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashToggleFlag(f){
|
// The four flags the dashboard filters on. Named here because the launcher's
|
||||||
|
// pipeline strip (T5.3 / B4) links straight to them, and a cell that links to a
|
||||||
|
// filter this file does not recognise is a dead link that still looks live.
|
||||||
|
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
|
||||||
|
|
||||||
|
function dashToggleFlag(f, opts){
|
||||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||||
dashPage=0; renderDashboard();
|
dashPage=0;
|
||||||
|
// S3: which slice of the board you are looking at is state, so it belongs in
|
||||||
|
// the URL. Without this a pipeline cell could open the filter but nobody could
|
||||||
|
// send anyone the result — which is the half of T5.3 that is about sharing.
|
||||||
|
if(!(opts && opts.fromUrl)) urlSyncDashFlag();
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlSyncDashFlag(opts){
|
||||||
|
if(typeof WPUrl === 'undefined') return;
|
||||||
|
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
|
||||||
|
if(activeProjectId) patch.project = activeProjectId;
|
||||||
|
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a flag arriving from outside — a URL, or the embedding shell forwarding
|
||||||
|
// one across the iframe boundary. Never pushes history: the caller's own
|
||||||
|
// navigation is what put us here.
|
||||||
|
function dashApplyFlag(f){
|
||||||
|
const next = DASH_FLAGS.indexOf(f) >= 0 ? f : '';
|
||||||
|
if(dashFilter.flag === next) return next;
|
||||||
|
dashFilter.flag = next;
|
||||||
|
dashPage = 0;
|
||||||
|
if(currentView === 'Dashboard') renderDashboard();
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
|
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
|
||||||
|
|
||||||
@@ -1889,7 +1918,7 @@ function showDashboard(opts){
|
|||||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||||
currentView='Dashboard'; cmtUpdateCurStep();
|
currentView='Dashboard'; cmtUpdateCurStep();
|
||||||
if(typeof WPUrl !== 'undefined' && !fromUrl){
|
if(typeof WPUrl !== 'undefined' && !fromUrl){
|
||||||
const patch = { view: 'dashboard', wp: '' };
|
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
|
||||||
if(activeProjectId) patch.project = activeProjectId;
|
if(activeProjectId) patch.project = activeProjectId;
|
||||||
WPUrl.push(patch);
|
WPUrl.push(patch);
|
||||||
}
|
}
|
||||||
@@ -2333,7 +2362,13 @@ function bootData(){
|
|||||||
if(ix>=0) wpNavOpen(ix);
|
if(ix>=0) wpNavOpen(ix);
|
||||||
else toast('That work package is not on this project (it may have been deleted).');
|
else toast('That work package is not on this project (it may have been deleted).');
|
||||||
}
|
}
|
||||||
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
// A pipeline cell links straight to a filtered board, so the filter has to be
|
||||||
|
// applied BEFORE the first render — applying it after would paint the whole
|
||||||
|
// board and then throw it away, which on a big project is visible.
|
||||||
|
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){
|
||||||
|
dashApplyFlag(p.get('flag') || '');
|
||||||
|
showDashboard({fromUrl:true});
|
||||||
|
}
|
||||||
|
|
||||||
// Back / Forward. The URL is the state, so restoring is "read it and show that",
|
// Back / Forward. The URL is the state, so restoring is "read it and show that",
|
||||||
// not a bespoke undo stack. Guarded against re-pushing while we restore, or every
|
// not a bespoke undo stack. Guarded against re-pushing while we restore, or every
|
||||||
@@ -2342,6 +2377,10 @@ function bootData(){
|
|||||||
WPUrl.onChange(function(state, viaPop){
|
WPUrl.onChange(function(state, viaPop){
|
||||||
if(!viaPop) return;
|
if(!viaPop) return;
|
||||||
if(state.view === 'dashboard'){
|
if(state.view === 'dashboard'){
|
||||||
|
// The flag first: showDashboardFromUrl() renders, so setting it after
|
||||||
|
// would paint the unfiltered board and then replace it. dashApplyFlag
|
||||||
|
// re-renders itself only when the board is already open.
|
||||||
|
dashApplyFlag(state.flag || '');
|
||||||
if(currentView !== 'Dashboard') showDashboardFromUrl();
|
if(currentView !== 'Dashboard') showDashboardFromUrl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
371
tests/pipeline_check.py
Normal file
371
tests/pipeline_check.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The launcher's pipeline strip — B4 surface (T5.3).
|
||||||
|
|
||||||
|
T4.1 put the counts on the server; this is the surface that shows them, and the
|
||||||
|
failure it has to avoid is the one B4 names: a per-browser number that looks
|
||||||
|
authoritative. So the strip is checked the way aggregates_check.py checks the
|
||||||
|
dashboard — by POISONING localStorage with different numbers and demanding the
|
||||||
|
strip still report the server's.
|
||||||
|
|
||||||
|
1. every number comes from a server endpoint
|
||||||
|
2. each cell links to a filtered view via a shareable URL, and the filter
|
||||||
|
actually applies at the other end
|
||||||
|
3. a project with zero work packages renders a sentence, not four zeros
|
||||||
|
4. the strip announces updates via aria-live, because it refreshes in place
|
||||||
|
5. a failed request is an error, not four zeros and not a remembered number
|
||||||
|
|
||||||
|
Check 2 is the one with a seam in it: the dashboard is an iframe CHILD of the SOP
|
||||||
|
page, so the filter has to cross that boundary. The probe follows the link and
|
||||||
|
reads the filter inside the frame, rather than trusting that the URL was built.
|
||||||
|
|
||||||
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import cdp # noqa: E402
|
||||||
|
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
|
||||||
|
|
||||||
|
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
|
||||||
|
"#pipeline-strip .pipe-error')"
|
||||||
|
|
||||||
|
CELLS_JS = r"""
|
||||||
|
JSON.stringify([...document.querySelectorAll('#pipeline-strip .pipe-cell')].map(a => ({
|
||||||
|
href: a.getAttribute('href'),
|
||||||
|
num: (a.querySelector('.pipe-num') || {}).textContent,
|
||||||
|
label: ((a.querySelector('.pipe-label') || {}).textContent || '').trim(),
|
||||||
|
sub: ((a.querySelector('.pipe-sub') || {}).textContent || '').trim(),
|
||||||
|
accent: getComputedStyle(a).borderLeftColor,
|
||||||
|
tag: a.tagName,
|
||||||
|
})))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def settle(seconds=1.4):
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
# The shape ProjectData.pushSOP writes: { sop, state }. browser_check's fixture
|
||||||
|
# stores a bare {governance: …} blob, which is enough for the field view but NOT
|
||||||
|
# for the SOP wizard — restoreSavedSOP() needs `state` and bails without it, so
|
||||||
|
# the Work Package tab shows its "complete the SOP first" gate and a pipeline link
|
||||||
|
# lands on a locked door. Production data has both keys; the fixture should too,
|
||||||
|
# or the probe is testing a shape no real project has.
|
||||||
|
SOP_DATA = {
|
||||||
|
"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
|
||||||
|
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
|
||||||
|
"governance": {"disciplines": ["Mechanical", "Electrical"],
|
||||||
|
"woFormat": "WP##-[TYPE]"},
|
||||||
|
"woTypes": [{"name": "Conduit Install", "enabled": True}]},
|
||||||
|
"state": {"bimEnabled": False,
|
||||||
|
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
|
||||||
|
"division": "Internal", "site": "QA Lab"},
|
||||||
|
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||||
|
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||||
|
"teamMembers": [],
|
||||||
|
"signoffRoles": [{"role": "Superintendent", "name": ""},
|
||||||
|
{"role": "Foreman", "name": ""}],
|
||||||
|
"wpTypes": [{"name": "Conduit Install", "enabled": True, "notes": "",
|
||||||
|
"approval": "", "specSection": ""}],
|
||||||
|
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
|
||||||
|
"disciplines": ["Mechanical", "Electrical"],
|
||||||
|
"discMode": "choice", "instanceSuffix": "letter",
|
||||||
|
"sizeHoursMax": ""},
|
||||||
|
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
|
||||||
|
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
|
||||||
|
"trackingUrl": "", "commissioningUrl": ""},
|
||||||
|
"constraints": [], "sequence": [], "sources": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_two_projects(db_path):
|
||||||
|
"""browser_check's fixture, plus a third project with no work packages at all
|
||||||
|
— the empty state is a database state and cannot be faked in the browser."""
|
||||||
|
tok = seed(db_path)
|
||||||
|
from server.db import SessionLocal
|
||||||
|
from server import models
|
||||||
|
with SessionLocal() as db:
|
||||||
|
sop = db.get(models.Sop, "sopA")
|
||||||
|
if sop:
|
||||||
|
sop.data = SOP_DATA
|
||||||
|
db.add(models.Project(id="projEmpty", name="Empty Job", number="E-1",
|
||||||
|
client="Internal QA"))
|
||||||
|
db.flush()
|
||||||
|
db.add(models.ProjectMember(id="pmE", user_id="user_root", project_id="projEmpty",
|
||||||
|
role=""))
|
||||||
|
# One package that is genuinely overdue and one on hold, so three of the
|
||||||
|
# four cells are non-zero and a strip that hardcoded them would show.
|
||||||
|
db.add(models.WorkPackage(
|
||||||
|
id="wpA3", project_id="projA", sop_id="sopA", number="WP03-HOLD",
|
||||||
|
subject="held for access", status="Issue", type="Conduit Install",
|
||||||
|
data={"disciplines": ["Electrical"], "hours": "8",
|
||||||
|
"constraints": [{"name": "Access", "status": "open", "comment": "no permit"}]}))
|
||||||
|
db.add(models.WorkPackage(
|
||||||
|
id="wpA4", project_id="projA", sop_id="sopA", number="WP04-LATE",
|
||||||
|
subject="late wire pull", status="In Progress", type="Conduit Install",
|
||||||
|
data={"disciplines": ["Electrical"], "hours": "12", "due": "2020-01-01",
|
||||||
|
"constraints": []}))
|
||||||
|
db.commit()
|
||||||
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
def server_metrics(page, base, project_id):
|
||||||
|
return json.loads(page.eval(
|
||||||
|
"fetch('/api/wps/metrics?project_id=%s', {headers:{Accept:'application/json'}})"
|
||||||
|
".then(r => r.text())" % project_id))
|
||||||
|
|
||||||
|
|
||||||
|
def run(page, base, tok):
|
||||||
|
def visit(path, wait=READY):
|
||||||
|
page.clear_cookies()
|
||||||
|
page.set_cookie("wp_session", tok["root"])
|
||||||
|
page.goto(base + path, wait)
|
||||||
|
settle(1.6)
|
||||||
|
|
||||||
|
print("\n1. every number comes from the server")
|
||||||
|
visit("/index.html?project=projA")
|
||||||
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||||
|
truth = server_metrics(page, base, "projA")
|
||||||
|
cells = json.loads(page.eval(CELLS_JS))
|
||||||
|
chk("the strip renders four cells", len(cells) == 4, [c["label"] for c in cells])
|
||||||
|
want = [("Work packages", truth["total"]), ("Release ready", truth["release_ready"]),
|
||||||
|
("On hold", truth["on_hold"]), ("Overdue", truth["overdue"])]
|
||||||
|
for (label, n), cell in zip(want, cells):
|
||||||
|
chk("%-14s reads %s, the server's own number" % (label, n),
|
||||||
|
cell["label"] == label and cell["num"] == str(n),
|
||||||
|
"cell %r = %r, server = %r" % (cell["label"], cell["num"], n))
|
||||||
|
chk("...and the fixture is not all zeros, so this proves something",
|
||||||
|
truth["total"] > 0 and truth["on_hold"] > 0 and truth["overdue"] > 0, truth)
|
||||||
|
|
||||||
|
print("\n1b. a poisoned cache does not move a single number")
|
||||||
|
page.eval("""(() => {
|
||||||
|
// Every shape the launcher has ever cached work packages under, filled with
|
||||||
|
// numbers nothing like the server's.
|
||||||
|
const fake = Array.from({length: 99}, (_, i) => ({id: 'x' + i, status: 'Closed'}));
|
||||||
|
for (const k of ['wp_packages', 'wp_packages__projA', 'wp_suite_wps',
|
||||||
|
'wp_suite_wps__projA', 'wp_creation_packages']) {
|
||||||
|
localStorage.setItem(k, JSON.stringify(fake));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
page.goto(base + "/index.html?project=projA", READY)
|
||||||
|
settle(1.6)
|
||||||
|
poisoned = json.loads(page.eval(CELLS_JS))
|
||||||
|
chk("with 99 fake packages in localStorage the total is still the server's %d"
|
||||||
|
% truth["total"],
|
||||||
|
poisoned and poisoned[0]["num"] == str(truth["total"]),
|
||||||
|
poisoned[0]["num"] if poisoned else poisoned)
|
||||||
|
chk("...and no cell reads 99", all(c["num"] != "99" for c in poisoned),
|
||||||
|
[c["num"] for c in poisoned])
|
||||||
|
page.eval("localStorage.clear(); true")
|
||||||
|
|
||||||
|
print("\n4. the strip announces, and says when it is working")
|
||||||
|
chk("the strip is a live region", page.eval(
|
||||||
|
"document.getElementById('pipeline-strip').getAttribute('aria-live')") == "polite")
|
||||||
|
chk("...polite, not assertive — a count is not an interruption", page.eval(
|
||||||
|
"document.getElementById('pipeline-strip').getAttribute('aria-live')") != "assertive")
|
||||||
|
chk("...and reports aria-busy=false once the numbers land", page.eval(
|
||||||
|
"document.getElementById('pipeline-strip').getAttribute('aria-busy')") == "false")
|
||||||
|
chk("the section is named", page.eval(
|
||||||
|
"!!document.querySelector('#pipeline[aria-labelledby]')"))
|
||||||
|
|
||||||
|
print("\n the four cells are told apart by more than a colour (C1)")
|
||||||
|
chk("every cell carries a label in words",
|
||||||
|
all(c["label"] for c in poisoned), [c["label"] for c in poisoned])
|
||||||
|
chk("...and a sentence saying what it counts",
|
||||||
|
all(len(c["sub"]) > 8 for c in poisoned), [c["sub"] for c in poisoned])
|
||||||
|
chk("...four distinct labels", len({c["label"] for c in poisoned}) == 4)
|
||||||
|
chk("...and four distinct accents, as a second channel",
|
||||||
|
len({c["accent"] for c in poisoned}) == 4, [c["accent"] for c in poisoned])
|
||||||
|
|
||||||
|
print("\n2. each cell is a shareable link into a filtered board")
|
||||||
|
chk("every cell is a real <a>, not a div with a handler",
|
||||||
|
all(c["tag"] == "A" for c in poisoned), [c["tag"] for c in poisoned])
|
||||||
|
hrefs = [c["href"] for c in poisoned]
|
||||||
|
chk("...all pointing at the dashboard",
|
||||||
|
all("view=dashboard" in h for h in hrefs), hrefs)
|
||||||
|
chk("...all carrying the project, so the link works on a cold browser",
|
||||||
|
all("project=projA" in h for h in hrefs), hrefs)
|
||||||
|
chk("...three of them naming a filter, the total naming none",
|
||||||
|
[("flag=" in h) for h in hrefs] == [False, True, True, True], hrefs)
|
||||||
|
chk("...and the filters are the ones the dashboard knows",
|
||||||
|
sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h)
|
||||||
|
== ["onhold", "overdue", "ready"], hrefs)
|
||||||
|
|
||||||
|
print(" ...and the filter survives the trip into the creator's iframe")
|
||||||
|
onhold_href = [h for h in hrefs if "flag=onhold" in h][0]
|
||||||
|
page.goto(base + "/" + onhold_href.lstrip("/"))
|
||||||
|
for _ in range(40):
|
||||||
|
got = page.eval("""(() => {
|
||||||
|
const f = document.getElementById('wp-frame');
|
||||||
|
try { return f && f.contentWindow && f.contentWindow.wpCreatorReady ? 'ready' : ''; }
|
||||||
|
catch (e) { return 'x'; }
|
||||||
|
})()""")
|
||||||
|
if got == "ready":
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
settle(2.0)
|
||||||
|
# Read the FRAME'S DOM, not its globals. `dashFilter` and `currentView` are
|
||||||
|
# declared with let in a classic script, so they are not properties of window
|
||||||
|
# and a cross-frame read of them comes back undefined — which looks exactly
|
||||||
|
# like a filter that was never applied. The board itself is the evidence.
|
||||||
|
inner = json.loads(page.eval("""(() => {
|
||||||
|
try {
|
||||||
|
const d = document.getElementById('wp-frame').contentDocument;
|
||||||
|
const dv = d.getElementById('dashboard-view');
|
||||||
|
const active = d.querySelector('.dash-metric.dm-active .dm-label');
|
||||||
|
// The board only — NOT the gating panel next to it, which is server-derived
|
||||||
|
// and not filtered. Counting both would let a two-package gating list pass
|
||||||
|
// this check whatever the filter did.
|
||||||
|
const panel = [...d.querySelectorAll('.dash-panel')].find(p => {
|
||||||
|
const t = p.querySelector('.dash-panel-title');
|
||||||
|
return t && /^Work packages/.test(t.textContent.trim());
|
||||||
|
});
|
||||||
|
const rows = panel
|
||||||
|
? [...panel.querySelectorAll('tbody tr')]
|
||||||
|
.map(r => (r.cells[0] ? r.cells[0].textContent : '').trim()).filter(Boolean)
|
||||||
|
: null;
|
||||||
|
return JSON.stringify({
|
||||||
|
shown: !!dv && getComputedStyle(dv).display !== 'none',
|
||||||
|
active: active ? active.textContent.trim() : null,
|
||||||
|
rows: rows,
|
||||||
|
});
|
||||||
|
} catch (e) { return JSON.stringify({error: String(e)}); }
|
||||||
|
})()"""))
|
||||||
|
chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner)
|
||||||
|
chk("...with the on-hold tile already the active filter, inside the frame",
|
||||||
|
(inner.get("active") or "").lower() == "on hold", inner)
|
||||||
|
chk("...and the board listing only the held package, not all four",
|
||||||
|
inner.get("rows") == ["WP03-HOLD"], inner)
|
||||||
|
|
||||||
|
print("\n3. a project with no work packages says so, in a sentence")
|
||||||
|
visit("/index.html?project=projEmpty")
|
||||||
|
chk("no cells are rendered", page.eval(
|
||||||
|
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
|
||||||
|
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
|
||||||
|
empty_txt = page.eval("(document.querySelector('#pipeline-strip .pipe-empty')||{}).textContent||''")
|
||||||
|
chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt))
|
||||||
|
chk("...naming the situation rather than showing four zeros",
|
||||||
|
"no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90]))
|
||||||
|
chk("...and offering somewhere to go", page.eval(
|
||||||
|
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"work-package-suite\"]')"))
|
||||||
|
chk("no zero is rendered as a headline number anywhere in the strip",
|
||||||
|
page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
|
||||||
|
|
||||||
|
print("\n5. a failed request is an error, not a zero and not a memory")
|
||||||
|
visit("/index.html?project=projA")
|
||||||
|
before = json.loads(page.eval(CELLS_JS))
|
||||||
|
chk("the strip has real numbers to lose", len(before) == 4)
|
||||||
|
page.eval("""(() => {
|
||||||
|
const real = window.fetch;
|
||||||
|
window.fetch = function (u, o) {
|
||||||
|
if (String(u).indexOf('/api/wps/metrics') >= 0) return Promise.reject(new Error('probe offline'));
|
||||||
|
return real.call(this, u, o);
|
||||||
|
};
|
||||||
|
renderPipeline(ProjectData.getActive());
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
for _ in range(20):
|
||||||
|
if page.eval("!!document.querySelector('#pipeline-strip .pipe-error')"):
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
settle(0.6)
|
||||||
|
err = page.eval("(document.querySelector('#pipeline-strip .pipe-error')||{}).textContent||''")
|
||||||
|
chk("an explicit error is shown", bool(err.strip()), repr(err))
|
||||||
|
chk("...naming the failure", "probe offline" in err, repr(err.strip()[:90]))
|
||||||
|
chk("...with no cells left showing the numbers from before", page.eval(
|
||||||
|
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
|
||||||
|
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
|
||||||
|
chk("...and no zeros in their place", page.eval(
|
||||||
|
"document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
|
||||||
|
|
||||||
|
print("\n both widths")
|
||||||
|
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||||
|
page.viewport(w, 900, mobile=(w == 390))
|
||||||
|
visit("/index.html?project=projA")
|
||||||
|
chk("%s: four cells still render" % label, page.eval(
|
||||||
|
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 4)
|
||||||
|
chk("%s: the page does not scroll sideways" % label,
|
||||||
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
||||||
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
||||||
|
chk("%s: every cell is at least a 44px tap target" % label,
|
||||||
|
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
|
||||||
|
".every(a => a.getBoundingClientRect().height >= 44)"),
|
||||||
|
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
|
||||||
|
".map(a => Math.round(a.getBoundingClientRect().height))"))
|
||||||
|
page.viewport(1400, 1000)
|
||||||
|
|
||||||
|
print("\n no localStorage read sits behind any of these numbers")
|
||||||
|
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"html", "index.html"), encoding="utf-8").read()
|
||||||
|
start = src.find("function renderPipeline")
|
||||||
|
body = src[start:src.find("\n }", start)] if start >= 0 else ""
|
||||||
|
chk("renderPipeline touches no localStorage", start >= 0 and "localStorage" not in body,
|
||||||
|
body[:200])
|
||||||
|
chk("...and reads its numbers from /api/wps/metrics", "/api/wps/metrics" in body)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
exe = cdp.find_browser()
|
||||||
|
if not exe:
|
||||||
|
print("no headless-capable browser found; set WP_BROWSER.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-pipeline-")
|
||||||
|
db_path = os.path.join(tmpdir, "check.db")
|
||||||
|
server = None
|
||||||
|
try:
|
||||||
|
tok = seed_two_projects(db_path)
|
||||||
|
port = cdp.free_port()
|
||||||
|
base = "http://127.0.0.1:%d" % port
|
||||||
|
server = start_server(port, db_path)
|
||||||
|
if server is None:
|
||||||
|
print("the test server would not start.")
|
||||||
|
return 2
|
||||||
|
print("\nLauncher pipeline strip — B4 surface\nTarget: %s" % base)
|
||||||
|
|
||||||
|
browser = cdp.Browser(exe)
|
||||||
|
page = browser.page()
|
||||||
|
try:
|
||||||
|
run(page, base, tok)
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
if server:
|
||||||
|
server.kill()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from server.db import engine
|
||||||
|
engine.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
import shutil
|
||||||
|
for _ in range(10):
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
if not os.path.exists(tmpdir):
|
||||||
|
break
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
total = len(_PASS) + len(_FAIL)
|
||||||
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||||
|
if _FAIL:
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
return 1
|
||||||
|
print("\nResult: " + _c("ALL PASS — four server counts, four shareable links.", "32") + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user