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.
@@ -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
 
@@ -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="spec.maxLength || 255" :readonly="spec.readOnly" :height="height"
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" :value="fieldModel" :disabled="inputDisabled" v-if="loadIf" />
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
- methods: {
19
- $ready() {
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
- this.fieldModel = hash;
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
- const DEV_TEST_PAGE = 'http://localhost:3000/glib/json_ui_garage?path=test_page%2F{{testPage}}'
2
-
3
- function testPageUrl(testPage) {
4
- return DEV_TEST_PAGE.replace('{{testPage}}', testPage);
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 HTTP mocking/stubbing
17
- - Backend must be running (`localhost:3000`)
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).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "glib-web",
4
- "version": "4.44.0",
4
+ "version": "4.44.2",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "scripts": {