Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,49 @@ env:
GOPRIVATE: github.com/GoCodeAlone/*

jobs:
forms:
name: Shared forms browser QA
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify exact tested source
env:
EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA"
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install isolated browser tooling
run: |
npm install --prefix "$RUNNER_TEMP/admin-ui" --no-audit --no-fund --save-exact playwright@1.61.1
"$RUNNER_TEMP/admin-ui/node_modules/.bin/playwright" install --with-deps chromium
- name: Exercise actual configuration and login forms
env:
CMS_PLAYWRIGHT_MODULE: ${{ runner.temp }}/admin-ui/package.json
CMS_UI_ARTIFACTS: ${{ runner.temp }}/admin-form-results
run: node test/form-usability-browser.cjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: admin-form-browser-results
path: ${{ runner.temp }}/admin-form-results
retention-days: 7
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify exact tested source
env:
EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA"

- uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -63,6 +101,12 @@ jobs:
goarch: arm64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify exact tested source
env:
EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA"

- uses: actions/setup-go@v5
with:
Expand Down
37 changes: 37 additions & 0 deletions docs/form-usability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Shared configuration and login form usability

The configuration grid's260px minimum and unbounded label/select/error intrinsic
widths caused viewport overflow. Real browser baseline at320px measured1480px
document width with a long label and4196px after an unbroken server error.
Tracks now shrink to available width, controls can shrink, and help/results wrap.
Normal and error states both measure320px after the repair. Desktop1440px,
tablet768px and375px also fit. Login required/email validation remains native.

Controls have visible borders and focus,44px text/action targets, and16px mobile
input text. Descriptions are associated with each control; configuration results
and sign-in errors are announced. Session/bearer behavior, endpoint resolution,
validation snapshots, secret clearing and disabled-state restoration are unchanged.

Run with an existing Playwright installation and browser:

```sh
CMS_PLAYWRIGHT_MODULE=/path/to/playwright/package.json \
CMS_BROWSER_CHANNEL=msedge CMS_UI_ARTIFACTS=/tmp/admin-forms \
node test/form-usability-browser.cjs
node --test test/*.test.cjs
```

The browser harness serves the actual shell with synthetic loopback configuration
APIs. Session mode is injected only into its fixture HTML; no deployed settings,
credentials, mail or hosted owner session are accessed. It checks1440/768/375/320px
geometry, long selected options/labels/help/errors, keyboard/native validation,
programmatic descriptions, synthetic validate/apply, disabled state, secret
clearing and no bearer emission in session mode. `--measure-only` can record
baseline screenshots and measurements without requiring repaired geometry.

CI runs the browser harness and existing checks against the exact PR head and
retains browser artifacts for7days. Package release, host dependency adoption,
review deployment and actual hosted full-app acceptance are subsequent steps;
this source patch performs none of them. Separate contributed tools such as
identity, authorization and onboarding have their own form implementations and
are not represented as covered by this generic shell fixture.
37 changes: 29 additions & 8 deletions internal/ui_dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
--ink: #18202f;
--muted: #647086;
--line: #d9dee8;
--control-border: #768697;
--accent: #0f766e;
--accent-ink: #ffffff;
--warn: #9a3412;
}
* { box-sizing: border-box; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible {
outline: 3px solid #2563b8;
outline-offset: 3px;
}
body {
margin: 0;
min-height: 100vh;
Expand Down Expand Up @@ -181,6 +186,7 @@
gap: 16px;
}
.config-group {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
Expand All @@ -197,12 +203,14 @@
}
.config-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
gap: 12px;
}
.config-control {
display: grid;
min-width: 0;
gap: 6px;
overflow-wrap: anywhere;
}
.config-control label {
color: var(--ink);
Expand All @@ -216,8 +224,9 @@
.config-control input,
.config-control select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
min-width: 0;
min-height: 44px;
border: 1px solid var(--control-border);
border-radius: 6px;
padding: 8px 10px;
color: var(--ink);
Expand All @@ -230,11 +239,14 @@
justify-self: start;
}
.config-result {
min-width: 0;
max-width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #f8fafc;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--muted);
}
Expand All @@ -254,8 +266,9 @@
}
.field input {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
min-width: 0;
min-height: 44px;
border: 1px solid var(--control-border);
border-radius: 6px;
padding: 8px 10px;
color: var(--ink);
Expand All @@ -264,7 +277,7 @@
}
.primary-button,
.secondary-button {
min-height: 36px;
min-height: 44px;
border-radius: 6px;
padding: 8px 12px;
font: inherit;
Expand Down Expand Up @@ -292,6 +305,7 @@
aside { position: static; }
.grid { grid-template-columns: 1fr; }
header { align-items: flex-start; flex-direction: column; }
.field input, .config-control input, .config-control select { font-size: 16px; }
}
</style>
</head>
Expand Down Expand Up @@ -328,7 +342,7 @@ <h2>Sign In</h2>
</div>
<div class="button-row">
<button class="primary-button" type="submit">Sign in</button>
<span id="login-error" class="warn hidden"></span>
<span id="login-error" class="warn hidden" role="alert"></span>
</div>
</form>
</section>
Expand Down Expand Up @@ -699,7 +713,12 @@ <h2>Management pages</h2>
wrapper.appendChild(input);

const help = document.createElement('small');
help.appendChild(document.createTextNode(control.disabled_reason || control.help_text || control.description || ''));
const helpText = control.disabled_reason || control.help_text || control.description || '';
help.appendChild(document.createTextNode(helpText));
if (helpText) {
help.id = `${id}-help`;
input.setAttribute('aria-describedby', help.id);
}
wrapper.appendChild(help);
controls.appendChild(wrapper);
});
Expand Down Expand Up @@ -762,6 +781,8 @@ <h2>Management pages</h2>

const result = document.createElement('pre');
result.className = 'config-result';
result.setAttribute('role', 'status');
result.setAttribute('aria-live', 'polite');
result.textContent = 'Loaded from plugin configuration contract.';
form.appendChild(result);

Expand Down
81 changes: 81 additions & 0 deletions test/form-usability-browser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Isolated real admin shell with synthetic configuration APIs. Reuse Playwright.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { createRequire } = require('node:module');
async function main() {
if (!process.env.CMS_PLAYWRIGHT_MODULE) throw new Error('CMS_PLAYWRIGHT_MODULE must point to existing Playwright');
const { chromium } = createRequire(path.resolve(process.env.CMS_PLAYWRIGHT_MODULE))('playwright');
const html = fs.readFileSync(process.env.ADMIN_UI_HTML || path.resolve(__dirname, '../internal/ui_dist/index.html'), 'utf8');
const artifacts = process.env.CMS_UI_ARTIFACTS, measureOnly = process.argv.includes('--measure-only');
if (artifacts) fs.mkdirSync(artifacts, { recursive:true });
const calls = [], measurements = [], external = [], errors = [];
let failValidation = false;
const surface = { id:'fixture', title:'Fixture configuration', path:'/api/admin/fixture', render_mode:'config-form', metadata:{ describe_path:'/api/admin/fixture', validate_path:'/api/admin/fixture/validate', apply_path:'/api/admin/fixture/apply' } };
const description = { effective_config:{ name:'Fixture tenant', kind:'review', locked:'Disabled value', enabled:true }, groups:[{ key:'general', label:'Shared configuration', description:'Synthetic fixture, no real services or settings.', controls:[
{ key:'name', label:'Site label', help_text:'A visible editorial label.' },
{ key:'kind', label:'Destination', options:[{ value:'review', label:'A long review destination label '.repeat(4) }] },
{ key:'enabled', label:'Enabled', input_type:'toggle' },
{ key:'secret', label:'Provider secret', input_type:'secret', secret:true, help_text:'Leave blank to keep the configured secret.' },
{ key:'locked', label:'Disabled setting', enabled:false, disabled_reason:'Only an authorized owner can change this value.' },
{ key:'long', label:'LongLabel'.repeat(12), help_text:'LongHelp'.repeat(24) },
] }] };
const server = http.createServer(async (req,res) => {
const chunks=[]; for await (const c of req) chunks.push(c);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks)) : null;
calls.push({ method:req.method, path:req.url, body, authorization:!!req.headers.authorization });
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/admin/contributions') return res.end(JSON.stringify({ contributions:[surface] }));
if (req.url === surface.path) return res.end(JSON.stringify(description));
if (req.url.endsWith('/validate')) {
if (failValidation) { res.statusCode=422; return res.end(JSON.stringify({ error:'LongServerError'.repeat(30) })); }
return res.end(JSON.stringify({ valid:true }));
}
if (req.url.endsWith('/apply')) return res.end(JSON.stringify({ applied:true }));
if (req.url === '/api/admin/auth/login') { res.statusCode=401; return res.end('{}'); }
res.setHeader('Content-Type','text/html');
return res.end(req.url === '/login-fixture' ? html : html.replace('data-admin-shell-version="1"', 'data-admin-shell-version="1" data-auth-mode="session"'));
});
await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
const origin = 'http://127.0.0.1:' + server.address().port;
const browser = await chromium.launch({ headless:true, ...(process.env.CMS_BROWSER_CHANNEL ? { channel:process.env.CMS_BROWSER_CHANNEL } : {}) });
try {
const context = await browser.newContext({ viewport:{ width:1440,height:1100 }, serviceWorkers:'block' });
await context.route('**/*',route => { if(new URL(route.request().url()).origin !== origin) { external.push(route.request().url()); return route.abort(); } return route.continue(); });
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror',e=>errors.push(e.message));
async function open() { await page.goto(origin); await page.getByRole('button',{name:'Fixture configuration',exact:true}).click(); await page.locator('.config-form').waitFor(); }
async function capture(label) {
const measurement=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,controls:[...document.querySelectorAll('.panel.active input,.panel.active select,.panel.active button')].map(el=>{const r=el.getBoundingClientRect();return{id:el.id,width:r.width,right:r.right,x:r.x}}),groups:[...document.querySelectorAll('.config-controls')].map(el=>({width:el.clientWidth,scroll:el.scrollWidth}))}));
measurements.push({label,...measurement});
if(!measureOnly) { assert.ok(measurement.document <= measurement.viewport+1,label+' document fits');for(const c of measurement.controls)assert.ok(c.x>=0&&c.right<=measurement.viewport+1,label+' control fits '+c.id);for(const g of measurement.groups)assert.ok(g.scroll<=g.width+1,label+' group fits'); }
if(artifacts)await page.screenshot({path:path.join(artifacts,label+'.png'),fullPage:true});
}
for(const width of [1440,768,375,320]) {
await page.setViewportSize({width,height:1100});await open();await capture('config-'+width);
failValidation=true;await page.getByRole('button',{name:'Validate changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'LongServerError'}).waitFor();await capture('config-error-'+width);failValidation=false;
await page.goto(origin+'/login-fixture');await page.locator('#login-form').waitFor();await capture('login-'+width);
}
if(!measureOnly) {
await open();
for(const input of await page.locator('.config-control input,.config-control select').all()) {
const association=await input.evaluate(el=>({name:el.labels?.[0]?.textContent,help:el.getAttribute('aria-describedby'),description:document.getElementById(el.getAttribute('aria-describedby'))?.textContent}));
assert.ok(association.name);if(await input.getAttribute('title'))assert.ok(association.help&&association.description);
}
await page.getByLabel('Site label',{exact:true}).fill('Edited local label');await page.getByLabel('Site label',{exact:true}).press('Tab');
await page.getByLabel('Provider secret',{exact:true}).fill('fixture-only-secret');await page.getByLabel('Provider secret',{exact:true}).press('Tab');
await page.getByRole('button',{name:'Validate changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'"valid": true'}).waitFor();
await page.getByRole('button',{name:'Apply changes',exact:true}).click();await page.locator('.config-result').filter({hasText:'"applied": true'}).waitFor();
assert.equal(await page.getByLabel('Provider secret',{exact:true}).inputValue(),'');assert.equal(await page.getByLabel('Disabled setting',{exact:true}).isDisabled(),true);
const applied=calls.findLast(c=>c.path.endsWith('/apply'));assert.equal(applied.body.desired_config.name,'Edited local label');assert.equal(applied.body.desired_config.secret,'fixture-only-secret');
assert.equal(calls.some(c=>c.authorization),false,'session fixture never emits bearer credentials');
await page.goto(origin+'/login-fixture');await page.getByRole('button',{name:'Sign in',exact:true}).click();assert.equal(await page.locator('#admin-email').evaluate(el=>document.activeElement===el),true);
const n=calls.filter(c=>c.path.endsWith('/auth/login')).length;await page.getByLabel('Email',{exact:true}).fill('invalid');await page.getByLabel('Password',{exact:true}).fill('fixture-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();assert.equal(calls.filter(c=>c.path.endsWith('/auth/login')).length,n);
await page.getByLabel('Email',{exact:true}).fill('fixture@example.test');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('#login-error').filter({hasText:'Sign-in failed.'}).waitFor();
}
assert.deepEqual(external,[]);assert.deepEqual(errors,[]);
const result={pass:true,measureOnly,browser:browser.version(),boundary:'actual upstream shell; synthetic loopback APIs; session-mode injection confined to fixture; no live auth/config writes',measurements,checks:['configuration and login desktop/tablet/375px/320px geometry','long labels/options/help/server errors',...(measureOnly?[]:['accessible control descriptions','synthetic validate/apply and secret clearing','disabled state retained','session bearer isolation','native required/email validation and login error'])]};
if(artifacts)fs.writeFileSync(path.join(artifacts,'results.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result));
} finally {await browser.close();await new Promise(resolve=>server.close(resolve));}
}
main().catch(e=>{console.error(e);process.exitCode=1});
Loading