haveapi 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +157 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +39 -12
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +399 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +912 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- metadata +52 -3
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const filename = path.resolve(__dirname, '../../../lib/haveapi/public/js/documentation.js');
|
|
7
|
+
const stylesheet = fs.readFileSync(path.resolve(__dirname, '../../../lib/haveapi/public/css/documentation.css'), 'utf8');
|
|
8
|
+
const { preferences, readPreferences, clientChoice, detailLevels, effectiveDetail, exampleDetail, copyText, exampleCopyControls,
|
|
9
|
+
disclosureMenus, legacyTarget, loginTarget, highlighterTarget, activeSection, navigationTargets, referenceNavigation, searchShortcut,
|
|
10
|
+
searchConfiguration, validateSearchIndex, searchMatcher, searchResultURL, renderSearchResult, searchIndexLoader } = require(filename);
|
|
11
|
+
const clients = [
|
|
12
|
+
{ id: '0', language: 'ruby', language_label: 'Ruby', variant: null },
|
|
13
|
+
{ id: 'python-sync', language: 'python', language_label: 'Python', variant: 'Sync' },
|
|
14
|
+
{ id: 'python-async', language: 'python', language_label: 'Python', variant: 'Async' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// The local DOM fixture has no layout engine. Pin the stylesheet's layout
|
|
18
|
+
// boundary here; native first-line and touch hit-testing stay browser checks.
|
|
19
|
+
const cssRule = selector => {
|
|
20
|
+
const begin = stylesheet.indexOf(selector + ' {');
|
|
21
|
+
assert(begin >= 0, 'missing stylesheet boundary ' + selector);
|
|
22
|
+
return stylesheet.slice(begin, stylesheet.indexOf('}', begin));
|
|
23
|
+
};
|
|
24
|
+
const blockLayout = cssRule('.docs-code-block');
|
|
25
|
+
const sourceLayout = cssRule('.docs-code-block > pre');
|
|
26
|
+
const buttonLayout = cssRule('[data-copy-example]');
|
|
27
|
+
const statusLayout = cssRule('.docs-copy-status');
|
|
28
|
+
assert(!/grid|padding|margin|line-height/.test(blockLayout), 'copy wrapper creates no toolbar row or vertical code inset');
|
|
29
|
+
assert(!/padding(?:-top|-block)|margin|line-height|grid/.test(sourceLayout), 'original pre first-line vertical layout is inherited unchanged');
|
|
30
|
+
assert(/padding-inline-end:/.test(sourceLayout), 'code can pan into a clear inline-end gutter');
|
|
31
|
+
assert(/position: absolute/.test(buttonLayout) && /width: 2\.75rem/.test(buttonLayout) && /height: 2\.75rem/.test(buttonLayout), 'small visual icon keeps an out-of-flow44px hit area');
|
|
32
|
+
assert(/position: absolute/.test(statusLayout) && /inset-block-start: 100%/.test(statusLayout), 'live status stays outside readable code and does not move it');
|
|
33
|
+
|
|
34
|
+
assert.equal(preferences(null).theme, 'auto');
|
|
35
|
+
assert.equal(preferences(null).detail, 'simple');
|
|
36
|
+
assert.equal(preferences({ theme: 'invalid', detail: 'advanced' }).detail, 'simple');
|
|
37
|
+
assert.equal(preferences({ theme: 'invalid' }).theme, 'auto');
|
|
38
|
+
assert.equal(readPreferences({ getItem() { throw new Error('storage denied'); } }).theme, 'auto');
|
|
39
|
+
assert.equal(readPreferences({ getItem: () => '{broken json' }).detail, 'simple');
|
|
40
|
+
const saved = preferences({ theme: 'dark', language: 'python', clients: { python: 'python-async' }, detail: 'thorough' });
|
|
41
|
+
assert.equal(clientChoice(saved, clients).id, 'python-async');
|
|
42
|
+
assert.equal(clientChoice(saved, clients.slice(0, 2)).id, 'python-sync');
|
|
43
|
+
assert.equal(saved.clients.python, 'python-async', 'temporary unavailability must retain the saved client');
|
|
44
|
+
assert.equal(clientChoice(saved, clients.slice(0, 1)).id, '0');
|
|
45
|
+
assert.equal(saved.language, 'python', 'another API must not overwrite the preferred language');
|
|
46
|
+
assert.equal(clientChoice(saved, clients).id, 'python-async');
|
|
47
|
+
assert.equal(clientChoice(saved, []), null);
|
|
48
|
+
const malicious = preferences(JSON.parse('{"clients":{"__proto__":"safe-data","python":42}}'));
|
|
49
|
+
assert.equal(Object.getPrototypeOf(malicious.clients), null);
|
|
50
|
+
assert.equal(malicious.clients.__proto__, 'safe-data');
|
|
51
|
+
assert.equal(malicious.clients.python, undefined);
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(detailLevels({}), ['simple', 'thorough'], 'old/custom metadata preserves both levels');
|
|
54
|
+
assert.deepEqual(detailLevels({ details: ['simple', 'thorough'] }), ['simple', 'thorough']);
|
|
55
|
+
for (const details of [null, [], 'simple', ['thorough'], ['simple', 'simple'], ['thorough', 'simple'], ['simple', 'other']]) {
|
|
56
|
+
assert.deepEqual(detailLevels({ details }), ['simple'], 'malformed capabilities retain the renderer with safe simple UI');
|
|
57
|
+
}
|
|
58
|
+
assert.equal(effectiveDetail(saved, { details: ['simple'] }), 'simple');
|
|
59
|
+
assert.equal(saved.detail, 'thorough');
|
|
60
|
+
assert.equal(effectiveDetail(saved, {}), 'thorough', 'returning to a capable client restores the saved level');
|
|
61
|
+
assert.equal(exampleDetail('thorough', { id: 'custom' }, null), 'thorough', 'missing custom group metadata preserves the legacy choice');
|
|
62
|
+
assert.equal(exampleDetail('thorough', { id: 'client' }, '{"client":["simple"]}'), 'simple');
|
|
63
|
+
assert.equal(exampleDetail('thorough', { id: 'client' }, '{"client":["simple","thorough"]}'), 'thorough');
|
|
64
|
+
assert.equal(exampleDetail('thorough', { id: 'missing' }, '{"client":["simple"]}'), null, 'absent renderers remain unavailable');
|
|
65
|
+
for (const declaration of ['broken', 'null', '[]', '{"client":null}', '{"client":["thorough"]}']) {
|
|
66
|
+
assert.equal(exampleDetail('thorough', { id: 'client' }, declaration), 'simple', 'malformed group metadata fails closed without dropping the renderer');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function element(tag) {
|
|
70
|
+
return {
|
|
71
|
+
tagName: tag.toUpperCase(), children: [], attributes: {}, listeners: {}, style: {}, text: '', hidden: false, disabled: false,
|
|
72
|
+
get textContent() { return this.text + this.children.map(child => child.textContent).join(''); },
|
|
73
|
+
set textContent(value) { this.text = value; this.children = []; },
|
|
74
|
+
setAttribute(name, value) { this.attributes[name] = value; },
|
|
75
|
+
getAttribute(name) { return this.attributes[name] || null; },
|
|
76
|
+
hasAttribute(name) { return Object.hasOwn(this.attributes, name); },
|
|
77
|
+
removeAttribute(name) { delete this.attributes[name]; },
|
|
78
|
+
contains(target) { return target === this || this.children.some(child => child.contains ? child.contains(target) : child === target); },
|
|
79
|
+
appendChild(child) { if (child.parentElement) child.remove(); this.children.push(child); child.parentElement = this; return child; },
|
|
80
|
+
replaceChildren(...children) { this.children = []; this.text = ''; children.forEach(child => this.appendChild(child)); },
|
|
81
|
+
insertBefore(child, before) { this.children.splice(this.children.indexOf(before), 0, child); child.parentElement = this; },
|
|
82
|
+
remove() { this.parentElement.children.splice(this.parentElement.children.indexOf(this), 1); this.parentElement = null; },
|
|
83
|
+
addEventListener(name, listener) { this.listeners[name] = listener; },
|
|
84
|
+
classList: { add() {} }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const elements = { createElement: element, createElementNS: (namespace, tag) => element(tag) };
|
|
88
|
+
|
|
89
|
+
const menuEvents = {};
|
|
90
|
+
let focusedSummary;
|
|
91
|
+
function menuFixture(left, width) {
|
|
92
|
+
const summary = Object.assign(element('summary'), { focus(options) { focusedSummary = this; assert.equal(options.preventScroll, true); } });
|
|
93
|
+
summary.setAttribute('aria-label', 'Documentation settings');
|
|
94
|
+
summary.setAttribute('title', 'Documentation settings');
|
|
95
|
+
const content = { style: {}, getBoundingClientRect: () => ({ width }) };
|
|
96
|
+
return { open: false, listeners: {}, summary, content,
|
|
97
|
+
querySelector(selector) {
|
|
98
|
+
assert(['summary', '[data-documentation-menu-content]'].includes(selector), 'menu consumes only the single settings contract');
|
|
99
|
+
return selector === 'summary' ? summary : content;
|
|
100
|
+
},
|
|
101
|
+
getBoundingClientRect: () => ({ left }),
|
|
102
|
+
addEventListener(name, listener) { this.listeners[name] = listener; },
|
|
103
|
+
contains(target) { return target === summary || target === content; } };
|
|
104
|
+
}
|
|
105
|
+
const settingsMenu = menuFixture(244, 232);
|
|
106
|
+
const closeMenus = disclosureMenus({ querySelectorAll(selector) {
|
|
107
|
+
assert.equal(selector, '[data-documentation-menu]'); return [settingsMenu];
|
|
108
|
+
} }, { getBoundingClientRect: () => ({ left: 0, right: 296 }) }, { addEventListener() {} },
|
|
109
|
+
{ addEventListener(name, listener) { menuEvents[name] = listener; } });
|
|
110
|
+
settingsMenu.open = true; settingsMenu.listeners.toggle();
|
|
111
|
+
assert.equal(settingsMenu.content.style.left, '-188px', 'settings panel remains within the unchanged sidebar bounds');
|
|
112
|
+
let prevented = false; let stopped = false;
|
|
113
|
+
settingsMenu.listeners.keydown({ key: 'Escape', preventDefault() { prevented = true; }, stopPropagation() { stopped = true; } });
|
|
114
|
+
assert.equal(settingsMenu.open, false);
|
|
115
|
+
assert.equal(focusedSummary, settingsMenu.summary);
|
|
116
|
+
assert(prevented && stopped, 'settings Escape preserves the outer mobile drawer and restores summary focus');
|
|
117
|
+
settingsMenu.open = true; menuEvents.pointerdown({ target: settingsMenu.content });
|
|
118
|
+
assert.equal(settingsMenu.open, true, 'touching theme/account content leaves native controls usable');
|
|
119
|
+
menuEvents.pointerdown({ target: {} }); assert.equal(settingsMenu.open, false);
|
|
120
|
+
settingsMenu.open = true; closeMenus();
|
|
121
|
+
assert.equal(settingsMenu.open, false, 'closing mobile navigation also closes the settings disclosure');
|
|
122
|
+
for (const sidebarWidth of [272, 296, 390, 768]) {
|
|
123
|
+
const menu = menuFixture(sidebarWidth - 52, 232);
|
|
124
|
+
disclosureMenus({ querySelectorAll: () => [menu] }, { getBoundingClientRect: () => ({ left: 0, right: sidebarWidth }) },
|
|
125
|
+
{ addEventListener() {} }, { addEventListener() {} });
|
|
126
|
+
menu.open = true; menu.listeners.toggle();
|
|
127
|
+
const left = sidebarWidth - 52 + parseFloat(menu.content.style.left);
|
|
128
|
+
assert(left >= 8 && left + 232 <= sidebarWidth - 8, 'title-adjacent panel stays inside sidebar at ' + sidebarWidth);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const anchors = {
|
|
132
|
+
'root-vps': '/api/v7.0/?resource=vps',
|
|
133
|
+
'root-vps-show': '/api/v7.0/?resource=vps',
|
|
134
|
+
'example-vps-show-': '/api/v7.0/?resource=vps',
|
|
135
|
+
'example-vps.snapshot-show-': '/api/v7.0/?resource=vps.snapshot',
|
|
136
|
+
'auth-basic': '/api/v7.0/?page=authentication'
|
|
137
|
+
};
|
|
138
|
+
assert.equal(legacyTarget('#root-vps-show', anchors), '/api/v7.0/?resource=vps');
|
|
139
|
+
assert.equal(legacyTarget('#example-vps.snapshot-show-0-python-async', anchors), '/api/v7.0/?resource=vps.snapshot');
|
|
140
|
+
assert.equal(legacyTarget('#root-vps-unknown', anchors), null, 'resource prefixes must not guess unknown action links');
|
|
141
|
+
assert.equal(legacyTarget('#auth-basic', anchors), '/api/v7.0/?page=authentication');
|
|
142
|
+
assert.equal(legacyTarget('#%E0%A4%A', anchors), null);
|
|
143
|
+
assert.equal(legacyTarget('#not-visible', anchors), null);
|
|
144
|
+
|
|
145
|
+
const current = 'https://docs.example/api/v7.0/?resource=vps#root-vps-show';
|
|
146
|
+
const login = '/api/_documentation/oauth/login?version=7.0&page=resource&resource=vps';
|
|
147
|
+
const withFragment = new URL(loginTarget(login, current, '#root-vps-show'));
|
|
148
|
+
assert.equal(withFragment.origin, 'https://docs.example');
|
|
149
|
+
assert.equal(withFragment.searchParams.get('version'), '7.0');
|
|
150
|
+
assert.equal(withFragment.searchParams.get('page'), 'resource');
|
|
151
|
+
assert.equal(withFragment.searchParams.get('resource'), 'vps');
|
|
152
|
+
assert.equal(withFragment.searchParams.get('fragment'), 'root-vps-show');
|
|
153
|
+
assert.equal(loginTarget('https://foreign.example/login', current, '#root-vps-show'), null);
|
|
154
|
+
assert.equal(loginTarget('//foreign.example/login', current, '#root-vps-show'), null);
|
|
155
|
+
assert.equal(loginTarget('https://user:secret@docs.example/login', current, '#root-vps-show'), null);
|
|
156
|
+
assert.equal(loginTarget('javascript:alert(1)', current, '#root-vps-show'), null);
|
|
157
|
+
for (const fragment of ['', '#', '#%3Cunsafe%3E', '#root-vps/show', '#' + 'x'.repeat(257)]) {
|
|
158
|
+
assert.equal(new URL(loginTarget(login + '&fragment=stale', current, fragment)).searchParams.has('fragment'), false);
|
|
159
|
+
}
|
|
160
|
+
assert.equal(new URL(loginTarget(login, current, '#' + 'x'.repeat(256))).searchParams.get('fragment').length, 256);
|
|
161
|
+
|
|
162
|
+
assert.equal(highlighterTarget('https://docs.example/js/documentation.js', current), 'https://docs.example/js/highlighter.js');
|
|
163
|
+
assert.equal(highlighterTarget('https://docs.example/api/js/documentation.js', current), 'https://docs.example/api/js/highlighter.js');
|
|
164
|
+
assert.equal(highlighterTarget('https://docs.example/js/documentation.js', current, '/mounted/js/highlighter.js'), 'https://docs.example/mounted/js/highlighter.js');
|
|
165
|
+
assert.equal(highlighterTarget('https://foreign.example/js/documentation.js', current), null);
|
|
166
|
+
assert.equal(highlighterTarget(null, current), null);
|
|
167
|
+
|
|
168
|
+
const actions = [
|
|
169
|
+
{ id: 'clone', top: -500, margin: '80px' },
|
|
170
|
+
{ id: 'create', top: 160.296875, margin: '80px' },
|
|
171
|
+
{ id: 'index', top: 2400, margin: '80px' }
|
|
172
|
+
];
|
|
173
|
+
assert.equal(activeSection(actions, '80px').id, 'create', 'native anchor landing must select Create, not preceding Clone');
|
|
174
|
+
assert.equal(activeSection(actions.map(action => ({ ...action, top: action.top + 32 })), '80px').id, 'clone', 'scrolling above the same activation line returns to Clone');
|
|
175
|
+
assert.equal(activeSection(actions.map(action => ({ ...action, top: action.top - 32 })), '80px').id, 'create');
|
|
176
|
+
assert.equal(activeSection([{ id: 'clone', top: -500, margin: '16px' }, { id: 'create', top: 80, margin: '16px' }], '0px').id, 'clone', 'changed header offsets must replace the old fixed threshold');
|
|
177
|
+
assert.equal(activeSection([{ id: 'clone', top: -500, margin: '16px' }, { id: 'create', top: 16.5, margin: '16px' }], '0px').id, 'create', 'changed header offsets need no click state');
|
|
178
|
+
assert.equal(activeSection([], 'auto'), null);
|
|
179
|
+
|
|
180
|
+
const headings = {
|
|
181
|
+
intro: { id: 'intro', top: -200, margin: '24px' },
|
|
182
|
+
'protocol syntax': { id: 'protocol syntax', top: 24.2, margin: '24px' }
|
|
183
|
+
};
|
|
184
|
+
const tocTargets = navigationTargets(['#intro', '#protocol%20syntax', '#intro', '#missing', 'other-page#intro', '#%E0%A4%A'], id => headings[id]);
|
|
185
|
+
assert.deepEqual(tocTargets, [headings.intro, headings['protocol syntax']], 'TOCs resolve exact existing headings, without wrappers or duplicate/missing targets');
|
|
186
|
+
assert.equal(activeSection(tocTargets, '0px').id, 'protocol syntax');
|
|
187
|
+
|
|
188
|
+
const navDocument = { documentElement: {}, activeElement: null, listeners: {},
|
|
189
|
+
addEventListener(name, listener) { this.listeners[name] = listener; }, getElementById(id) { return navHeadings[id]; } };
|
|
190
|
+
let navScroll = 0;
|
|
191
|
+
const navHeadings = Object.fromEntries(['root-first', 'reference-6f7061717565', 'reference-756e69636f6465', 'auth-token', 'reference-61757468'].map((id, order) => [id, {
|
|
192
|
+
id, order, getBoundingClientRect: () => ({ top: [-100, 80, 160.5, 1000, 1080][order] - navScroll }),
|
|
193
|
+
compareDocumentPosition(other) { return other.order < this.order ? 2 : 4; }
|
|
194
|
+
}]));
|
|
195
|
+
function navLink(id) { const link = element('a'); link.setAttribute('href', '#' + id); return link; }
|
|
196
|
+
const navPrimaries = [navLink('root-first'), navLink('auth-token')];
|
|
197
|
+
const navGroups = [
|
|
198
|
+
['root-first', ['reference-6f7061717565', 'reference-756e69636f6465']],
|
|
199
|
+
['auth-token', ['reference-61757468']]
|
|
200
|
+
].map(([id, ids]) => {
|
|
201
|
+
const owner = element('li'); owner.setAttribute('data-doc-nav-owner', id);
|
|
202
|
+
const list = element('ul'); list.setAttribute('data-doc-subnav', id); owner.appendChild(list);
|
|
203
|
+
const links = ids.map(navLink); links.forEach(link => list.appendChild(link));
|
|
204
|
+
owner.querySelectorAll = selector => { assert.equal(selector, '[data-doc-subnav]'); return [list]; };
|
|
205
|
+
list.querySelectorAll = selector => { assert.equal(selector, '[data-doc-subsection]'); return links; };
|
|
206
|
+
return { owner, list, links };
|
|
207
|
+
});
|
|
208
|
+
const nav = referenceNavigation({ querySelectorAll(selector) {
|
|
209
|
+
return selector === '[data-action-nav] [data-doc-primary]' ? navPrimaries
|
|
210
|
+
: selector === '[data-doc-nav-owner]' ? navGroups.map(group => group.owner) : [];
|
|
211
|
+
} }, { getComputedStyle: () => ({ scrollMarginTop: '80px', scrollPaddingTop: '80px' }), requestAnimationFrame: callback => callback() }, navDocument);
|
|
212
|
+
nav.update();
|
|
213
|
+
assert.equal(navPrimaries[0].getAttribute('aria-current'), 'location');
|
|
214
|
+
assert.equal(navGroups[0].links[1].getAttribute('aria-current'), 'location', 'subsection uses computed padding/margin rounding within its current owner');
|
|
215
|
+
assert.equal(navGroups[0].list.hidden, false); assert.equal(navGroups[1].list.hidden, true);
|
|
216
|
+
navDocument.activeElement = navGroups[0].links[1]; navScroll = 1000; nav.update();
|
|
217
|
+
assert.equal(navPrimaries[0].getAttribute('aria-current'), null);
|
|
218
|
+
assert.equal(navPrimaries[1].getAttribute('aria-current'), 'location', 'provider transitions depend on primary geometry, never the old subsection/hash');
|
|
219
|
+
assert.equal(navGroups[1].links[0].getAttribute('aria-current'), 'location');
|
|
220
|
+
assert.equal(navGroups[0].list.hidden, false, 'scrolling never hides the submenu holding keyboard focus');
|
|
221
|
+
navDocument.activeElement = navPrimaries[1]; navDocument.listeners.focusout();
|
|
222
|
+
assert.equal(navGroups[0].list.hidden, true, 'old submenu closes after focus leaves');
|
|
223
|
+
navScroll = 0; nav.update(); assert.equal(navGroups[0].list.hidden, false);
|
|
224
|
+
assert.equal(navPrimaries[0].getAttribute('aria-current'), 'location', 'scrolling upwards replaces the current provider without a click lock');
|
|
225
|
+
|
|
226
|
+
const shortcutDocument = { addEventListener(name, listener) { this[name] = listener; } };
|
|
227
|
+
const shortcutInput = { value: 'keep this query', getClientRects: () => [{}] };
|
|
228
|
+
let shortcutAvailable = shortcutInput; let shortcutOpened = 0; let shortcutFocused = 0;
|
|
229
|
+
searchShortcut(shortcutDocument, () => shortcutAvailable, () => { shortcutOpened += 1; }, input => { assert.equal(input, shortcutInput); shortcutFocused += 1; });
|
|
230
|
+
function slash(overrides) {
|
|
231
|
+
const event = { key: '/', target: { tagName: 'DIV' }, prevented: false, preventDefault() { this.prevented = true; }, ...overrides };
|
|
232
|
+
shortcutDocument.keydown(event); return event;
|
|
233
|
+
}
|
|
234
|
+
assert(slash().prevented); assert.equal(shortcutOpened, 1); assert.equal(shortcutFocused, 1); assert.equal(shortcutInput.value, 'keep this query');
|
|
235
|
+
for (const overrides of [{ key: '?' }, { defaultPrevented: true }, { isComposing: true }, { ctrlKey: true }, { metaKey: true }, { altKey: true },
|
|
236
|
+
...['INPUT', 'TEXTAREA', 'SELECT'].map(tagName => ({ target: { tagName } })),
|
|
237
|
+
{ target: { tagName: 'SPAN', parentElement: { isContentEditable: true } } },
|
|
238
|
+
{ composedPath: () => [{ tagName: 'SPAN' }, { isContentEditable: true }] }]) {
|
|
239
|
+
assert.equal(slash(overrides).prevented, false, 'editing/composition/modifier events keep their native behavior');
|
|
240
|
+
}
|
|
241
|
+
assert.equal(shortcutFocused, 1);
|
|
242
|
+
shortcutAvailable = null; assert.equal(slash().prevented, false); assert.equal(shortcutOpened, 1, 'no usable search form means no drawer action');
|
|
243
|
+
|
|
244
|
+
// Owner-supplied Rack HTML can additionally exercise these controls with the
|
|
245
|
+
// actual emitted target IDs and page/group metadata, without a browser server.
|
|
246
|
+
if (process.env.DOCUMENTATION_REFERENCE_FIXTURES) {
|
|
247
|
+
const rendered = JSON.parse(fs.readFileSync(process.env.DOCUMENTATION_REFERENCE_FIXTURES, 'utf8'));
|
|
248
|
+
for (const page of rendered.pages) {
|
|
249
|
+
const targets = new Map(page.ids.map((id, position) => [id, { id, position,
|
|
250
|
+
getBoundingClientRect() { return { top: this.position * 100 - scroll }; },
|
|
251
|
+
compareDocumentPosition(other) { return other.position < this.position ? 2 : 4; } }]));
|
|
252
|
+
let scroll = 0;
|
|
253
|
+
const primary = page.owners.map(owner => navLink(owner.primary.slice(1)));
|
|
254
|
+
const groups = page.owners.map(owner => {
|
|
255
|
+
const list = element('ul'); list.setAttribute('data-doc-subnav', owner.id);
|
|
256
|
+
const links = owner.sections.map(fragment => navLink(fragment.slice(1)));
|
|
257
|
+
links.forEach(link => { assert(targets.has(link.getAttribute('href').slice(1))); list.appendChild(link); });
|
|
258
|
+
list.querySelectorAll = () => links;
|
|
259
|
+
return { getAttribute: () => owner.id, querySelectorAll: () => [list], list, links };
|
|
260
|
+
});
|
|
261
|
+
const doc = { documentElement: {}, activeElement: null, listeners: {},
|
|
262
|
+
getElementById: id => targets.get(id), addEventListener(name, listener) { this.listeners[name] = listener; } };
|
|
263
|
+
const control = referenceNavigation({ querySelectorAll: selector => selector === '[data-action-nav] [data-doc-primary]' ? primary : groups },
|
|
264
|
+
{ getComputedStyle: () => ({ scrollPaddingTop: '0px', scrollMarginTop: '24px' }), requestAnimationFrame: callback => callback() }, doc);
|
|
265
|
+
for (let current = 0; current < page.owners.length; current += 1) {
|
|
266
|
+
const owner = page.owners[current];
|
|
267
|
+
for (const fragment of [owner.primary, ...owner.sections]) {
|
|
268
|
+
scroll = targets.get(fragment.slice(1)).position * 100 - 24.5;
|
|
269
|
+
control.update();
|
|
270
|
+
assert.equal(primary[current].getAttribute('aria-current'), 'location', page.name + ': emitted subsection never changes its owning action/provider');
|
|
271
|
+
assert.equal(groups[current].list.hidden, false);
|
|
272
|
+
groups.forEach((group, index) => { if (index !== current) assert.equal(group.list.hidden, true); });
|
|
273
|
+
if (fragment !== owner.primary) assert.equal(groups[current].links.find(link => link.getAttribute('href') === fragment).getAttribute('aria-current'), 'location');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const group of page.examples) {
|
|
277
|
+
for (const [id, levels] of Object.entries(group.details)) {
|
|
278
|
+
const client = page.clients.find(client => client.id === id);
|
|
279
|
+
assert(client, 'actual rendered group client exists in page metadata');
|
|
280
|
+
const selected = exampleDetail(effectiveDetail({ detail: 'thorough' }, client), client, JSON.stringify(group.details));
|
|
281
|
+
assert.equal(selected, levels.includes('thorough') && client.details.includes('thorough') ? 'thorough' : 'simple');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
console.log('Actual rendered ' + page.name + ' navigation/group consumer passed; HTML SHA256 ' + page.sha256);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function browserFixture(workerMode, blockedStorage, display) {
|
|
289
|
+
const attributes = {};
|
|
290
|
+
const messages = [];
|
|
291
|
+
const writes = [];
|
|
292
|
+
const documentEvents = {};
|
|
293
|
+
const windowEvents = {};
|
|
294
|
+
const themeButtons = ['auto', 'light', 'dark'].map(theme => ({
|
|
295
|
+
attributes: { 'data-theme-choice': theme }, listeners: {},
|
|
296
|
+
closest(selector) { assert.equal(selector, '[data-documentation-menu]'); return display && display.menu || null; },
|
|
297
|
+
getAttribute(name) { return this.attributes[name]; },
|
|
298
|
+
setAttribute(name, value) { this.attributes[name] = value; },
|
|
299
|
+
addEventListener(name, listener) { this.listeners[name] = listener; }
|
|
300
|
+
}));
|
|
301
|
+
let worker;
|
|
302
|
+
const code = (visible, hidden) => ({
|
|
303
|
+
textContent: '<unsafe>', innerHTML: '<unsafe>',
|
|
304
|
+
classList: { item: () => 'ruby', contains: () => false, add() {} },
|
|
305
|
+
getAttribute: () => null,
|
|
306
|
+
closest: selector => selector === '[hidden]' && hidden ? {} : null,
|
|
307
|
+
getBoundingClientRect: () => ({ width: 200, height: 50, top: visible ? 100 : 2000, bottom: visible ? 150 : 2050 })
|
|
308
|
+
});
|
|
309
|
+
const visibleCode = code(true, false);
|
|
310
|
+
const codes = [visibleCode, code(false, false), code(true, true)];
|
|
311
|
+
const root = {
|
|
312
|
+
querySelector: selector => display && selector === '[data-documentation-clients]' ? { textContent: JSON.stringify(display.metadata) }
|
|
313
|
+
: display && selector === '[data-docs-sidebar]' ? display.sidebar
|
|
314
|
+
: display && selector === '[data-nav-toggle]' ? display.toggle : display && selector === '.docs-main' ? display.main : null,
|
|
315
|
+
querySelectorAll: selector => display && display.matches[selector] ? display.matches[selector]
|
|
316
|
+
: selector === '[data-documentation-menu]' && display && display.menu ? [display.menu]
|
|
317
|
+
: selector === 'pre code' ? codes : selector === '[data-theme-choice]' ? themeButtons : [],
|
|
318
|
+
getAttribute: name => name === 'data-page' ? 'resource' : attributes[name],
|
|
319
|
+
setAttribute(name, value) { attributes[name] = value; }, removeAttribute(name) { delete attributes[name]; },
|
|
320
|
+
hasAttribute: name => Object.hasOwn(attributes, name), addEventListener() {}
|
|
321
|
+
};
|
|
322
|
+
const localStorage = { getItem: () => JSON.stringify(display ? display.saved : { theme: 'dark' }), setItem: (key, value) => writes.push({ key, value }) };
|
|
323
|
+
const themeMedia = { matches: false, addEventListener(name, listener) { this.listener = listener; } };
|
|
324
|
+
const window = {
|
|
325
|
+
matchMedia: query => query.includes('prefers-color-scheme') ? themeMedia : ({ matches: !!(display && display.mobile), addEventListener() {} }),
|
|
326
|
+
isSecureContext: !!(display && display.clipboard), navigator: display && display.clipboard ? { clipboard: display.clipboard } : {},
|
|
327
|
+
getComputedStyle: () => ({ scrollPaddingTop: '80px', scrollMarginTop: '80px' }),
|
|
328
|
+
addEventListener(name, listener) { (windowEvents[name] ||= []).push(listener); }, location: { hash: '', href: current }, innerHeight: 800,
|
|
329
|
+
requestAnimationFrame(callback) { callback(); },
|
|
330
|
+
scrollBy(x, y) { if (display) display.geometry.scroll += y; }
|
|
331
|
+
};
|
|
332
|
+
if (display && display.fetch) Object.assign(window, { fetch: display.fetch, AbortController, TextEncoder });
|
|
333
|
+
Object.defineProperty(window, 'localStorage', { get() { if (blockedStorage) throw new Error('denied'); return localStorage; } });
|
|
334
|
+
const document = { readyState: 'complete', body: { style: { overflow: 'auto' } }, currentScript: { src: 'https://docs.example/api/js/documentation.js', getAttribute: () => null }, documentElement: { setAttribute: (key, value) => { attributes[key] = value; } }, querySelector: () => root,
|
|
335
|
+
addEventListener(name, listener) { (documentEvents[name] ||= []).push(listener); },
|
|
336
|
+
createElement(tag) { return tag === 'template' && display && display.fetch ? { content: {} } : element(tag); }, createElementNS: elements.createElementNS };
|
|
337
|
+
const Worker = function (url) {
|
|
338
|
+
assert.equal(url, 'https://docs.example/api/js/highlighter.js');
|
|
339
|
+
if (workerMode === 'constructor-failure') throw new Error('worker denied');
|
|
340
|
+
worker = this;
|
|
341
|
+
this.postMessage = message => { if (workerMode === 'post-failure') throw new Error('worker stopped'); messages.push(message); };
|
|
342
|
+
this.terminate = () => { this.stopped = true; };
|
|
343
|
+
};
|
|
344
|
+
if (workerMode !== 'missing') window.Worker = Worker;
|
|
345
|
+
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { window, document, Worker, URL, console });
|
|
346
|
+
return { attributes, messages, worker, visibleCode, themeButtons, writes, themeMedia, document, documentEvents, windowEvents };
|
|
347
|
+
}
|
|
348
|
+
for (const mode of ['missing', 'constructor-failure', 'post-failure', 'working']) {
|
|
349
|
+
const fixture = browserFixture(mode, true);
|
|
350
|
+
assert.equal(fixture.attributes['data-docs-theme'], 'light', 'blocked storage uses automatic system theme');
|
|
351
|
+
if (mode === 'working') {
|
|
352
|
+
assert.equal(fixture.messages.length, 1, 'only visible, selected source is queued');
|
|
353
|
+
assert.equal(fixture.messages[0].code, '<unsafe>');
|
|
354
|
+
fixture.worker.onerror();
|
|
355
|
+
assert.equal(fixture.worker.stopped, true);
|
|
356
|
+
}
|
|
357
|
+
assert.equal(fixture.visibleCode.innerHTML, '<unsafe>', 'worker failures preserve escaped source');
|
|
358
|
+
}
|
|
359
|
+
assert.equal(browserFixture('missing', false).attributes['data-docs-theme'], 'dark', 'saved theme applies before page initialization');
|
|
360
|
+
const themed = browserFixture('missing', false);
|
|
361
|
+
assert.equal(themed.themeButtons[2].attributes['aria-pressed'], 'true');
|
|
362
|
+
themed.themeButtons[0].listeners.click();
|
|
363
|
+
assert.equal(themed.themeButtons[0].attributes['aria-pressed'], 'true');
|
|
364
|
+
assert.equal(themed.themeButtons[2].attributes['aria-pressed'], 'false');
|
|
365
|
+
assert.equal(themed.writes.at(-1).key, 'haveapi.docs.preferences.v1');
|
|
366
|
+
assert.equal(JSON.parse(themed.writes.at(-1).value).theme, 'auto');
|
|
367
|
+
themed.themeMedia.matches = true;
|
|
368
|
+
themed.themeMedia.listener();
|
|
369
|
+
assert.equal(themed.attributes['data-docs-theme'], 'dark', 'automatic theme follows OS changes');
|
|
370
|
+
themed.themeButtons[1].listeners.click();
|
|
371
|
+
themed.themeMedia.listener();
|
|
372
|
+
assert.equal(themed.attributes['data-docs-theme'], 'light', 'explicit choice survives OS changes');
|
|
373
|
+
const blockedTheme = browserFixture('missing', true);
|
|
374
|
+
blockedTheme.themeButtons[2].listeners.click();
|
|
375
|
+
assert.equal(blockedTheme.attributes['data-docs-theme'], 'dark', 'native theme buttons work when storage is blocked');
|
|
376
|
+
const menuTheme = browserFixture('missing', false, { matches: {}, saved: { theme: 'light' }, menu: settingsMenu });
|
|
377
|
+
settingsMenu.open = true;
|
|
378
|
+
menuTheme.themeButtons[2].listeners.click();
|
|
379
|
+
assert.equal(settingsMenu.open, false, 'selecting a theme closes the one settings disclosure');
|
|
380
|
+
assert.equal(focusedSummary, settingsMenu.summary);
|
|
381
|
+
assert.equal(settingsMenu.summary.getAttribute('aria-label'), 'Documentation settings', 'theme choice never renames the combined menu');
|
|
382
|
+
assert.equal(settingsMenu.summary.getAttribute('title'), 'Documentation settings');
|
|
383
|
+
assert.equal(menuTheme.themeButtons[2].attributes['aria-pressed'], 'true');
|
|
384
|
+
|
|
385
|
+
const shortcutToggle = element('button');
|
|
386
|
+
const shortcutMain = { inert: false };
|
|
387
|
+
let slashFocusCount = 0;
|
|
388
|
+
let slashRequests = 0;
|
|
389
|
+
const liveSearch = Object.assign(element('input'), { value: 'unchanged', readOnly: false,
|
|
390
|
+
closest: () => null, getClientRects: () => [{}], focus(options) { assert.equal(options.preventScroll, true); slashFocusCount += 1; this.listeners.focus(); } });
|
|
391
|
+
const shortcutSearchForm = { action: 'https://docs.example/api/v7.0/', method: 'get', addEventListener() {},
|
|
392
|
+
getAttribute(name) { return { 'data-search-index-url': this.action + '?page=resources&search_index=' + 'a'.repeat(64),
|
|
393
|
+
'data-search-view-key': 'a'.repeat(64), 'data-search-version': '7.0' }[name]; },
|
|
394
|
+
querySelector(selector) { return selector === '[data-search-query]' ? liveSearch : element('div'); } };
|
|
395
|
+
const shortcutPage = browserFixture('missing', false, { saved: {}, geometry: { scroll: 0 }, mobile: true,
|
|
396
|
+
fetch() { slashRequests += 1; return Promise.reject(new Error('unexpected shortcut fetch')); },
|
|
397
|
+
toggle: shortcutToggle, main: shortcutMain, matches: { '[data-documentation-search] [data-search-query]': [liveSearch], '[data-documentation-search]': [shortcutSearchForm] } });
|
|
398
|
+
const liveSlash = { key: '/', target: element('div'), preventDefault() { this.prevented = true; } };
|
|
399
|
+
shortcutPage.documentEvents.keydown.forEach(listener => listener(liveSlash));
|
|
400
|
+
assert(liveSlash.prevented); assert.equal(slashFocusCount, 1); assert.equal(liveSearch.value, 'unchanged');
|
|
401
|
+
assert.equal(shortcutToggle.getAttribute('aria-expanded'), 'true'); assert.equal(shortcutMain.inert, true);
|
|
402
|
+
assert.equal(shortcutPage.document.body.style.overflow, 'hidden', 'slash uses the normal drawer open path');
|
|
403
|
+
shortcutToggle.listeners.click();
|
|
404
|
+
assert.equal(shortcutMain.inert, false); assert.equal(shortcutPage.document.body.style.overflow, 'auto');
|
|
405
|
+
assert.equal(shortcutPage.writes.length, 0, 'the shortcut never persists a query/preference');
|
|
406
|
+
|
|
407
|
+
const detailWrapper = { hidden: false };
|
|
408
|
+
const variantWrapper = { hidden: false };
|
|
409
|
+
const detailSelect = Object.assign(element('select'), { value: '', closest: selector => selector === '[data-detail-control]' ? detailWrapper : null });
|
|
410
|
+
const variantSelect = Object.assign(element('select'), { value: '', closest: selector => selector === '[data-variant-control]' ? variantWrapper : null });
|
|
411
|
+
const languageSelect = Object.assign(element('select'), { closest: () => null });
|
|
412
|
+
const detailPanels = ['capable-first', 'capable-other', 'single'].flatMap(client => ['simple', 'thorough'].map(detail => ({
|
|
413
|
+
client, detail, tagName: 'DETAILS', hidden: false, open: false,
|
|
414
|
+
getAttribute(name) { return name === 'data-client' ? client : detail; }
|
|
415
|
+
})));
|
|
416
|
+
const geometry = { scroll: 0 };
|
|
417
|
+
const example = {
|
|
418
|
+
isConnected: true,
|
|
419
|
+
getAttribute: () => null,
|
|
420
|
+
querySelectorAll: () => detailPanels, querySelector: () => ({ hidden: true }),
|
|
421
|
+
getBoundingClientRect: () => ({ top: 100 + (detailPanels.find(panel => !panel.hidden)?.client === 'single' ? 80 : 0) - geometry.scroll, bottom: 600 })
|
|
422
|
+
};
|
|
423
|
+
const detailDisplay = {
|
|
424
|
+
metadata: [{ id: 'capable-first', language: 'capable', language_label: 'Capable', variant: 'First', details: ['simple', 'thorough'] },
|
|
425
|
+
{ id: 'capable-other', language: 'capable', language_label: 'Capable', variant: 'Other', details: ['simple', 'thorough'] },
|
|
426
|
+
{ id: 'single', language: 'single', language_label: 'Single', variant: null, details: ['simple'] }],
|
|
427
|
+
saved: { language: 'capable', clients: { capable: 'capable-other' }, detail: 'thorough' }, geometry, sidebar: { scrollTop: 17, addEventListener() {} },
|
|
428
|
+
matches: { '[data-language-select]': [languageSelect], '[data-variant-select]': [variantSelect], '[data-detail-select]': [detailSelect], '[data-example]': [example] }
|
|
429
|
+
};
|
|
430
|
+
const detailFixture = browserFixture('missing', false, detailDisplay);
|
|
431
|
+
const values = select => select.children.map(option => option.value);
|
|
432
|
+
assert.deepEqual(values(languageSelect), ['capable', 'single']);
|
|
433
|
+
assert.deepEqual(values(variantSelect), ['capable-first', 'capable-other']);
|
|
434
|
+
assert.equal(variantSelect.value, 'capable-other');
|
|
435
|
+
assert.deepEqual(values(detailSelect), ['simple', 'thorough']);
|
|
436
|
+
assert.deepEqual(detailSelect.children.map(option => option.textContent), ['Simple', 'With error handling']);
|
|
437
|
+
assert.equal(detailSelect.value, 'thorough');
|
|
438
|
+
assert.equal(detailWrapper.hidden, false);
|
|
439
|
+
assert.equal(languageSelect.disabled, false, 'Language remains the primary available choice');
|
|
440
|
+
languageSelect.value = 'single'; languageSelect.listeners.change();
|
|
441
|
+
assert.equal(detailWrapper.hidden, false); assert.equal(detailSelect.disabled, true);
|
|
442
|
+
assert.equal(variantWrapper.hidden, true); assert.equal(variantSelect.disabled, true);
|
|
443
|
+
assert.deepEqual(values(variantSelect), ['single'], 'Client contains only renderers for the selected language');
|
|
444
|
+
assert.deepEqual(values(detailSelect), ['simple'], 'Examples contains only the current renderer capabilities');
|
|
445
|
+
assert.equal(detailSelect.value, 'simple');
|
|
446
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'simple');
|
|
447
|
+
assert.equal(JSON.parse(detailFixture.writes.at(-1).value).detail, 'thorough', 'incapable clients never replace the saved preference');
|
|
448
|
+
variantSelect.value = 'single'; variantSelect.listeners.change();
|
|
449
|
+
assert.equal(JSON.parse(detailFixture.writes.at(-1).value).clients.capable, 'capable-other', 'a singleton client event preserves other language choices');
|
|
450
|
+
const beforeSingletonChange = detailFixture.writes.length;
|
|
451
|
+
detailSelect.listeners.change();
|
|
452
|
+
assert.equal(detailFixture.writes.length, beforeSingletonChange, 'a singleton event does not erase a saved alternate level');
|
|
453
|
+
assert.equal(example.getBoundingClientRect().top, 100, 'capability changes preserve the stable example position');
|
|
454
|
+
assert.equal(geometry.scroll, 80, 'scroll compensation covers layout movement');
|
|
455
|
+
languageSelect.value = 'capable'; languageSelect.listeners.change();
|
|
456
|
+
assert.equal(detailWrapper.hidden, false); assert.equal(detailSelect.disabled, false);
|
|
457
|
+
assert.equal(variantWrapper.hidden, false); assert.equal(variantSelect.disabled, false, 'Client label/control returns for multiple variants');
|
|
458
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'thorough');
|
|
459
|
+
assert.equal(detailSelect.value, 'thorough'); assert.equal(geometry.scroll, 0);
|
|
460
|
+
assert.equal(variantSelect.value, 'capable-other', 'another language preserves this language client preference');
|
|
461
|
+
assert.deepEqual(values(variantSelect), ['capable-first', 'capable-other']);
|
|
462
|
+
detailSelect.value = 'simple'; detailSelect.listeners.change();
|
|
463
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'simple', 'supported user detail changes still take effect');
|
|
464
|
+
assert.equal(JSON.parse(detailFixture.writes.at(-1).value).detail, 'simple');
|
|
465
|
+
detailSelect.value = 'thorough'; detailSelect.listeners.change();
|
|
466
|
+
assert.equal(JSON.parse(detailFixture.writes.at(-1).value).clients.capable, 'capable-other');
|
|
467
|
+
detailDisplay.metadata[2].details = null;
|
|
468
|
+
browserFixture('missing', false, { ...detailDisplay, saved: { language: 'single', detail: 'thorough' } });
|
|
469
|
+
assert.equal(detailSelect.disabled, true); assert.equal(detailWrapper.hidden, false);
|
|
470
|
+
assert.deepEqual(values(detailSelect), ['simple']);
|
|
471
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).client, 'single', 'malformed metadata must not discard the renderer');
|
|
472
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'simple');
|
|
473
|
+
delete detailDisplay.metadata[2].details;
|
|
474
|
+
browserFixture('missing', false, { ...detailDisplay, saved: { language: 'single', detail: 'thorough' } });
|
|
475
|
+
assert.equal(detailSelect.disabled, false); assert.equal(detailWrapper.hidden, false);
|
|
476
|
+
assert.deepEqual(values(detailSelect), ['simple', 'thorough']);
|
|
477
|
+
assert.equal(detailPanels.find(panel => !panel.hidden).detail, 'thorough', 'missing metadata keeps legacy two-level markup');
|
|
478
|
+
|
|
479
|
+
function declaredGroup(levels, panels) {
|
|
480
|
+
const unavailable = { hidden: true };
|
|
481
|
+
return { isConnected: true, getAttribute: () => levels,
|
|
482
|
+
querySelectorAll: () => panels, querySelector: () => unavailable,
|
|
483
|
+
getBoundingClientRect: () => ({ top: 100, bottom: 500 }), unavailable };
|
|
484
|
+
}
|
|
485
|
+
const panelFor = (client, detail) => ({ tagName: 'DETAILS', hidden: false, open: false,
|
|
486
|
+
getAttribute: name => name === 'data-client' ? client : detail });
|
|
487
|
+
const mixedControls = { language: Object.assign(element('select'), { closest: () => null }),
|
|
488
|
+
detail: Object.assign(element('select'), { closest: () => null }) };
|
|
489
|
+
const mixedInit = declaredGroup('{"callback":["simple"]}', [panelFor('callback', 'simple')]);
|
|
490
|
+
const mixedAction = declaredGroup('{"callback":["simple","thorough"]}', [panelFor('callback', 'simple'), panelFor('callback', 'thorough')]);
|
|
491
|
+
const mixedCustom = declaredGroup(null, [panelFor('custom', 'simple'), panelFor('custom', 'thorough')]);
|
|
492
|
+
const mixedMalformed = declaredGroup('{broken', [panelFor('callback', 'simple')]);
|
|
493
|
+
const mixedDisplay = { saved: { language: 'callback', detail: 'thorough' }, geometry: { scroll: 0 },
|
|
494
|
+
metadata: [{ id: 'callback', language: 'callback', language_label: 'Callback', details: ['simple', 'thorough'] },
|
|
495
|
+
{ id: 'custom', language: 'custom', language_label: 'Custom' }],
|
|
496
|
+
matches: { '[data-example]': [mixedInit, mixedAction, mixedCustom, mixedMalformed], '[data-language-select]': [mixedControls.language], '[data-detail-select]': [mixedControls.detail] } };
|
|
497
|
+
const mixedFixture = browserFixture('missing', false, mixedDisplay);
|
|
498
|
+
assert.deepEqual(values(mixedControls.detail), ['simple', 'thorough'], 'mixed page union retains meaningful action choices');
|
|
499
|
+
assert.equal(mixedInit.querySelectorAll().find(panel => !panel.hidden).getAttribute('data-detail'), 'simple', 'provider initialization stays actually Simple with a global thorough choice');
|
|
500
|
+
assert.equal(mixedAction.querySelectorAll().find(panel => !panel.hidden).getAttribute('data-detail'), 'thorough');
|
|
501
|
+
assert.equal(mixedMalformed.querySelectorAll().find(panel => !panel.hidden).getAttribute('data-detail'), 'simple');
|
|
502
|
+
assert.equal(mixedCustom.unavailable.hidden, false, 'missing current renderer preserves the explicit unavailable message');
|
|
503
|
+
mixedControls.language.value = 'custom'; mixedControls.language.listeners.change();
|
|
504
|
+
assert.equal(mixedCustom.querySelectorAll().find(panel => !panel.hidden).getAttribute('data-detail'), 'thorough', 'custom defaults and omitted group metadata retain distinct thorough output');
|
|
505
|
+
assert.equal(mixedInit.unavailable.hidden, false);
|
|
506
|
+
const singletonPageSelect = Object.assign(element('select'), { closest: () => null });
|
|
507
|
+
const singletonPage = browserFixture('missing', false, { metadata: [{ id: 'callback', language: 'callback', language_label: 'Callback', details: ['simple'] }],
|
|
508
|
+
saved: JSON.parse(mixedFixture.writes.at(-1).value), geometry: { scroll: 0 },
|
|
509
|
+
matches: { '[data-detail-select]': [singletonPageSelect] } });
|
|
510
|
+
assert.deepEqual(values(singletonPageSelect), ['simple'], 'a no-example page consumes its emitted Simple-only union');
|
|
511
|
+
assert.equal(singletonPageSelect.disabled, true); assert.equal(singletonPageSelect.hidden, false);
|
|
512
|
+
singletonPageSelect.listeners.change();
|
|
513
|
+
assert.equal(singletonPage.writes.length, 0, 'singleton pages/events never persist over the preferred thorough choice');
|
|
514
|
+
const restoredPageSelect = Object.assign(element('select'), { closest: () => null });
|
|
515
|
+
browserFixture('missing', false, { metadata: mixedDisplay.metadata, saved: JSON.parse(mixedFixture.writes.at(-1).value), geometry: { scroll: 0 },
|
|
516
|
+
matches: { '[data-detail-select]': [restoredPageSelect] } });
|
|
517
|
+
assert.equal(restoredPageSelect.value, 'thorough', 'returning from a Simple-only page restores the saved preferred thorough level');
|
|
518
|
+
|
|
519
|
+
const oneLanguage = element('select');
|
|
520
|
+
const oneClientWrapper = { hidden: false };
|
|
521
|
+
const oneClient = Object.assign(element('select'), { closest: () => oneClientWrapper });
|
|
522
|
+
const oneDetail = Object.assign(element('select'), { closest: () => null });
|
|
523
|
+
const oneChoice = browserFixture('missing', false, { saved: { language: 'only', detail: 'thorough' },
|
|
524
|
+
metadata: [{ id: 'only', language: 'only', language_label: 'Only', details: ['simple'] }], geometry: { scroll: 0 },
|
|
525
|
+
matches: { '[data-language-select]': [oneLanguage], '[data-variant-select]': [oneClient], '[data-detail-select]': [oneDetail] } });
|
|
526
|
+
assert.deepEqual(values(oneLanguage), ['only']); assert.equal(oneLanguage.disabled, true);
|
|
527
|
+
assert.equal(oneClientWrapper.hidden, true); assert.equal(oneClient.disabled, true);
|
|
528
|
+
assert.equal(oneDetail.hidden, false); assert.equal(oneDetail.disabled, true); assert.equal(oneDetail.value, 'simple');
|
|
529
|
+
oneDetail.listeners.change(); assert.equal(oneChoice.writes.length, 0, 'no-example singleton controls do not erase a saved thorough preference');
|
|
530
|
+
|
|
531
|
+
const fixture = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../documentation/fixtures/search_index.json'), 'utf8'));
|
|
532
|
+
const key = 'a'.repeat(64);
|
|
533
|
+
const action = 'https://docs.example' + fixture.index.base_path;
|
|
534
|
+
const indexURL = action + '?page=resources&search_index=' + key;
|
|
535
|
+
const config = searchConfiguration(action, indexURL, key, fixture.index.version, current, false);
|
|
536
|
+
assert.equal(config.base_path, fixture.index.base_path);
|
|
537
|
+
assert.equal(config.key, key);
|
|
538
|
+
assert.equal(searchConfiguration(action, indexURL, key, fixture.index.version, current, true).url, action + '?page=resources&search_index=current');
|
|
539
|
+
assert.equal(searchConfiguration(action, action + '?page=resources&search_index=current', '', fixture.index.version, current, false).key, '');
|
|
540
|
+
assert.equal(searchConfiguration(action, 'https://foreign.example/api/v1/?page=resources&search_index=' + key, key, '1', current, false), null);
|
|
541
|
+
assert.equal(searchConfiguration(action, indexURL + '&q=secret', key, '1', current, false), null);
|
|
542
|
+
assert.equal(searchConfiguration(action, indexURL, '', '1', current, false), null);
|
|
543
|
+
assert.equal(searchConfiguration(action, indexURL.replace(key, 'b'.repeat(64)), key, '1', current, false), null);
|
|
544
|
+
const index = validateSearchIndex({ key, index: fixture.index }, config);
|
|
545
|
+
const matchIndex = searchMatcher(index);
|
|
546
|
+
for (const example of fixture.queries) {
|
|
547
|
+
const result = matchIndex(example.query);
|
|
548
|
+
const identities = result.results.map(item => [item.kind, item.authentication || '', item.resource_path.join('.'), item.action_name || '', item.direction || '', item.name].join(':'));
|
|
549
|
+
assert.deepEqual(identities, example.expected, example.query + ': shared Ruby/browser kind-first ordering');
|
|
550
|
+
assert.equal(result.more, example.more, example.query);
|
|
551
|
+
}
|
|
552
|
+
for (const example of fixture.display_queries) {
|
|
553
|
+
const rendered = matchIndex(example.query).results.map(result => renderSearchResult(elements, index, result, config.origin));
|
|
554
|
+
assert.deepEqual(rendered.map(item => ({ title: item.children[0].textContent, context: item.children.slice(1).map(span => span.textContent) })),
|
|
555
|
+
example.expected, example.query + ': actual suggestion title/context matches native HTML');
|
|
556
|
+
}
|
|
557
|
+
const labelIndex = { resources: [
|
|
558
|
+
{ path: ['same'], label: 'Application', authentication: null, search: ['needle'] },
|
|
559
|
+
{ path: ['same', 'child'], label: 'Child', authentication: null, search: ['needle'] },
|
|
560
|
+
{ path: ['same'], label: 'Provider', authentication: 'token', search: ['needle'] },
|
|
561
|
+
{ path: ['same', 'child'], label: 'Member', authentication: 'token', search: ['needle'] },
|
|
562
|
+
{ path: ['missing', 'child'], label: 'Leaf', authentication: null, search: ['needle'] }
|
|
563
|
+
], actions: [], parameters: [] };
|
|
564
|
+
const labels = searchMatcher(labelIndex)('needle').results;
|
|
565
|
+
assert.equal(labels.find(result => result.resource_path.join('.') === 'same.child' && result.authentication === null).resource_label, 'Application · Child');
|
|
566
|
+
assert.equal(labels.find(result => result.resource_path.join('.') === 'same.child' && result.authentication === 'token').resource_label, 'Provider · Member');
|
|
567
|
+
assert.equal(labels.find(result => result.resource_path[0] === 'missing').resource_label, 'missing · Leaf');
|
|
568
|
+
const identical = renderSearchResult(elements, index, { kind: 'resource', resource_path: ['same'], resource_label: 'same', authentication: null }, config.origin);
|
|
569
|
+
assert.equal(identical.children.length, 1, 'identical resource context is omitted');
|
|
570
|
+
const emptyLabel = renderSearchResult(elements, index, { kind: 'resource', resource_path: ['same'], resource_label: '', authentication: null }, config.origin);
|
|
571
|
+
assert.equal(emptyLabel.children[1].textContent, '', 'valid empty labels preserve native HTML presentation semantics');
|
|
572
|
+
for (const mutate of [
|
|
573
|
+
value => { value.key = 'b'.repeat(64); },
|
|
574
|
+
value => { value.index.format = 2; },
|
|
575
|
+
value => { value.index.version = 'other'; },
|
|
576
|
+
value => { value.index.base_path = '//foreign.example/'; },
|
|
577
|
+
value => { value.index.visibility = 'unknown'; },
|
|
578
|
+
value => { value.index.resources[0].secret = 'not part of projection'; },
|
|
579
|
+
value => { value.index.resources[0].search = 'not an array'; },
|
|
580
|
+
value => { value.index.resources[0].path = []; },
|
|
581
|
+
value => { value.index.resources[0].label = '\ud800'; },
|
|
582
|
+
value => { value.index.actions[0].resource = -1; },
|
|
583
|
+
value => { value.index.actions[0].resource = value.index.resources.length; },
|
|
584
|
+
value => { value.index.actions[0].identifiers = [null]; },
|
|
585
|
+
value => { value.index.parameters[0].action = 0.5; },
|
|
586
|
+
value => { value.index.parameters[0].direction = 'both'; },
|
|
587
|
+
value => { value.index.parameters[0].type = null; }
|
|
588
|
+
]) {
|
|
589
|
+
const envelope = JSON.parse(JSON.stringify({ key, index: fixture.index }));
|
|
590
|
+
mutate(envelope);
|
|
591
|
+
assert.throws(() => validateSearchIndex(envelope, config));
|
|
592
|
+
}
|
|
593
|
+
const unicodeResult = { kind: 'parameter', authentication: 'token', resource_path: ['síť', 'a.b'], action_name: 'vytvoř', direction: 'input', name: 'číslo_😀' };
|
|
594
|
+
const unicodeURL = new URL(searchResultURL(index, unicodeResult, config.origin));
|
|
595
|
+
assert.equal(unicodeURL.origin, config.origin);
|
|
596
|
+
assert.equal(unicodeURL.searchParams.get('page'), 'authentication');
|
|
597
|
+
assert.deepEqual(JSON.parse(Buffer.from(unicodeURL.hash.slice('#parameter-'.length), 'hex').toString('utf8')), ['token', ['síť', 'a.b'], 'vytvoř', 'input', 'číslo_😀']);
|
|
598
|
+
assert.notEqual(searchResultURL(index, { ...unicodeResult, resource_path: ['síť.a', 'b'] }, config.origin), unicodeURL.href);
|
|
599
|
+
assert.throws(() => searchResultURL({ base_path: '//foreign.example/' }, unicodeResult, config.origin));
|
|
600
|
+
const unusualResource = new URL(searchResultURL(index, { kind: 'resource', authentication: null,
|
|
601
|
+
resource_path: ['https://foreign.example/?q=<script>'], action_name: null }, config.origin));
|
|
602
|
+
assert.equal(unusualResource.origin, config.origin);
|
|
603
|
+
assert.equal(unusualResource.searchParams.get('resource'), 'https://foreign.example/?q=<script>');
|
|
604
|
+
|
|
605
|
+
function clipboardFixture(secure, nativeResult) {
|
|
606
|
+
const body = Object.assign(element('body'), { scrollLeft: 9, scrollTop: 33 });
|
|
607
|
+
const active = Object.assign(element('input'), { isConnected: true, scrollLeft: 3, scrollTop: 7,
|
|
608
|
+
selectionStart: 2, selectionEnd: 6, selectionDirection: 'backward' });
|
|
609
|
+
body.appendChild(active);
|
|
610
|
+
const range = { name: 'original selection', cloneRange() { return this; } };
|
|
611
|
+
const selection = { ranges: [range], get rangeCount() { return this.ranges.length; },
|
|
612
|
+
getRangeAt(index) { return this.ranges[index]; }, removeAllRanges() { this.ranges = []; }, addRange(value) { this.ranges.push(value); } };
|
|
613
|
+
const win = { isSecureContext: secure, location: { protocol: secure ? 'https:' : 'http:' }, scrollX: 4, scrollY: 321,
|
|
614
|
+
getSelection: () => selection, scrollTo(x, y) { this.scrollX = x; this.scrollY = y; } };
|
|
615
|
+
const copied = [];
|
|
616
|
+
const doc = { body, activeElement: active, createElementNS: elements.createElementNS,
|
|
617
|
+
createElement(tag) {
|
|
618
|
+
const field = element(tag);
|
|
619
|
+
field.focus = options => {
|
|
620
|
+
assert.equal(options.preventScroll, true); doc.activeElement = field;
|
|
621
|
+
win.scrollX = 999; win.scrollY = 999; body.scrollTop = 999; active.scrollTop = 999;
|
|
622
|
+
};
|
|
623
|
+
field.select = () => { selection.ranges = []; active.selectionStart = 0; active.selectionEnd = 0; };
|
|
624
|
+
return field;
|
|
625
|
+
},
|
|
626
|
+
execCommand(command) {
|
|
627
|
+
assert.equal(command, 'copy'); copied.push(doc.activeElement.value);
|
|
628
|
+
if (nativeResult === 'throw') throw new Error('clipboard denied');
|
|
629
|
+
return nativeResult;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
active.focus = options => { assert.equal(options.preventScroll, true); doc.activeElement = active; };
|
|
633
|
+
active.setSelectionRange = (start, end, direction) => { active.selectionStart = start; active.selectionEnd = end; active.selectionDirection = direction; };
|
|
634
|
+
return { win, doc, copied, assertRestored() {
|
|
635
|
+
assert.equal(doc.activeElement, active);
|
|
636
|
+
assert.deepEqual([active.selectionStart, active.selectionEnd, active.selectionDirection], [2, 6, 'backward']);
|
|
637
|
+
assert.deepEqual(selection.ranges, [range]); assert.deepEqual([win.scrollX, win.scrollY], [4, 321]);
|
|
638
|
+
assert.equal(body.scrollTop, 33); assert.equal(active.scrollTop, 7);
|
|
639
|
+
assert.deepEqual(body.children, [active], 'temporary clipboard field is removed');
|
|
640
|
+
} };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
(async () => {
|
|
644
|
+
await Promise.resolve(); assert.equal(slashRequests, 0, 'shortcut focus does not trigger the existing search intent/prefetch listener');
|
|
645
|
+
const exactText = [
|
|
646
|
+
'$ curl --request POST \\',
|
|
647
|
+
' --data-binary \'{"widget":{"name":"雪"}}\' \\',
|
|
648
|
+
' https://docs.example/api/widgets/7',
|
|
649
|
+
'',
|
|
650
|
+
'# NAME: $(touch marker)',
|
|
651
|
+
'# `touch marker`',
|
|
652
|
+
'# ; echo "<unsafe>" $value \\',
|
|
653
|
+
'# trailing\\',
|
|
654
|
+
''
|
|
655
|
+
].join('\n');
|
|
656
|
+
const secure = clipboardFixture(true, true);
|
|
657
|
+
const secureCalls = [];
|
|
658
|
+
const secureResult = copyText(exactText, secure.win, secure.doc, { clipboard: { writeText(text) { secureCalls.push(text); return Promise.resolve(); } } });
|
|
659
|
+
assert.deepEqual(secureCalls, [exactText], 'secure clipboard starts synchronously in the click gesture with exact text');
|
|
660
|
+
assert.equal(await secureResult, true); assert.deepEqual(secure.copied, []); secure.assertRestored();
|
|
661
|
+
assert.equal(await copyText(exactText, secure.win, secure.doc, { clipboard: { writeText() { return Promise.reject(new Error('denied')); } } }), false);
|
|
662
|
+
assert.equal(await copyText(exactText, secure.win, secure.doc, {}), false, 'missing secure API offers honest manual copy');
|
|
663
|
+
assert.deepEqual(secure.copied, [], 'denied secure clipboard does not invoke a second clipboard mechanism');
|
|
664
|
+
secure.assertRestored();
|
|
665
|
+
for (const outcome of [true, false, 'throw']) {
|
|
666
|
+
const native = clipboardFixture(false, outcome);
|
|
667
|
+
assert.equal(await copyText(exactText, native.win, native.doc, {}), outcome === true);
|
|
668
|
+
assert.deepEqual(native.copied, [exactText]); native.assertRestored();
|
|
669
|
+
}
|
|
670
|
+
const unsupported = clipboardFixture(false, true); unsupported.doc.execCommand = undefined;
|
|
671
|
+
assert.equal(await copyText(exactText, unsupported.win, unsupported.doc, {}), false); unsupported.assertRestored();
|
|
672
|
+
|
|
673
|
+
function copyPanel(text, hidden) {
|
|
674
|
+
const panel = Object.assign(element('details'), { hidden, open: !hidden,
|
|
675
|
+
closest: () => panel.hidden ? panel : null, getClientRects: () => panel.hidden ? [] : [{}] });
|
|
676
|
+
const pre = element('pre'); const code = element('code'); code.textContent = text;
|
|
677
|
+
panel.appendChild(pre); pre.appendChild(code);
|
|
678
|
+
panel.querySelector = selector => { assert.equal(selector, ':scope > [data-example-code-block] > pre > code'); return code; };
|
|
679
|
+
return { panel, pre, code };
|
|
680
|
+
}
|
|
681
|
+
const simple = copyPanel(exactText, false); const thorough = copyPanel(' other panel\n', true);
|
|
682
|
+
let release;
|
|
683
|
+
let mode = 'success';
|
|
684
|
+
const panelCopies = [];
|
|
685
|
+
const copyRoot = { querySelectorAll(selector) {
|
|
686
|
+
assert.equal(selector, '[data-example] > [data-example-panel] > pre > code, [data-example] > [data-example-panel] > [data-example-code-block] > pre > code', 'guide/limitation blocks are outside copy controls');
|
|
687
|
+
return [simple.code, thorough.code];
|
|
688
|
+
} };
|
|
689
|
+
const clipboard = { clipboard: { writeText(text) {
|
|
690
|
+
panelCopies.push(text);
|
|
691
|
+
return mode === 'pending' ? new Promise(resolve => { release = resolve; })
|
|
692
|
+
: mode === 'failure' ? Promise.reject(new Error('denied')) : Promise.resolve();
|
|
693
|
+
} } };
|
|
694
|
+
const clearCopies = exampleCopyControls(copyRoot, secure.win, elements, clipboard);
|
|
695
|
+
const block = pair => pair.pre.parentElement;
|
|
696
|
+
const button = pair => block(pair).children.find(child => child.tagName === 'BUTTON');
|
|
697
|
+
const status = pair => block(pair).children.find(child => child.tagName === 'SPAN');
|
|
698
|
+
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
699
|
+
for (const outcome of [true, false, 'throw']) {
|
|
700
|
+
const native = clipboardFixture(false, outcome);
|
|
701
|
+
const selected = copyPanel(exactText, false);
|
|
702
|
+
const root = { querySelectorAll() { return [selected.code]; } };
|
|
703
|
+
exampleCopyControls(root, native.win, native.doc, {});
|
|
704
|
+
button(selected).listeners.click(); await flush();
|
|
705
|
+
assert.deepEqual(native.copied, [exactText], 'real Copy handler preserves the complete commented shell transcript through native fallback');
|
|
706
|
+
assert.equal(status(selected).textContent, outcome === true ? 'Copied' : 'Select the code and copy it.');
|
|
707
|
+
assert.equal(selected.code.textContent, exactText); native.assertRestored();
|
|
708
|
+
}
|
|
709
|
+
assert.equal(button(simple).attributes['aria-label'], 'Copy example');
|
|
710
|
+
assert.equal(button(simple).type, 'button');
|
|
711
|
+
assert.equal(status(simple).attributes['aria-live'], 'polite');
|
|
712
|
+
assert.deepEqual(simple.pre.children, [simple.code], 'controls remain outside code/highlighter ownership');
|
|
713
|
+
assert.deepEqual(simple.panel.children, [block(simple)], 'only the pre and controls are wrapped, never the panel/details summary');
|
|
714
|
+
assert(block(simple).hasAttribute('data-example-code-block'));
|
|
715
|
+
assert.equal(block(simple).className, 'docs-code-block');
|
|
716
|
+
exampleCopyControls(copyRoot, secure.win, elements, clipboard);
|
|
717
|
+
assert.equal(block(simple).children.filter(child => child.tagName === 'BUTTON').length, 1, 'reinitialization neither nests wrappers nor duplicates copy controls/listeners');
|
|
718
|
+
assert.equal(simple.code.textContent, exactText, 'wrapper and status never contaminate the actual code');
|
|
719
|
+
assert.deepEqual(simple.pre.style, {}, 'copy enhancement adds no inline top inset or line-height to the original pre');
|
|
720
|
+
assert.deepEqual(simple.code.style, {}, 'the original source keeps its own style and first line');
|
|
721
|
+
simple.pre.scrollLeft = 417;
|
|
722
|
+
button(thorough).listeners.click(); assert.equal(panelCopies.length, 0, 'hidden panels cannot copy');
|
|
723
|
+
button(simple).listeners.click(); await flush();
|
|
724
|
+
assert.deepEqual(panelCopies, [exactText]); assert.equal(status(simple).textContent, 'Copied');
|
|
725
|
+
assert.equal(simple.pre.scrollLeft, 417, 'a delivered overlay click copies exact text after local code panning without resetting it');
|
|
726
|
+
simple.panel.hidden = true; simple.panel.open = false; thorough.panel.hidden = false; thorough.panel.open = true; clearCopies();
|
|
727
|
+
thorough.code.textContent = '\n updated currently visible error-handling text\n';
|
|
728
|
+
button(thorough).listeners.click(); await flush();
|
|
729
|
+
assert.equal(panelCopies.at(-1), thorough.code.textContent, 'click reads current selected panel text, not an earlier snapshot');
|
|
730
|
+
assert.equal(status(thorough).textContent, 'Copied');
|
|
731
|
+
mode = 'pending'; button(thorough).listeners.click();
|
|
732
|
+
thorough.panel.hidden = true; thorough.panel.open = false; simple.panel.hidden = false; simple.panel.open = true; clearCopies();
|
|
733
|
+
release(); await flush(); assert.equal(status(thorough).textContent, ''); assert.equal(status(simple).textContent, '', 'obsolete completion announces neither panel');
|
|
734
|
+
mode = 'failure'; button(simple).listeners.click(); await flush();
|
|
735
|
+
assert.equal(status(simple).textContent, 'Select the code and copy it.');
|
|
736
|
+
assert.equal(simple.pre.scrollLeft, 417); assert.equal(simple.code.textContent, exactText, 'failure status remains outside the locally panned code');
|
|
737
|
+
simple.panel.open = false; button(simple).listeners.click(); assert.equal(panelCopies.length, 4, 'closed details cannot copy');
|
|
738
|
+
|
|
739
|
+
// Run the actual page initializer so option changes, effective panels and
|
|
740
|
+
// copy controls share the same state rather than a second selection model.
|
|
741
|
+
const copyLanguage = Object.assign(element('select'), { closest: () => null });
|
|
742
|
+
const copyVariant = Object.assign(element('select'), { closest: selector => selector === '[data-variant-control]' ? { hidden: false } : null });
|
|
743
|
+
const copyDetail = Object.assign(element('select'), { closest: selector => selector === '[data-detail-control]' ? { hidden: false } : null });
|
|
744
|
+
const copyPanels = ['capable', 'single'].flatMap(client => ['simple', 'thorough'].map(detail => {
|
|
745
|
+
const pair = copyPanel(' ' + client + ' ' + detail + '\n', false);
|
|
746
|
+
pair.panel.setAttribute('data-client', client); pair.panel.setAttribute('data-detail', detail);
|
|
747
|
+
return pair;
|
|
748
|
+
}));
|
|
749
|
+
const currentExample = { isConnected: true, getAttribute: () => null, querySelectorAll: () => copyPanels.map(pair => pair.panel),
|
|
750
|
+
querySelector: () => ({ hidden: true }), getBoundingClientRect: () => ({ top: 100, bottom: 500 }) };
|
|
751
|
+
const actualCopies = [];
|
|
752
|
+
const copyControls = browserFixture('missing', false, {
|
|
753
|
+
saved: { language: 'capable', detail: 'thorough' }, geometry: { scroll: 0 },
|
|
754
|
+
metadata: [{ id: 'capable', language: 'capable', language_label: 'Capable', details: ['simple', 'thorough'] },
|
|
755
|
+
{ id: 'single', language: 'single', language_label: 'Single', details: ['simple'] }],
|
|
756
|
+
clipboard: { writeText(text) { actualCopies.push(text); return Promise.resolve(); } },
|
|
757
|
+
matches: { '[data-language-select]': [copyLanguage], '[data-variant-select]': [copyVariant],
|
|
758
|
+
'[data-detail-select]': [copyDetail], '[data-example]': [currentExample],
|
|
759
|
+
'[data-example] > [data-example-panel] > pre > code, [data-example] > [data-example-panel] > [data-example-code-block] > pre > code': copyPanels.map(pair => pair.code) }
|
|
760
|
+
});
|
|
761
|
+
const selectedPair = () => copyPanels.find(pair => !pair.panel.hidden);
|
|
762
|
+
button(selectedPair()).listeners.click(); await flush();
|
|
763
|
+
assert.equal(actualCopies.at(-1), ' capable thorough\n');
|
|
764
|
+
copyLanguage.value = 'single'; copyLanguage.listeners.change();
|
|
765
|
+
assert.equal(status(copyPanels[1]).textContent, '', 'changing selected client clears stale copied status');
|
|
766
|
+
assert.deepEqual(values(copyVariant), ['single']); assert.deepEqual(values(copyDetail), ['simple']);
|
|
767
|
+
assert.equal(copyVariant.disabled, true); assert.equal(copyDetail.disabled, true);
|
|
768
|
+
assert.equal(copyDetail.hidden, false);
|
|
769
|
+
button(selectedPair()).listeners.click(); await flush();
|
|
770
|
+
assert.equal(actualCopies.at(-1), ' single simple\n', 'copy uses the actual Simple panel while its singleton selector is disabled');
|
|
771
|
+
assert.equal(JSON.parse(copyControls.writes.at(-1).value).detail, 'thorough');
|
|
772
|
+
copyLanguage.value = 'capable'; copyLanguage.listeners.change();
|
|
773
|
+
button(selectedPair()).listeners.click(); await flush();
|
|
774
|
+
assert.equal(actualCopies.at(-1), ' capable thorough\n', 'returning to capable restores the preferred level for copying too');
|
|
775
|
+
|
|
776
|
+
const operations = [];
|
|
777
|
+
class Controller {
|
|
778
|
+
constructor() { this.signal = { aborted: false }; }
|
|
779
|
+
abort() { this.signal.aborted = true; }
|
|
780
|
+
}
|
|
781
|
+
const loader = searchIndexLoader((url, options) => new Promise((resolve, reject) => operations.push({ url, options, resolve, reject })), Controller,
|
|
782
|
+
(html, expected) => validateSearchIndex(JSON.parse(html), expected));
|
|
783
|
+
assert.equal(operations.length, 0, 'constructing an index loader must not fetch on page load');
|
|
784
|
+
const first = loader.load(config);
|
|
785
|
+
const second = loader.load(config);
|
|
786
|
+
assert.equal(first, second, 'forms share the same pending index load');
|
|
787
|
+
await Promise.resolve();
|
|
788
|
+
assert.equal(operations.length, 1);
|
|
789
|
+
assert.equal(operations[0].options.cache, 'default');
|
|
790
|
+
assert.equal(operations[0].options.credentials, 'same-origin');
|
|
791
|
+
assert.equal(operations[0].options.redirect, 'error');
|
|
792
|
+
assert.equal(operations[0].options.headers.Accept, 'text/html');
|
|
793
|
+
operations[0].resolve({ ok: true, text: async () => JSON.stringify({ key, index }) });
|
|
794
|
+
const loaded = await first;
|
|
795
|
+
assert.equal(loader.peek(config), loaded);
|
|
796
|
+
assert.equal(await loader.load(config), loaded, 'warm queries reuse document memory');
|
|
797
|
+
assert.equal(operations.length, 1);
|
|
798
|
+
loader.reset();
|
|
799
|
+
assert.equal(loader.peek(config), null, 'BFCache restoration discards the prior proof');
|
|
800
|
+
const boot = searchConfiguration(action, indexURL, key, config.version, current, true);
|
|
801
|
+
const pending = loader.load(boot);
|
|
802
|
+
await Promise.resolve();
|
|
803
|
+
loader.reset();
|
|
804
|
+
assert.equal(operations[1].options.signal.aborted, true);
|
|
805
|
+
operations[1].resolve({ ok: true, text: async () => JSON.stringify({ key, index }) });
|
|
806
|
+
await assert.rejects(pending);
|
|
807
|
+
assert.equal(loader.peek(boot), null, 'obsolete completions cannot restore discarded snapshots');
|
|
808
|
+
const failed = loader.load(config);
|
|
809
|
+
await Promise.resolve();
|
|
810
|
+
operations[2].resolve({ ok: false });
|
|
811
|
+
await assert.rejects(failed);
|
|
812
|
+
const retry = loader.load(config);
|
|
813
|
+
await Promise.resolve();
|
|
814
|
+
operations[3].resolve({ ok: true, text: async () => JSON.stringify({ key: 'b'.repeat(64), index }) });
|
|
815
|
+
await assert.rejects(retry, 'mismatched proof must use the normal-search fallback');
|
|
816
|
+
console.log('Documentation controls, clipboard preservation, shared search display, strict projection and lazy cache loading passed');
|
|
817
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|