glib-web 4.44.0 → 4.44.2
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.
- package/.claude/settings.local.json +11 -1
- package/AGENTS.md +3 -0
- package/components/component.vue +2 -0
- package/components/fields/captcha.vue +90 -0
- package/components/fields/textarea.vue +5 -1
- package/components/fields/urlFragment.vue +13 -6
- package/cypress/e2e/glib-web/fieldsCaptcha.cy.ts +52 -0
- package/cypress/e2e/glib-web/fieldsUrlFragment.cy.ts +29 -0
- package/cypress/helper.ts +4 -4
- package/doc/TESTING.md +5 -3
- package/package.json +1 -1
|
@@ -16,7 +16,17 @@
|
|
|
16
16
|
"Bash(readlink:*)",
|
|
17
17
|
"WebFetch(domain:github.com)",
|
|
18
18
|
"Bash(npm run dev)",
|
|
19
|
-
"Bash(npm run)"
|
|
19
|
+
"Bash(npm run)",
|
|
20
|
+
"Bash(npx eslint:*)",
|
|
21
|
+
"Bash(npm install:*)",
|
|
22
|
+
"Bash(yarn lint:*)",
|
|
23
|
+
"Bash(bin/rails db:migrate:*)",
|
|
24
|
+
"Bash(bin/rails server:*)",
|
|
25
|
+
"Bash(dpkg -S:*)",
|
|
26
|
+
"Bash(source:*)",
|
|
27
|
+
"Bash(node --version:*)",
|
|
28
|
+
"Bash(nvm ls:*)",
|
|
29
|
+
"Bash(nvm use:*)"
|
|
20
30
|
],
|
|
21
31
|
"deny": [],
|
|
22
32
|
"ask": []
|
package/AGENTS.md
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
- For working examples on how to use `glib` UI components, see all the jbuilder
|
|
13
13
|
files located in `doc/garage/`.
|
|
14
14
|
|
|
15
|
+
## Testing
|
|
16
|
+
- Read [TESTING.md](doc/TESTING.md) before writing or running Cypress tests.
|
|
17
|
+
|
|
15
18
|
## Agent commands
|
|
16
19
|
- Available agent command definitions live in `agent/commands/` (see the `.yaml` files there).
|
|
17
20
|
|
package/components/component.vue
CHANGED
|
@@ -97,6 +97,7 @@ import TimerField from "./fields/timer.vue";
|
|
|
97
97
|
import OtpField from "./fields/otpField.vue";
|
|
98
98
|
import ChipGroup from "./fields/chipGroup.vue";
|
|
99
99
|
import UrlFragmentField from "./fields/urlFragment.vue";
|
|
100
|
+
import CaptchaField from "./fields/captcha.vue";
|
|
100
101
|
|
|
101
102
|
import ScrollPanel from "./panels/scroll.vue";
|
|
102
103
|
import VerticalPanel from "./panels/vertical.vue";
|
|
@@ -202,6 +203,7 @@ export default {
|
|
|
202
203
|
"fields-otp": OtpField,
|
|
203
204
|
"fields-chipGroup": ChipGroup,
|
|
204
205
|
"fields-urlFragment": UrlFragmentField,
|
|
206
|
+
"fields-captcha": CaptchaField,
|
|
205
207
|
|
|
206
208
|
"panels-scroll": ScrollPanel,
|
|
207
209
|
"panels-vertical": VerticalPanel,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="loadIf" :class="$classes()" :style="$styles()">
|
|
3
|
+
<input type="hidden" :name="fieldName" :value="fieldModel" :disabled="inputDisabled" />
|
|
4
|
+
|
|
5
|
+
<div v-if="spec.siteKey">
|
|
6
|
+
<v-progress-circular v-if="loading" indeterminate color="primary" size="24" width="3" />
|
|
7
|
+
<p v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script>
|
|
13
|
+
import GlibBase from "../base/glibBase.js";
|
|
14
|
+
import { getCurrentInstance, onMounted, ref } from "vue";
|
|
15
|
+
import { useGlibInput } from "../composable/form";
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
extends: GlibBase,
|
|
19
|
+
props: {
|
|
20
|
+
spec: { type: Object, required: true },
|
|
21
|
+
},
|
|
22
|
+
setup(props) {
|
|
23
|
+
useGlibInput({ props });
|
|
24
|
+
|
|
25
|
+
const instance = getCurrentInstance();
|
|
26
|
+
const loading = ref(true);
|
|
27
|
+
const error = ref(null);
|
|
28
|
+
|
|
29
|
+
const loadRecaptchaEnterprise = () => {
|
|
30
|
+
return new Promise(function (resolve) {
|
|
31
|
+
if (window.grecaptcha && window.grecaptcha.enterprise) {
|
|
32
|
+
resolve(window.grecaptcha.enterprise);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
window.onRecaptchaEnterpriseLoad = function () {
|
|
37
|
+
resolve(window.grecaptcha.enterprise);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
var script = document.createElement('script');
|
|
41
|
+
script.src = 'https://www.google.com/recaptcha/enterprise.js?render=' + props.spec.siteKey + '&onload=onRecaptchaEnterpriseLoad';
|
|
42
|
+
script.async = true;
|
|
43
|
+
script.defer = true;
|
|
44
|
+
document.head.appendChild(script);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const initRecaptcha = async () => {
|
|
49
|
+
try {
|
|
50
|
+
var enterprise = await loadRecaptchaEnterprise();
|
|
51
|
+
|
|
52
|
+
enterprise.ready(function () {
|
|
53
|
+
enterprise.execute(props.spec.siteKey, { action: 'VERIFY' }).then(function (token) {
|
|
54
|
+
if (!token) {
|
|
55
|
+
error.value = 'Verification failed. Please refresh and try again.';
|
|
56
|
+
loading.value = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
loading.value = false;
|
|
61
|
+
instance.proxy.fieldModel = token;
|
|
62
|
+
|
|
63
|
+
GLib.action.execute(props.spec.onSolve, instance.proxy);
|
|
64
|
+
}).catch(function (err) {
|
|
65
|
+
console.error('reCAPTCHA execute failed:', err);
|
|
66
|
+
error.value = 'Verification failed. Please refresh and try again.';
|
|
67
|
+
loading.value = false;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error('Failed to load reCAPTCHA:', e);
|
|
72
|
+
error.value = 'Failed to load verification. Please refresh and try again.';
|
|
73
|
+
loading.value = false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
onMounted(() => {
|
|
78
|
+
if (!props.spec.siteKey) {
|
|
79
|
+
console.error('fields/captcha: siteKey is not configured');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
initRecaptcha();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { loading, error };
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<style scoped></style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div v-if="loadIf" :style="styles()" :class="$classes()">
|
|
3
3
|
<v-textarea ref="field" v-model="fieldModel" :color="gcolor" :label="spec.label" :name="fieldName" :hint="spec.hint"
|
|
4
|
-
:placeholder="spec.placeholder" :maxlength="
|
|
4
|
+
:placeholder="spec.placeholder" :maxlength="maxLength" :readonly="spec.readOnly" :height="height"
|
|
5
5
|
:rules="$validation()" counter :outlined="$classes().includes('outlined')" :disabled="inputDisabled"
|
|
6
6
|
:no-resize="$classes().includes('no-resize')" validate-on="blur" :variant="variant" :density="density"
|
|
7
7
|
persistent-placeholder :clearable="spec.clearable" @update:modelValue="onChange()">
|
|
@@ -40,6 +40,10 @@ export default {
|
|
|
40
40
|
computed: {
|
|
41
41
|
density() {
|
|
42
42
|
return determineDensity(this.spec.styleClasses);
|
|
43
|
+
},
|
|
44
|
+
maxLength() {
|
|
45
|
+
if (this.spec.maxLength < 0) return undefined;
|
|
46
|
+
return this.spec.maxLength || 255;
|
|
43
47
|
}
|
|
44
48
|
},
|
|
45
49
|
methods: {
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<input type="hidden" :name="fieldName" :
|
|
2
|
+
<input v-if="loadIf" type="hidden" :name="fieldName" :disabled="inputDisabled" :value="fieldModel" />
|
|
3
3
|
</template>
|
|
4
4
|
|
|
5
5
|
<script>
|
|
6
|
+
import GlibBase from "../base/glibBase.js";
|
|
7
|
+
import { getCurrentInstance, onMounted, nextTick } from "vue";
|
|
6
8
|
import { useGlibInput } from "../composable/form";
|
|
7
9
|
import { isRerender } from "../../store";
|
|
8
10
|
|
|
9
11
|
let hasPopulated = false;
|
|
10
12
|
|
|
11
13
|
export default {
|
|
14
|
+
extends: GlibBase,
|
|
12
15
|
props: {
|
|
13
16
|
spec: { type: Object, required: true },
|
|
14
17
|
},
|
|
15
18
|
setup(props) {
|
|
16
19
|
useGlibInput({ props });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
const instance = getCurrentInstance();
|
|
22
|
+
|
|
23
|
+
onMounted(() => {
|
|
20
24
|
if (hasPopulated && !isRerender()) return;
|
|
21
25
|
hasPopulated = true;
|
|
22
26
|
|
|
23
27
|
const hash = window.location.hash.substring(1);
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
instance.proxy.fieldModel = hash;
|
|
29
|
+
nextTick(() => {
|
|
30
|
+
GLib.action.executeWithFormData(props.spec.onResolveValue, instance.proxy, hash);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
26
33
|
},
|
|
27
34
|
};
|
|
28
35
|
</script>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { testPageUrl } from "../../helper"
|
|
2
|
+
|
|
3
|
+
const url = testPageUrl('fields_captcha')
|
|
4
|
+
|
|
5
|
+
const visitWithCaptcha = (executeResult: () => Promise<string>) => {
|
|
6
|
+
cy.visit(url, {
|
|
7
|
+
onBeforeLoad(win) {
|
|
8
|
+
(win as any).grecaptcha = {
|
|
9
|
+
enterprise: {
|
|
10
|
+
ready: function(cb: Function) { cb(); },
|
|
11
|
+
execute: function() { return executeResult(); }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('fieldsCaptcha', () => {
|
|
19
|
+
it('renders and shows verifying state', () => {
|
|
20
|
+
cy.intercept('GET', '**/recaptcha/enterprise.js*', { body: '' }).as('recaptchaScript')
|
|
21
|
+
|
|
22
|
+
cy.visit(url)
|
|
23
|
+
|
|
24
|
+
cy.get('input[type="hidden"][name="user[captcha_token]"]').should('exist')
|
|
25
|
+
cy.get('.v-progress-circular').should('exist')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('populates token when reCAPTCHA resolves', () => {
|
|
29
|
+
visitWithCaptcha(() => Promise.resolve('fake-captcha-token'))
|
|
30
|
+
|
|
31
|
+
cy.get('input[type="hidden"][name="user[captcha_token]"]').should('have.value', 'fake-captcha-token')
|
|
32
|
+
cy.get('#captcha_status').should('contain.text', 'Status: solved')
|
|
33
|
+
cy.get('.v-progress-circular').should('not.exist')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('submits the captcha token with the form', () => {
|
|
37
|
+
visitWithCaptcha(() => Promise.resolve('fake-captcha-token'))
|
|
38
|
+
|
|
39
|
+
cy.get('input[type="hidden"][name="user[captcha_token]"]').should('have.value', 'fake-captcha-token')
|
|
40
|
+
|
|
41
|
+
cy.contains('Submit form').click()
|
|
42
|
+
|
|
43
|
+
cy.get('.unformatted').should('contain.text', 'fake-captcha-token')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('shows error when reCAPTCHA fails', () => {
|
|
47
|
+
visitWithCaptcha(() => Promise.reject(new Error('reCAPTCHA unavailable')))
|
|
48
|
+
|
|
49
|
+
cy.contains('Verification failed. Please refresh and try again.').should('exist')
|
|
50
|
+
cy.get('.v-progress-circular').should('not.exist')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { testPageUrl } from "../../helper"
|
|
2
|
+
|
|
3
|
+
const url = testPageUrl('fields_url_fragment')
|
|
4
|
+
|
|
5
|
+
describe('fieldsUrlFragment', () => {
|
|
6
|
+
it('captures URL hash and populates hidden field', () => {
|
|
7
|
+
cy.visit(url + '#my-test-value')
|
|
8
|
+
|
|
9
|
+
cy.get('input[type="hidden"][name="user[fragment]"]').should('have.value', 'my-test-value')
|
|
10
|
+
cy.get('#fragment_status').should('contain.text', 'Status: resolved')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('submits the captured fragment with the form', () => {
|
|
14
|
+
cy.visit(url + '#submitted-value')
|
|
15
|
+
|
|
16
|
+
cy.get('input[type="hidden"][name="user[fragment]"]').should('have.value', 'submitted-value')
|
|
17
|
+
|
|
18
|
+
cy.contains('Submit form').click()
|
|
19
|
+
|
|
20
|
+
cy.get('.unformatted').should('contain.text', 'submitted-value')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('handles empty hash', () => {
|
|
24
|
+
cy.visit(url)
|
|
25
|
+
|
|
26
|
+
cy.get('input[type="hidden"][name="user[fragment]"]').should('have.value', '')
|
|
27
|
+
cy.get('#fragment_status').should('contain.text', 'Status: resolved')
|
|
28
|
+
})
|
|
29
|
+
})
|
package/cypress/helper.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
return
|
|
1
|
+
function testPageUrl(testPage: string) {
|
|
2
|
+
const port = Cypress.env('BACKEND_PORT') || '3000';
|
|
3
|
+
const baseUrl = `http://localhost:${port}/glib/json_ui_garage?path=test_page%2F${testPage}`;
|
|
4
|
+
return baseUrl;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export { testPageUrl }
|
package/doc/TESTING.md
CHANGED
|
@@ -13,9 +13,8 @@ The tests hit: `http://localhost:3000/glib/json_ui_garage?path=test_page/{name}`
|
|
|
13
13
|
|
|
14
14
|
### 2. **Integration Testing Philosophy**
|
|
15
15
|
Tests are **full-stack integration tests**, not unit tests:
|
|
16
|
-
- No
|
|
17
|
-
-
|
|
18
|
-
- Tests verify the entire request-response cycle
|
|
16
|
+
- No mocking/stubbing of glib API requests — the backend must be running and tests verify the entire request-response cycle
|
|
17
|
+
- Stubbing external third-party services (e.g., reCAPTCHA) is acceptable since they cannot run in a test environment
|
|
19
18
|
- Form submissions are verified by backend rendering the data back
|
|
20
19
|
|
|
21
20
|
This is fundamentally different from typical frontend testing (Jest/Vitest with mocks).
|
|
@@ -55,6 +54,9 @@ You **cannot write frontend tests in isolation**.
|
|
|
55
54
|
|
|
56
55
|
These 6 concepts explain **why the test structure exists** and **what you need to get started**. Everything else (selectors, assertions, etc.) is standard Cypress mechanics.
|
|
57
56
|
|
|
57
|
+
### 7. **Do Not Run `bin/vite dev` Separately During Cypress Tests**
|
|
58
|
+
Running the Vite dev server separately (`bin/vite dev`) while running Cypress tests causes blank pages (Vue app fails to mount). Just use the Rails server alone — it handles Vite compilation internally.
|
|
59
|
+
|
|
58
60
|
## Cypress Coverage Workflow
|
|
59
61
|
|
|
60
62
|
- **Prerequisites**: Keep the backend `glib-web` server running with the matching `test_page/*` fixtures (same as normal Cypress runs) and make sure the frontend package is linked into that app: `yarn link glib-web` (run from the backend repo).
|