T9.8 - D7: archiving stops reading as deletion - for project admins
Archiving already froze a project (the server refuses every write); what did not exist was the way back in. Now: - GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE: a project admin (or super/app admin) on THAT project sees it; everyone else receives an empty list from the same request - archived projects appear nowhere for them, counts and pickers included (the default listing already excluded them for everyone; asking is what got gated). Admin-on-Job-A does not surface archived Job B. - The launcher gains a visibly separate, labelled "Archived projects" section (dashed border, read-only stated in words), rendered only when the server returns rows. Opening one makes it active; the launcher's reconcile learned that an active project whose stored summary says archived:true was opened ON PURPOSE and keeps it, while a project archived out from under someone still drops with the existing explanation. - The creator shows ARCHIVED - READ-ONLY where the project is named (both ctx-bar branches, from the SERVER's answer - the page's project comes from the URL, so a stale local summary is not trusted) and refuses saves with a reason before the round trip. The courtesy; the server's refusal is the rule, verified by calling the endpoints directly (wp upsert AND the material-list write both refuse with "archived" even for an admin). - No unarchive button, no second mechanism, and it fits at 390px. Verification (each probe run alone): NEW tests/archived_check.py 15/15. Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20, frame_check 38/38. Items: D7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -304,6 +304,7 @@ python tests/sample_check.py # S7 - one sample affordance, confirmed+fen
|
|||||||
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
||||||
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 13 checks
|
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 13 checks
|
||||||
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
|
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
|
||||||
|
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||||
|
|||||||
@@ -427,6 +427,19 @@
|
|||||||
══════════════════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════════════════ -->
|
||||||
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
|
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
|
||||||
|
|
||||||
|
<!-- D7 / T9.8: the way back into an archived project - PROJECT ADMINS ONLY
|
||||||
|
(the server filters; everyone else gets an empty list and this section
|
||||||
|
never renders). Visually its own thing, so nobody opens one thinking
|
||||||
|
it is live: the server refuses every write regardless. -->
|
||||||
|
<section class="section" id="archived-projects" hidden
|
||||||
|
style="border:1px dashed var(--cds-border-subtle-01); background:var(--cds-layer-01); opacity:.92">
|
||||||
|
<h2>Archived projects</h2>
|
||||||
|
<p style="font-size:13px; color:var(--cds-text-secondary)">Read-only. Visible to project
|
||||||
|
admins only. Opening one lets you read everything; nothing on it can be changed while
|
||||||
|
it stays archived.</p>
|
||||||
|
<div id="archived-projects-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- (a) no projects at all -->
|
<!-- (a) no projects at all -->
|
||||||
<section class="section first-run" id="first-run" hidden>
|
<section class="section first-run" id="first-run" hidden>
|
||||||
<h2>No projects yet</h2>
|
<h2>No projects yet</h2>
|
||||||
@@ -585,7 +598,31 @@
|
|||||||
const esc = ProjectData.esc;
|
const esc = ProjectData.esc;
|
||||||
let _projects = [];
|
let _projects = [];
|
||||||
|
|
||||||
|
// D7: render the archived list for whoever the server says may see one.
|
||||||
|
function renderArchivedProjects(){
|
||||||
|
ProjectData.listArchivedProjects().then(rows => {
|
||||||
|
const sec = $('archived-projects');
|
||||||
|
const list = $('archived-projects-list');
|
||||||
|
if(!sec || !list) return;
|
||||||
|
if(!rows.length){ sec.hidden = true; return; }
|
||||||
|
sec.hidden = false;
|
||||||
|
list.innerHTML = rows.map(p =>
|
||||||
|
`<button type="button" class="card-button" style="display:block; width:100%; text-align:left; margin-bottom:8px"
|
||||||
|
data-open-archived="${esc(p.id)}">
|
||||||
|
${esc(p.name || p.id)} ${p.number ? '· ' + esc(p.number) : ''}
|
||||||
|
<span style="font-size:11px; color:var(--cds-text-secondary)"> — archived, read-only</span>
|
||||||
|
</button>`).join('');
|
||||||
|
list.querySelectorAll('[data-open-archived]').forEach(b => {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const p = rows.find(x => x.id === b.dataset.openArchived);
|
||||||
|
if(p){ ProjectData.setActive(p); location.reload(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initProjects(){
|
function initProjects(){
|
||||||
|
renderArchivedProjects();
|
||||||
ProjectData.list().then(list => {
|
ProjectData.list().then(list => {
|
||||||
_projects = list || [];
|
_projects = list || [];
|
||||||
// A deep link names the project explicitly, and every other page in the
|
// A deep link names the project explicitly, and every other page in the
|
||||||
@@ -606,7 +643,12 @@
|
|||||||
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
||||||
WPUrl.replace({ project: active.id });
|
WPUrl.replace({ project: active.id });
|
||||||
}
|
}
|
||||||
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
// D7: a project OPENED FROM THE ARCHIVED LIST is active on purpose - its
|
||||||
|
// stored summary says archived:true, and only someone the server let see
|
||||||
|
// that list could have stored it. A project archived out from under
|
||||||
|
// someone still drops and gets explained, exactly as before.
|
||||||
|
const dropped = (active && !_projects.some(p => p.id === active.id)
|
||||||
|
&& !active.archived) ? active : null;
|
||||||
if(dropped) ProjectData.setActive(null);
|
if(dropped) ProjectData.setActive(null);
|
||||||
_listLoaded = true;
|
_listLoaded = true;
|
||||||
renderProjectEntry();
|
renderProjectEntry();
|
||||||
|
|||||||
@@ -52,6 +52,15 @@
|
|||||||
.catch(function () { return readLocal(); });
|
.catch(function () { return readLocal(); });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// D7 / T9.8: the way back in, for project admins. The server filters the
|
||||||
|
// answer by per-project role; everyone else simply receives [].
|
||||||
|
listArchivedProjects: function () {
|
||||||
|
return fetch(API + '/projects?archived=only', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (rows) { return (rows || []).filter(function (p) { return p.archived; }); })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
},
|
||||||
|
|
||||||
get: function (id) {
|
get: function (id) {
|
||||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
|||||||
@@ -542,15 +542,20 @@ function editQuality(id){
|
|||||||
}
|
}
|
||||||
function renderCtxBar(){
|
function renderCtxBar(){
|
||||||
const bar=document.getElementById('ctx-bar');
|
const bar=document.getElementById('ctx-bar');
|
||||||
|
const archMark = window._projArchived
|
||||||
|
? ` <span class="ctx-sample" style="background:var(--red);color:var(--cds-text-on-color)">ARCHIVED — READ-ONLY</span>` : '';
|
||||||
if(!SOP){
|
if(!SOP){
|
||||||
bar.innerHTML = activeProjectId
|
bar.innerHTML = activeProjectId
|
||||||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.${archMark}</div>`
|
||||||
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.</div>`;
|
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.${archMark}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const p=SOP.project||{}, g=SOP.governance||{};
|
const p=SOP.project||{}, g=SOP.governance||{};
|
||||||
|
// D7: an archived project is read-only. The chip is the courtesy; the server's
|
||||||
|
// write refusal is the rule, and savePackage() says so before the round trip.
|
||||||
|
const archived = archMark;
|
||||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}${archived}</div>
|
||||||
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
||||||
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
||||||
}
|
}
|
||||||
@@ -1764,6 +1769,10 @@ function collectPackage(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function savePackage(view){
|
async function savePackage(view){
|
||||||
|
if(window._projArchived){
|
||||||
|
toast('This project is archived — read-only. Nothing can be saved to it.', 'alert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(!wpValidateForm()) return;
|
if(!wpValidateForm()) return;
|
||||||
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
||||||
// The status can also be set programmatically (the per-discipline roll-up), so
|
// The status can also be set programmatically (the per-discipline roll-up), so
|
||||||
@@ -3833,6 +3842,15 @@ function bootData(){
|
|||||||
loadLocations(); // CR-004: the option lists come from the server
|
loadLocations(); // CR-004: the option lists come from the server
|
||||||
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||||||
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
|
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
|
||||||
|
// D7: the read-only courtesy needs the SERVER's answer, not a stale local
|
||||||
|
// summary - the page's project comes from the URL, which may not be the one
|
||||||
|
// last stored. The chip and the save guard both read this flag.
|
||||||
|
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){
|
||||||
|
ProjectData.get(activeProjectId).then(p=>{
|
||||||
|
window._projArchived = !!(p && p.archived);
|
||||||
|
if(window._projArchived) renderCtxBar();
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
initWpNavDrawer();
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
|
|||||||
@@ -1294,6 +1294,15 @@ def list_projects(
|
|||||||
elif archived != "all":
|
elif archived != "all":
|
||||||
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
||||||
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
|
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
|
||||||
|
# D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone
|
||||||
|
# below that sees them nowhere, counts and pickers included. The default
|
||||||
|
# listing already excludes them; asking for them is what gets gated, and it
|
||||||
|
# is gated per project, so admin-on-Job-A does not surface archived Job B.
|
||||||
|
if archived != "exclude":
|
||||||
|
rows = [p for p in rows
|
||||||
|
if p.archived_at is None
|
||||||
|
or effective_role(db, user, p.id) in (
|
||||||
|
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)]
|
||||||
return [p.summary() for p in rows]
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
181
tests/archived_check.py
Normal file
181
tests/archived_check.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Can a project admin get back into an archived project — and only they? — D7, T9.8.
|
||||||
|
|
||||||
|
Archiving read as deletion because there was no way back in. Now: a separate,
|
||||||
|
labelled, read-only list on the launcher for project admins; the server filters
|
||||||
|
the answer by per-project role, refuses every write regardless of what the
|
||||||
|
browser sends, and shows archived projects to nobody else anywhere - counts and
|
||||||
|
pickers included.
|
||||||
|
|
||||||
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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 # noqa: E402
|
||||||
|
from sections_check import set_sop # noqa: E402
|
||||||
|
from stepper_check import dismiss_dialogs # noqa: E402
|
||||||
|
from qa_gate_check import api # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def ascii_(v, n=280):
|
||||||
|
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def settle(seconds=0.5):
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_projB(db_path):
|
||||||
|
from server.db import SessionLocal
|
||||||
|
from server import models
|
||||||
|
with SessionLocal() as db:
|
||||||
|
proj = db.get(models.Project, "projB")
|
||||||
|
proj.archived_at = models.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
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-arch-")
|
||||||
|
db_path = os.path.join(tmpdir, "check.db")
|
||||||
|
server = None
|
||||||
|
browser = None
|
||||||
|
try:
|
||||||
|
tok = seed(db_path)
|
||||||
|
set_sop(db_path, {})
|
||||||
|
archive_projB(db_path)
|
||||||
|
port = cdp.free_port()
|
||||||
|
base = "http://127.0.0.1:%d" % port
|
||||||
|
server = start_server(port, db_path)
|
||||||
|
root, bob, pat = tok["root"], tok["bob"], tok["pat"]
|
||||||
|
|
||||||
|
# ── 1. who sees what ──────────────────────────────────────────────────
|
||||||
|
print("\n1. visibility, by role")
|
||||||
|
_, rows = api(base, "/api/projects", root)
|
||||||
|
chk("the default list hides archived projects from EVERYONE, admin included",
|
||||||
|
all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows]))
|
||||||
|
_, rows = api(base, "/api/projects?archived=only", root)
|
||||||
|
chk("an admin asking for the archived list gets it",
|
||||||
|
[p["id"] for p in rows] == ["projB"], ascii_(rows))
|
||||||
|
_, rows = api(base, "/api/projects?archived=only", bob)
|
||||||
|
chk("a plain project user ON that project gets an empty list - no leak",
|
||||||
|
rows == [], ascii_(rows))
|
||||||
|
_, rows = api(base, "/api/projects?archived=all", bob)
|
||||||
|
chk("...and cannot smuggle it through archived=all either",
|
||||||
|
all(p["id"] != "projB" for p in rows), ascii_(rows))
|
||||||
|
_, rows = api(base, "/api/projects?archived=only", pat)
|
||||||
|
chk("a user with no access to it sees nothing, same as before",
|
||||||
|
rows == [], ascii_(rows))
|
||||||
|
|
||||||
|
# ── 2. the server refuses writes regardless of the browser ───────────
|
||||||
|
print("\n2. frozen means frozen")
|
||||||
|
code, out = api(base, "/api/wps", root, "POST", {
|
||||||
|
"id": "wpArch1", "project_id": "projB", "number": "AR-1",
|
||||||
|
"subject": "write into the archive", "status": "Draft",
|
||||||
|
"data": {"constraints": []}})
|
||||||
|
chk("a direct write to an archived project is refused, even for an admin",
|
||||||
|
code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out)))
|
||||||
|
code, _ = api(base, "/api/projects/projB/materials", root, "POST",
|
||||||
|
{"description": "Sample sneak", "unit": "EA"})
|
||||||
|
chk("...and so is every other write route (material list)", code in (403, 409), code)
|
||||||
|
code, wps = api(base, "/api/wps?project_id=projB", root)
|
||||||
|
chk("reading it still works - archived is readable, not gone",
|
||||||
|
code == 200, code)
|
||||||
|
|
||||||
|
# ── 3. the launcher, both roles, at 390px ─────────────────────────────
|
||||||
|
print("\n3. the launcher")
|
||||||
|
browser = cdp.Browser(exe)
|
||||||
|
page = browser.page()
|
||||||
|
page.clear_cookies()
|
||||||
|
page.set_cookie("wp_session", root)
|
||||||
|
page.viewport(390, 844, mobile=True)
|
||||||
|
page.goto(base + "/index.html")
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
settle(2.5)
|
||||||
|
sec = json.loads(page.eval("""JSON.stringify((() => {
|
||||||
|
const s = document.getElementById('archived-projects');
|
||||||
|
return {hidden: !s || s.hidden,
|
||||||
|
text: s ? s.textContent : '',
|
||||||
|
buttons: s ? s.querySelectorAll('button').length : 0};
|
||||||
|
})())"""))
|
||||||
|
chk("a project admin sees the archived list, separate and labelled",
|
||||||
|
not sec["hidden"] and "Archived projects" in sec["text"]
|
||||||
|
and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec))
|
||||||
|
chk("...and it fits at 390px", page.eval(
|
||||||
|
"document.getElementById('archived-projects').scrollWidth <= 392"))
|
||||||
|
|
||||||
|
page.eval("document.querySelector('[data-open-archived]').click()")
|
||||||
|
settle(2.0)
|
||||||
|
chk("opening one makes it the active project",
|
||||||
|
page.eval("(ProjectData.getActive()||{}).id") == "projB")
|
||||||
|
|
||||||
|
# the creator's read-only courtesy on top of the server's rule
|
||||||
|
page.goto(base + "/wp-creation-index.html?project=projB")
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
settle(2.5)
|
||||||
|
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
|
||||||
|
chk("the creator says ARCHIVED where the project is named",
|
||||||
|
"ARCHIVED" in page.eval(
|
||||||
|
"(document.getElementById('ctx-bar')||{textContent:''}).textContent"))
|
||||||
|
page.eval("document.getElementById('wp_subject').value='x'")
|
||||||
|
page.eval("document.getElementById('wp_type').value='Conduit Install'")
|
||||||
|
n0 = page.eval("savedPackages.length")
|
||||||
|
page.eval("void savePackage(false)")
|
||||||
|
settle(0.8)
|
||||||
|
chk("saving is refused with a reason, before the round trip",
|
||||||
|
page.eval("savedPackages.length") == n0
|
||||||
|
and "archived" in page.eval(
|
||||||
|
"(document.getElementById('toast')||{textContent:''}).textContent").lower())
|
||||||
|
|
||||||
|
# a NON-admin's launcher shows no archived section at all
|
||||||
|
page.clear_cookies()
|
||||||
|
page.set_cookie("wp_session", bob)
|
||||||
|
page.goto(base + "/index.html")
|
||||||
|
dismiss_dialogs(page)
|
||||||
|
settle(2.5)
|
||||||
|
chk("a non-admin's launcher never shows the section",
|
||||||
|
page.eval("(() => { const s=document.getElementById('archived-projects');"
|
||||||
|
" return !s || s.hidden; })()"))
|
||||||
|
|
||||||
|
# projB has no SOP, and GET /api/sops/latest answering 404 for it is the
|
||||||
|
# correct answer, not an error - the seed fixture documents exactly this
|
||||||
|
# false alarm.
|
||||||
|
js_errors = [e for e in page.js_errors()
|
||||||
|
if "beforeunload" not in e and "sops/latest" not in e]
|
||||||
|
chk("no JavaScript errors anywhere in this run", not js_errors,
|
||||||
|
ascii_(js_errors[:2]))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if browser is not None:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if server is not None:
|
||||||
|
try:
|
||||||
|
server.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\n" + "-" * 54)
|
||||||
|
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
return 1 if _FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user