diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json index bce7446f..c513f23a 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/scripting-revamp-coll.json @@ -70,7 +70,7 @@ "params": [], "headers": [], "preRequestScript": "export {};\nhopp.env.set('test_key', 'test_value')\nhopp.env.set('recursive_key', '<>')\nhopp.env.global.set('global_key', 'global_value')\nhopp.env.active.set('active_key', 'active_value')\n\n// `pm` namespace equivalents\npm.variables.set('pm_test_key', 'pm_test_value')\npm.environment.set('pm_active_key', 'pm_active_value')\npm.globals.set('pm_global_key', 'pm_global_value')\n", - "testScript": "export {};\n\nhopp.test('`hopp.env.get()` retrieves environment variables', () => {\n const value = hopp.env.get('test_key')\n hopp.expect(value).toBe('test_value')\n})\n\npm.test('`pm.variables.get()` retrieves environment variables', () => {\n const value = pm.variables.get('test_key')\n pm.expect(value).toBe('test_value')\n})\n\nhopp.test('`hopp.env.getRaw()` retrieves raw environment variables without resolution', () => {\n const rawValue = hopp.env.getRaw('recursive_key')\n hopp.expect(rawValue).toBe('<>')\n})\n\nhopp.test('`hopp.env.get()` resolves recursive environment variables', () => {\n const resolvedValue = hopp.env.get('recursive_key')\n hopp.expect(resolvedValue).toBe('test_value')\n})\n\npm.test('`pm.variables.replaceIn()` resolves template variables', () => {\n const resolved = pm.variables.replaceIn('Value is {{test_key}}')\n pm.expect(resolved).toBe('Value is test_value')\n})\n\nhopp.test('`hopp.env.global.get()` retrieves global environment variables', () => {\n const globalValue = hopp.env.global.get('global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (globalValue) {\n hopp.expect(globalValue).toBe('global_value')\n }\n})\n\npm.test('`pm.globals.get()` retrieves global environment variables', () => {\n const globalValue = pm.globals.get('global_key')\n\n // `pm.globals` would be empty for the CLI\n if (globalValue) {\n pm.expect(globalValue).toBe('global_value')\n }\n})\n\nhopp.test('`hopp.env.active.get()` retrieves active environment variables', () => {\n const activeValue = hopp.env.active.get('active_key')\n hopp.expect(activeValue).toBe('active_value')\n})\n\npm.test('`pm.environment.get()` retrieves active environment variables', () => {\n const activeValue = pm.environment.get('active_key')\n pm.expect(activeValue).toBe('active_value')\n})\n\nhopp.test('Environment methods return null for non-existent keys', () => {\n hopp.expect(hopp.env.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.getRaw('non_existent')).toBe(null)\n hopp.expect(hopp.env.global.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.active.get('non_existent')).toBe(null)\n})\n\npm.test('`pm` environment methods handle non-existent keys correctly', () => {\n pm.expect(pm.variables.get('non_existent')).toBe(null)\n pm.expect(pm.environment.get('non_existent')).toBe(null)\n pm.expect(pm.globals.get('non_existent')).toBe(null)\n pm.expect(pm.variables.has('non_existent')).toBe(false)\n pm.expect(pm.environment.has('non_existent')).toBe(false)\n pm.expect(pm.globals.has('non_existent')).toBe(false)\n})\n\npm.test('`pm` variables set in pre-request script are accessible', () => {\n pm.expect(pm.variables.get('pm_test_key')).toBe('pm_test_value')\n pm.expect(pm.environment.get('pm_active_key')).toBe('pm_active_value')\n\n const pmGlobalValue = hopp.env.global.get('pm_global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (pmGlobalValue) {\n hopp.expect(pmGlobalValue).toBe('pm_global_value')\n }\n})\n", + "testScript": "export {};\n\nhopp.test('`hopp.env.get()` retrieves environment variables', () => {\n const value = hopp.env.get('test_key')\n hopp.expect(value).toBe('test_value')\n})\n\npm.test('`pm.variables.get()` retrieves environment variables', () => {\n const value = pm.variables.get('test_key')\n pm.expect(value).toBe('test_value')\n})\n\nhopp.test('`hopp.env.getRaw()` retrieves raw environment variables without resolution', () => {\n const rawValue = hopp.env.getRaw('recursive_key')\n hopp.expect(rawValue).toBe('<>')\n})\n\nhopp.test('`hopp.env.get()` resolves recursive environment variables', () => {\n const resolvedValue = hopp.env.get('recursive_key')\n hopp.expect(resolvedValue).toBe('test_value')\n})\n\npm.test('`pm.variables.replaceIn()` resolves template variables', () => {\n const resolved = pm.variables.replaceIn('Value is {{test_key}}')\n pm.expect(resolved).toBe('Value is test_value')\n})\n\nhopp.test('`hopp.env.global.get()` retrieves global environment variables', () => {\n const globalValue = hopp.env.global.get('global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (globalValue) {\n hopp.expect(globalValue).toBe('global_value')\n }\n})\n\npm.test('`pm.globals.get()` retrieves global environment variables', () => {\n const globalValue = pm.globals.get('global_key')\n\n // `pm.globals` would be empty for the CLI\n if (globalValue) {\n pm.expect(globalValue).toBe('global_value')\n }\n})\n\nhopp.test('`hopp.env.active.get()` retrieves active environment variables', () => {\n const activeValue = hopp.env.active.get('active_key')\n hopp.expect(activeValue).toBe('active_value')\n})\n\npm.test('`pm.environment.get()` retrieves active environment variables', () => {\n const activeValue = pm.environment.get('active_key')\n pm.expect(activeValue).toBe('active_value')\n})\n\nhopp.test('Environment methods return null for non-existent keys', () => {\n hopp.expect(hopp.env.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.getRaw('non_existent')).toBe(null)\n hopp.expect(hopp.env.global.get('non_existent')).toBe(null)\n hopp.expect(hopp.env.active.get('non_existent')).toBe(null)\n})\n\npm.test('`pm` environment methods handle non-existent keys correctly', () => {\n pm.expect(pm.variables.get('non_existent')).toBe(undefined)\n pm.expect(pm.environment.get('non_existent')).toBe(undefined)\n pm.expect(pm.globals.get('non_existent')).toBe(undefined)\n pm.expect(pm.variables.has('non_existent')).toBe(false)\n pm.expect(pm.environment.has('non_existent')).toBe(false)\n pm.expect(pm.globals.has('non_existent')).toBe(false)\n})\n\npm.test('`pm` variables set in pre-request script are accessible', () => {\n pm.expect(pm.variables.get('pm_test_key')).toBe('pm_test_value')\n pm.expect(pm.environment.get('pm_active_key')).toBe('pm_active_value')\n\n const pmGlobalValue = hopp.env.global.get('pm_global_key')\n\n // `hopp.env.global` would be empty for the CLI\n if (pmGlobalValue) {\n hopp.expect(pmGlobalValue).toBe('pm_global_value')\n }\n})\n", "auth": { "authType": "inherit", "authActive": true @@ -133,7 +133,7 @@ } ], "preRequestScript": "export {};\n", - "testScript": "export {};\n\nhopp.test('`hopp.response.statusCode` returns the response status code', () => {\n hopp.expect(hopp.response.statusCode).toBe(200)\n})\n\npm.test('`pm.response.code` returns the response status code', () => {\n pm.expect(pm.response.code).toBe(200)\n})\n\nhopp.test('`hopp.response.statusText` returns the response status text', () => {\n hopp.expect(hopp.response.statusText).toBeType('string')\n})\n\npm.test('`pm.response.status` returns the response status text', () => {\n pm.expect(pm.response.status).toBeType('string')\n})\n\nhopp.test('`hopp.response.headers` contains response headers', () => {\n const { headers } = hopp.response\n\n hopp.expect(headers).toBeType('object')\n hopp.expect(headers.length > 0).toBe(true)\n})\n\npm.test('`pm.response.headers` contains response headers', () => {\n const headersAll = pm.response.headers.all()\n pm.expect(headersAll).toBeType('object')\n pm.expect(Object.keys(headersAll).length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.responseTime` is a positive number', () => {\n hopp.expect(hopp.response.responseTime).toBeType('number')\n hopp.expect(hopp.response.responseTime > 0).toBe(true)\n})\n\npm.test('`pm.response.responseTime` is a positive number', () => {\n pm.expect(pm.response.responseTime).toBeType('number')\n pm.expect(pm.response.responseTime > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.text()` returns response as text', () => {\n const responseText = hopp.response.body.asText()\n hopp.expect(responseText).toBeType('string')\n hopp.expect(responseText.length > 0).toBe(true)\n})\n\npm.test('`pm.response.text()` returns response as text', () => {\n const responseText = pm.response.text()\n pm.expect(responseText).toBeType('string')\n pm.expect(responseText.length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.json()` parses JSON response', () => {\n const responseJSON = hopp.response.body.asJSON()\n hopp.expect(responseJSON).toBeType('object')\n})\n\npm.test('`pm.response.json()` parses JSON response', () => {\n const responseJSON = pm.response.json()\n pm.expect(responseJSON).toBeType('object')\n})\n\n\nhopp.test('`hopp.response.bytes()` returns the raw response', () => {\n const responseBuffer = hopp.response.body.bytes()\n hopp.expect(responseBuffer).toBeType('object')\n hopp.expect(responseBuffer.constructor.name).toBe('Object')\n})\n\npm.test('`pm.response.stream` returns the raw response', () => {\n const responseBuffer = pm.response.stream\n pm.expect(responseBuffer).toBeType('object')\n pm.expect(responseBuffer.constructor.name).toBe('Object')\n})", + "testScript": "export {};\n\nhopp.test('`hopp.response.statusCode` returns the response status code', () => {\n hopp.expect(hopp.response.statusCode).toBe(200)\n})\n\npm.test('`pm.response.code` returns the response status code', () => {\n pm.expect(pm.response.code).toBe(200)\n})\n\nhopp.test('`hopp.response.statusText` returns the response status text', () => {\n hopp.expect(hopp.response.statusText).toBeType('string')\n})\n\npm.test('`pm.response.status` returns the response status text', () => {\n pm.expect(pm.response.status).toBeType('string')\n})\n\nhopp.test('`hopp.response.headers` contains response headers', () => {\n const { headers }\u00a0= hopp.response\n\n hopp.expect(headers).toBeType('object')\n hopp.expect(headers.length > 0).toBe(true)\n})\n\npm.test('`pm.response.headers` contains response headers', () => {\n const headersAll = pm.response.headers.all()\n pm.expect(headersAll).toBeType('object')\n pm.expect(Object.keys(headersAll).length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.responseTime` is a positive number', () => {\n hopp.expect(hopp.response.responseTime).toBeType('number')\n hopp.expect(hopp.response.responseTime > 0).toBe(true)\n})\n\npm.test('`pm.response.responseTime` is a positive number', () => {\n pm.expect(pm.response.responseTime).toBeType('number')\n pm.expect(pm.response.responseTime > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.text()` returns response as text', () => {\n const responseText = hopp.response.body.asText()\n hopp.expect(responseText).toBeType('string')\n hopp.expect(responseText.length > 0).toBe(true)\n})\n\npm.test('`pm.response.text()` returns response as text', () => {\n const responseText = pm.response.text()\n pm.expect(responseText).toBeType('string')\n pm.expect(responseText.length > 0).toBe(true)\n})\n\nhopp.test('`hopp.response.json()` parses JSON response', () => {\n const responseJSON = hopp.response.body.asJSON()\n hopp.expect(responseJSON).toBeType('object')\n})\n\npm.test('`pm.response.json()` parses JSON response', () => {\n const responseJSON = pm.response.json()\n pm.expect(responseJSON).toBeType('object')\n})\n\n\nhopp.test('`hopp.response.bytes()` returns the raw response', () => {\n const responseBuffer = hopp.response.body.bytes()\n hopp.expect(responseBuffer).toBeType('object')\n hopp.expect(responseBuffer.constructor.name).toBe('Object')\n})\n\npm.test('`pm.response.stream` returns the raw response', () => {\n const responseBuffer = pm.response.stream\n pm.expect(responseBuffer).toBeType('object')\n pm.expect(responseBuffer.constructor.name).toBe('Object')\n})", "auth": { "authType": "inherit", "authActive": true @@ -191,7 +191,7 @@ "params": [], "headers": [], "preRequestScript": "export {};\n", - "testScript": "export {};\n\npm.test('`pm.info.eventName` indicates the script context', () => {\n pm.expect(pm.info.eventName).toBe('post-request')\n})\n\npm.test('`pm.info.requestName` returns the request name', () => {\n pm.expect(pm.info.requestName).toBe('info-context-test')\n})\n\npm.test('`pm.info.requestId` returns an optional request identifier', () => {\n const requestId = pm.info.requestId\n if (requestId) {\n pm.expect(requestId).toBeType('string')\n pm.expect(requestId?.length > 0).toBe(true)\n } else {\n pm.expect(requestId).toBe(undefined)\n }\n})", + "testScript": "export {};\n\npm.test('`pm.info.eventName` indicates the script context', () => {\n pm.expect(pm.info.eventName).toBe('test')\n})\n\npm.test('`pm.info.requestName` returns the request name', () => {\n pm.expect(pm.info.requestName).toBe('info-context-test')\n})\n\npm.test('`pm.info.requestId` returns an optional request identifier', () => {\n const requestId = pm.info.requestId\n if (requestId) {\n pm.expect(requestId).toBeType('string')\n pm.expect(requestId?.length > 0).toBe(true)\n } else {\n pm.expect(requestId).toBe(undefined)\n }\n})", "auth": { "authType": "inherit", "authActive": true @@ -244,6 +244,550 @@ }, "requestVariables": [], "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00chai1qt0inext01", + "name": "chai-assertions-hopp-extended", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\n// EQUALITY ASSERTIONS\nhopp.test('Chai equality - equal() method', () => {\n hopp.expect(5).to.equal(5)\n hopp.expect('hello').to.equal('hello')\n hopp.expect(true).to.equal(true)\n})\n\nhopp.test('Chai equality - eql() for deep equality', () => {\n hopp.expect({ a: 1 }).to.eql({ a: 1 })\n hopp.expect([1, 2, 3]).to.eql([1, 2, 3])\n})\n\nhopp.test('Chai equality - negation with .not', () => {\n hopp.expect(5).to.not.equal(10)\n hopp.expect('hello').to.not.equal('world')\n})\n\n// TYPE ASSERTIONS\nhopp.test('Chai type - .a() and .an() assertions', () => {\n hopp.expect('test').to.be.a('string')\n hopp.expect(42).to.be.a('number')\n hopp.expect([]).to.be.an('array')\n hopp.expect({}).to.be.an('object')\n})\n\nhopp.test('Chai type - instanceof assertions', () => {\n hopp.expect([1, 2, 3]).to.be.instanceof(Array)\n hopp.expect(new Date()).to.be.instanceof(Date)\n hopp.expect(new Error('test')).to.be.instanceof(Error)\n})\n\n// TRUTHINESS ASSERTIONS\nhopp.test('Chai truthiness - .true, .false, .null, .undefined', () => {\n hopp.expect(true).to.be.true\n hopp.expect(false).to.be.false\n hopp.expect(null).to.be.null\n hopp.expect(undefined).to.be.undefined\n})\n\nhopp.test('Chai truthiness - .ok and .exist', () => {\n hopp.expect(1).to.be.ok\n hopp.expect('string').to.exist\n hopp.expect(0).to.not.be.ok\n})\n\nhopp.test('Chai truthiness - .NaN assertion', () => {\n hopp.expect(NaN).to.be.NaN\n hopp.expect(42).to.not.be.NaN\n})\n\n// NUMERICAL COMPARISONS\nhopp.test('Chai numbers - .above() and .below()', () => {\n hopp.expect(10).to.be.above(5)\n hopp.expect(5).to.be.below(10)\n hopp.expect(5).to.not.be.above(10)\n})\n\nhopp.test('Chai numbers - aliases gt, lt, gte, lte', () => {\n hopp.expect(10).to.be.gt(5)\n hopp.expect(5).to.be.lt(10)\n hopp.expect(5).to.be.gte(5)\n hopp.expect(5).to.be.lte(5)\n})\n\nhopp.test('Chai numbers - .least() and .most()', () => {\n hopp.expect(10).to.be.at.least(10)\n hopp.expect(10).to.be.at.most(10)\n hopp.expect(15).to.be.at.least(10)\n})\n\nhopp.test('Chai numbers - .within() range', () => {\n hopp.expect(7).to.be.within(5, 10)\n hopp.expect(5).to.be.within(5, 10)\n hopp.expect(10).to.be.within(5, 10)\n})\n\nhopp.test('Chai numbers - .closeTo() with delta', () => {\n hopp.expect(10).to.be.closeTo(10.5, 0.6)\n hopp.expect(9.99).to.be.closeTo(10, 0.1)\n})\n\n// PROPERTY ASSERTIONS\nhopp.test('Chai properties - .property() checks', () => {\n const obj = { name: 'test', nested: { value: 42 } }\n hopp.expect(obj).to.have.property('name')\n hopp.expect(obj).to.have.property('name', 'test')\n hopp.expect(obj).to.have.nested.property('nested.value', 42)\n})\n\nhopp.test('Chai properties - .ownProperty() checks', () => {\n const obj = { own: 'value' }\n hopp.expect(obj).to.have.ownProperty('own')\n hopp.expect(obj).to.not.have.ownProperty('toString')\n})\n\n// LENGTH ASSERTIONS\nhopp.test('Chai length - .lengthOf() for arrays and strings', () => {\n hopp.expect([1, 2, 3]).to.have.lengthOf(3)\n hopp.expect('hello').to.have.lengthOf(5)\n hopp.expect([]).to.have.lengthOf(0)\n})\n\n// COLLECTION ASSERTIONS\nhopp.test('Chai collections - .keys() assertions', () => {\n const obj = { a: 1, b: 2, c: 3 }\n hopp.expect(obj).to.have.keys('a', 'b', 'c')\n hopp.expect(obj).to.have.all.keys('a', 'b', 'c')\n hopp.expect(obj).to.have.any.keys('a', 'd')\n})\n\nhopp.test('Chai collections - .members() for arrays', () => {\n hopp.expect([1, 2, 3]).to.have.members([3, 2, 1])\n hopp.expect([1, 2, 3]).to.include.members([1, 2])\n})\n\nhopp.test('Chai collections - .deep.members() for object arrays', () => {\n hopp.expect([{ a: 1 }, { b: 2 }]).to.have.deep.members([{ b: 2 }, { a: 1 }])\n})\n\nhopp.test('Chai collections - .oneOf() checks', () => {\n hopp.expect(2).to.be.oneOf([1, 2, 3])\n hopp.expect('a').to.be.oneOf(['a', 'b', 'c'])\n})\n\n// INCLUSION ASSERTIONS\nhopp.test('Chai inclusion - .include() for arrays and strings', () => {\n hopp.expect([1, 2, 3]).to.include(2)\n hopp.expect('hello world').to.include('world')\n})\n\nhopp.test('Chai inclusion - .deep.include() for objects', () => {\n hopp.expect([{ a: 1 }, { b: 2 }]).to.deep.include({ a: 1 })\n})\n\n// FUNCTION/ERROR ASSERTIONS\nhopp.test('Chai functions - .throw() assertions', () => {\n const throwFn = () => { throw new Error('test error') }\n const noThrow = () => { return 42 }\n \n hopp.expect(throwFn).to.throw()\n hopp.expect(throwFn).to.throw(Error)\n hopp.expect(throwFn).to.throw('test error')\n hopp.expect(noThrow).to.not.throw()\n})\n\nhopp.test('Chai functions - .respondTo() method checks', () => {\n const obj = { method: function() {} }\n hopp.expect(obj).to.respondTo('method')\n hopp.expect([]).to.respondTo('push')\n})\n\nhopp.test('Chai functions - .satisfy() custom matcher', () => {\n hopp.expect(10).to.satisfy((num) => num > 5)\n hopp.expect('hello').to.satisfy((str) => str.length === 5)\n})\n\n// OBJECT STATE ASSERTIONS\nhopp.test('Chai object state - .sealed, .frozen, .extensible', () => {\n const sealed = Object.seal({ a: 1 })\n const frozen = Object.freeze({ b: 2 })\n const extensible = { c: 3 }\n \n hopp.expect(sealed).to.be.sealed\n hopp.expect(frozen).to.be.frozen\n hopp.expect(extensible).to.be.extensible\n})\n\nhopp.test('Chai number state - .finite', () => {\n hopp.expect(42).to.be.finite\n hopp.expect(Infinity).to.not.be.finite\n})\n\n// EXOTIC OBJECTS\nhopp.test('Chai exotic - Set assertions', () => {\n const mySet = new Set([1, 2, 3])\n hopp.expect(mySet).to.be.instanceof(Set)\n hopp.expect(mySet).to.have.lengthOf(3)\n})\n\nhopp.test('Chai exotic - Map assertions', () => {\n const myMap = new Map([['key', 'value']])\n hopp.expect(myMap).to.be.instanceof(Map)\n hopp.expect(myMap).to.have.lengthOf(1)\n})\n\n// SIDE-EFFECT ASSERTIONS\nhopp.test('Chai side-effects - .change() assertions', () => {\n const obj = { count: 0 }\n const changeFn = () => { obj.count = 5 }\n hopp.expect(changeFn).to.change(obj, 'count')\n \n const noChangeFn = () => {} \n hopp.expect(noChangeFn).to.not.change(obj, 'count')\n})\n\nhopp.test('Chai side-effects - .change().by() delta', () => {\n const obj = { count: 10 }\n const addFive = () => { obj.count += 5 }\n hopp.expect(addFive).to.change(obj, 'count').by(5)\n})\n\nhopp.test('Chai side-effects - .increase() assertions', () => {\n const obj = { count: 0 }\n const incFn = () => { obj.count++ }\n hopp.expect(incFn).to.increase(obj, 'count')\n})\n\nhopp.test('Chai side-effects - .decrease() assertions', () => {\n const obj = { count: 10 }\n const decFn = () => { obj.count-- }\n hopp.expect(decFn).to.decrease(obj, 'count')\n})\n\n// LANGUAGE CHAINS AND MODIFIERS\nhopp.test('Chai chains - Complex chaining with multiple modifiers', () => {\n hopp.expect([1, 2, 3]).to.be.an('array').that.includes(2)\n hopp.expect({ a: 1, b: 2 }).to.be.an('object').that.has.property('a')\n})\n\nhopp.test('Chai modifiers - .deep with .equal()', () => {\n hopp.expect({ a: { b: 1 } }).to.deep.equal({ a: { b: 1 } })\n hopp.expect([{ a: 1 }]).to.deep.equal([{ a: 1 }])\n})\n\n// RESPONSE-BASED TESTS\nhopp.test('Chai with response - status code checks', () => {\n hopp.expect(hopp.response.statusCode).to.equal(200)\n hopp.expect(hopp.response.statusCode).to.be.within(200, 299)\n})\n\nhopp.test('Chai with response - body parsing', () => {\n const response = hopp.response.body.asJSON()\n hopp.expect(response).to.be.an('object')\n hopp.expect(response).to.have.property('data')\n \n const body = JSON.parse(response.data)\n hopp.expect(body).to.have.property('testData')\n hopp.expect(body.testData).to.have.property('number', 42)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"testData\": {\n \"number\": 42,\n \"string\": \"hello world\",\n \"array\": [1, 2, 3, 4, 5],\n \"object\": { \"nested\": { \"value\": true } },\n \"bool\": true,\n \"nullValue\": null\n }\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00chai2qt0inext02", + "name": "chai-assertions-pm-parity", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('PM Chai - equality assertions', () => {\n pm.expect(5).to.equal(5)\n pm.expect('test').to.not.equal('other')\n pm.expect({ a: 1 }).to.eql({ a: 1 })\n})\n\npm.test('PM Chai - type assertions', () => {\n pm.expect('string').to.be.a('string')\n pm.expect(42).to.be.a('number')\n pm.expect([]).to.be.an('array')\n})\n\npm.test('PM Chai - truthiness assertions', () => {\n pm.expect(true).to.be.true\n pm.expect(false).to.be.false\n pm.expect(null).to.be.null\n})\n\npm.test('PM Chai - numerical comparisons', () => {\n pm.expect(10).to.be.above(5)\n pm.expect(5).to.be.below(10)\n pm.expect(7).to.be.within(5, 10)\n})\n\npm.test('PM Chai - property and length assertions', () => {\n const obj = { name: 'test' }\n pm.expect(obj).to.have.property('name')\n pm.expect([1, 2, 3]).to.have.lengthOf(3)\n pm.expect('hello').to.have.lengthOf(5)\n})\n\npm.test('PM Chai - string and collection assertions', () => {\n pm.expect('hello world').to.include('world')\n pm.expect([1, 2, 3]).to.include(2)\n pm.expect({ a: 1, b: 2 }).to.have.keys('a', 'b')\n})\n\npm.test('PM Chai - function assertions', () => {\n const throwFn = () => { throw new Error('test') }\n pm.expect(throwFn).to.throw()\n pm.expect([]).to.respondTo('push')\n})\n\npm.test('PM Chai - response validation', () => {\n pm.expect(pm.response.code).to.equal(200)\n pm.expect(pm.response.responseTime).to.be.a('number')\n \n const response = pm.response.json()\n pm.expect(response).to.have.property('data')\n \n const body = JSON.parse(response.data)\n pm.expect(body).to.have.property('pmTest')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"pmTest\": {\n \"value\": 42,\n \"text\": \"postman compatible\"\n }\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00cookies01", + "name": "cookie-assertions-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Cookie", + "value": "session_id=abc123; user_token=xyz789; preferences=theme%3Ddark", + "active": true, + "description": "Test cookies" + } + ], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\n// NOTE: Full cookie behavior with Set-Cookie headers is tested in js-sandbox unit tests\n// (see packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts)\n// These CLI E2E tests verify API contracts and integration behavior\n\npm.test('pm.response.cookies API contract - all methods exist', () => {\n pm.expect(pm.response.cookies).to.be.an('object')\n pm.expect(typeof pm.response.cookies.get).to.equal('function')\n pm.expect(typeof pm.response.cookies.has).to.equal('function')\n pm.expect(typeof pm.response.cookies.toObject).to.equal('function')\n})\n\npm.test('pm.response.cookies.toObject() returns proper structure', () => {\n const allCookies = pm.response.cookies.toObject()\n pm.expect(allCookies).to.be.an('object')\n pm.expect(typeof allCookies).to.equal('object')\n})\n\npm.test('pm.response.cookies.has() returns boolean for cookie checks', () => {\n const hasCookie = pm.response.cookies.has('test_cookie_name')\n pm.expect(hasCookie).to.be.a('boolean')\n})\n\npm.test('pm.response.cookies.get() returns null for non-existent cookies', () => {\n const cookieValue = pm.response.cookies.get('non_existent_cookie_xyz')\n pm.expect(cookieValue).to.be.null\n})\n\npm.test('pm.response.cookies API integrates with response object', () => {\n pm.expect(pm.response.code).to.equal(200)\n \n // Verify cookies object is accessible from response\n pm.expect(pm.response).to.have.property('cookies')\n pm.expect(pm.response.cookies).to.not.be.null\n pm.expect(pm.response.cookies).to.not.be.undefined\n})\n\npm.test('Request cookies are properly sent via Cookie header', () => {\n const hasCookieHeader = pm.request.headers.has('Cookie')\n \n if (hasCookieHeader) {\n const cookieHeader = pm.request.headers.get('Cookie')\n pm.expect(cookieHeader).to.be.a('string')\n pm.expect(cookieHeader).to.include('session_id')\n pm.expect(cookieHeader).to.include('user_token')\n }\n})\n\npm.test('pm.response.to.have.cookie() assertion method exists', () => {\n // Verify the cookie assertion is defined in the type system\n pm.expect(typeof pm.response.to.have.cookie).to.equal('function')\n})\n\nhopp.test('hopp.cookies API contract matches pm.response.cookies', () => {\n hopp.expect(typeof hopp.cookies).toBe('object')\n hopp.expect(typeof hopp.cookies.get).toBe('function')\n hopp.expect(typeof hopp.cookies.has).toBe('function')\n hopp.expect(typeof hopp.cookies.getAll).toBe('function')\n hopp.expect(typeof hopp.cookies.set).toBe('function')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00schema01", + "name": "json-schema-validation-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.response.to.have.jsonSchema() validates response structure', () => {\n const schema = {\n type: 'object',\n required: ['data'],\n properties: {\n data: { type: 'string' },\n headers: { type: 'object' }\n }\n }\n \n pm.response.to.have.jsonSchema(schema)\n \n // Explicit assertions to ensure schema validation passed\n const json = pm.response.json()\n pm.expect(json).to.have.property('data')\n pm.expect(json.data).to.be.a('string')\n})\n\npm.test('JSON Schema validation with nested properties', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n const userSchema = {\n type: 'object',\n required: ['name', 'age'],\n properties: {\n name: { type: 'string' },\n age: { type: 'number', minimum: 0, maximum: 150 },\n email: { type: 'string' }\n }\n }\n \n pm.expect(body).to.have.jsonSchema(userSchema)\n \n // Explicit assertions to ensure schema validation passed\n pm.expect(body).to.have.property('name')\n pm.expect(body).to.have.property('age')\n pm.expect(body.name).to.equal('Alice Smith')\n pm.expect(body.age).to.equal(28)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"name\": \"Alice Smith\",\n \"age\": 28,\n \"email\": \"alice@example.com\"\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00charset01", + "name": "charset-validation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\n// NOTE: Full charset behavior with actual charset values is tested in js-sandbox unit tests\n// (see packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts)\n// These CLI E2E tests verify API contracts and header parsing behavior\n\npm.test('pm.expect().to.have.charset() assertion API contract exists', () => {\n const testString = 'test'\n pm.expect(typeof pm.expect(testString).to.have.charset).to.equal('function')\n})\n\npm.test('pm.response.to.have.charset() assertion API contract exists', () => {\n pm.expect(typeof pm.response.to.have.charset).to.equal('function')\n})\n\npm.test('Response Content-Type header is accessible and parseable', () => {\n const contentType = pm.response.headers.get('content-type')\n pm.expect(contentType).to.be.a('string')\n pm.expect(contentType.length).to.be.above(0)\n pm.expect(contentType).to.include('application/')\n})\n\npm.test('Content-Type header parsing logic validates structure', () => {\n const contentType = pm.response.headers.get('content-type')\n \n // Test charset detection logic\n const hasCharset = contentType.includes('charset=')\n pm.expect(typeof hasCharset).to.equal('boolean')\n \n // Test charset extraction pattern\n const charsetMatch = contentType.match(/charset=([^;\\s]+)/i)\n if (hasCharset) {\n pm.expect(charsetMatch).to.be.an('array')\n pm.expect(charsetMatch[1]).to.be.a('string')\n } else {\n pm.expect(charsetMatch).to.be.null\n }\n})\n\npm.test('Charset handling works with or without explicit charset', () => {\n const contentType = pm.response.headers.get('content-type')\n const hasExplicitCharset = contentType.toLowerCase().includes('charset=')\n \n // Whether charset is present or not, response decoding should work\n const responseText = pm.response.text()\n pm.expect(responseText).to.be.a('string')\n pm.expect(responseText.length).to.be.above(0)\n})\n\npm.test('Response text decoding works with UTF-8 default', () => {\n const responseText = pm.response.text()\n pm.expect(responseText).to.be.a('string')\n \n // Verify JSON parsing works (implies correct encoding)\n const responseJson = pm.response.json()\n pm.expect(responseJson).to.be.an('object')\n pm.expect(responseJson).to.have.property('data')\n})\n\npm.test('Response headers integrate correctly with charset assertions', () => {\n const allHeaders = pm.response.headers.all()\n pm.expect(allHeaders).to.be.an('object')\n pm.expect(Object.keys(allHeaders).length).to.be.above(0)\n})\n\nhopp.test('hopp namespace handles response encoding with proper defaults', () => {\n const textResponse = hopp.response.body.asText()\n hopp.expect(textResponse).toBeType('string')\n hopp.expect(textResponse.length > 0).toBe(true)\n \n // Verify JSON parsing works with default encoding\n const jsonResponse = hopp.response.body.asJSON()\n hopp.expect(jsonResponse).toBeType('object')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00jsonpath01", + "name": "jsonpath-query-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.response.to.have.jsonPath() queries nested JSON data', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n pm.expect(body).to.have.jsonPath('$.users[0].name')\n pm.expect(body).to.have.jsonPath('$.users[*].active')\n pm.expect(body).to.have.jsonPath('$.metadata.version')\n})\n\npm.test('JSONPath with value validation', () => {\n const response = pm.response.json()\n const body = JSON.parse(response.data)\n \n pm.expect(body).to.have.jsonPath('$.users[0].name', 'John')\n pm.expect(body).to.have.jsonPath('$.metadata.version', '1.0')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"users\": [\n { \"name\": \"John\", \"active\": true },\n { \"name\": \"Jane\", \"active\": false }\n ],\n \"metadata\": {\n \"version\": \"1.0\",\n \"timestamp\": \"2025-01-15\"\n }\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00envext01", + "name": "environment-extensions-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\npm.environment.set('template_var', 'world')\npm.environment.set('user_id', '12345')\npm.globals.set('api_base', 'https://api.example.com')\npm.globals.set('version', 'v2')\n", + "testScript": "export {};\n\npm.test('pm.environment.name returns environment identifier', () => {\n pm.expect(pm.environment.name).to.be.a('string')\n pm.expect(pm.environment.name).to.equal('active')\n})\n\npm.test('pm.environment.replaceIn() resolves template variables', () => {\n const template = 'Hello {{template_var}}, user {{user_id}}!'\n const resolved = pm.environment.replaceIn(template)\n pm.expect(resolved).to.equal('Hello world, user 12345!')\n})\n\npm.test('pm.globals.replaceIn() resolves global template variables', () => {\n const template = '{{api_base}}/{{version}}/users'\n const resolved = pm.globals.replaceIn(template)\n pm.expect(resolved).to.equal('https://api.example.com/v2/users')\n})\n\npm.test('pm.environment.toObject() returns all environment variables', () => {\n const allVars = pm.environment.toObject()\n pm.expect(allVars).to.be.an('object')\n pm.expect(allVars).to.have.property('template_var', 'world')\n pm.expect(allVars).to.have.property('user_id', '12345')\n})\n\npm.test('pm.globals.toObject() returns all global variables', () => {\n const allGlobals = pm.globals.toObject()\n pm.expect(allGlobals).to.be.an('object')\n \n // globals might be empty in CLI context\n if (Object.keys(allGlobals).length > 0) {\n pm.expect(allGlobals).to.have.property('api_base')\n }\n})\n\npm.test('pm.variables.toObject() returns combined variables with precedence', () => {\n const allVariables = pm.variables.toObject()\n pm.expect(allVariables).to.be.an('object')\n pm.expect(allVariables).to.have.property('template_var')\n})\n\npm.test('pm.environment.clear() removes all environment variables', () => {\n pm.environment.clear()\n const clearedVars = pm.environment.toObject()\n pm.expect(Object.keys(clearedVars).length).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00respext01", + "name": "response-extensions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.response.responseSize returns response body size in bytes', () => {\n pm.expect(pm.response.responseSize).to.be.a('number')\n pm.expect(pm.response.responseSize).to.be.above(0)\n})\n\npm.test('pm.response.responseSize matches actual body length', () => {\n const bodyText = pm.response.text()\n // Use the same workaround as pm.response.responseSize for QuickJS\n const encoder = new TextEncoder()\n const encoded = encoder.encode(bodyText)\n // QuickJS represents Uint8Array as object with numeric keys\n const actualSize = encoded && typeof encoded.length === 'number' && encoded.length > 0\n ? encoded.length\n : Object.keys(encoded).filter(k => !isNaN(k)).length\n pm.expect(pm.response.responseSize).to.equal(actualSize)\n})\n\npm.test('Response size is calculated correctly for JSON payload', () => {\n const response = pm.response.json()\n pm.expect(pm.response.responseSize).to.be.a('number')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"message\": \"Testing response size calculation\",\n \"data\": {\n \"items\": [1, 2, 3, 4, 5],\n \"metadata\": {\n \"count\": 5,\n \"type\": \"numeric\"\n }\n }\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00execext01", + "name": "execution-context-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.execution.location provides execution path', () => {\n pm.expect(pm.execution.location).to.be.an('array')\n pm.expect(pm.execution.location.length).to.be.above(0)\n})\n\npm.test('pm.execution.location.current returns current location', () => {\n pm.expect(pm.execution.location.current).to.be.a('string')\n pm.expect(pm.execution.location.current).to.equal('Hoppscotch')\n})\n\npm.test('pm.execution.location is immutable', () => {\n const location = pm.execution.location\n const throwFn = () => { location.push('test') }\n pm.expect(throwFn).to.throw()\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00bddassert01", + "name": "bdd-response-assertions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + } + ], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.response.to.have.status() validates exact status code', () => {\n pm.response.to.have.status(200)\n pm.expect(pm.response.code).to.equal(200)\n})\n\npm.test('pm.response.to.be.ok validates 2xx status codes', () => {\n pm.response.to.be.ok()\n})\n\npm.test('pm.response.to.be.success validates 2xx status codes (alias)', () => {\n pm.response.to.be.success()\n})\n\npm.test('pm.response.to.have.header() validates response headers', () => {\n pm.response.to.have.header('content-type')\n pm.expect(pm.response.headers.has('content-type')).to.be.true\n})\n\npm.test('pm.response.to.have.jsonBody() validates JSON response', () => {\n pm.response.to.have.jsonBody()\n pm.response.to.have.jsonBody('data')\n \n const json = pm.response.json()\n pm.expect(json).to.have.property('data')\n})\n\npm.test('pm.response.to.be.json validates JSON content type', () => {\n pm.response.to.be.json()\n})\n\npm.test('pm.response.to.have.responseTime assertions', () => {\n pm.response.to.have.responseTime.below(5000)\n pm.expect(pm.response.responseTime).to.be.a('number')\n pm.expect(pm.response.responseTime).to.be.above(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"test\": \"BDD assertions\",\n \"status\": \"success\"\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00includecontain01", + "name": "include-contain-assertions-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.expect().to.include() validates string inclusion', () => {\n pm.expect('hello world').to.include('world')\n pm.expect('hello world').to.include('hello')\n pm.expect('test string').to.not.include('missing')\n})\n\npm.test('pm.expect().to.contain() validates array inclusion', () => {\n pm.expect([1, 2, 3]).to.contain(2)\n pm.expect([1, 2, 3]).to.include(1)\n pm.expect(['a', 'b', 'c']).to.contain('b')\n})\n\npm.test('pm.expect().to.includes() alias works', () => {\n pm.expect('testing').to.includes('test')\n pm.expect([10, 20, 30]).to.includes(20)\n})\n\npm.test('pm.expect().to.contains() alias works', () => {\n pm.expect('contains test').to.contains('contains')\n pm.expect([true, false]).to.contains(true)\n})\n\npm.test('include/contain with response data', () => {\n const response = pm.response.json()\n pm.expect(response).to.have.property('data')\n \n const bodyText = pm.response.text()\n pm.expect(bodyText).to.include('includeTest')\n})\n\nhopp.test('hopp.expect() also supports toInclude()', () => {\n hopp.expect('hopp test').toInclude('hopp')\n hopp.expect([1, 2]).toInclude(1)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"includeTest\": \"This text should be found\",\n \"array\": [1, 2, 3]\n}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00envunsetclear01", + "name": "environment-unset-clear-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\npm.environment.set('to_unset1', 'value1')\npm.environment.set('to_unset2', 'value2')\npm.environment.set('to_clear1', 'clear_value1')\npm.environment.set('to_clear2', 'clear_value2')\npm.environment.set('to_clear3', 'clear_value3')\npm.globals.set('global_to_unset', 'global_value')\npm.globals.set('global_to_clear1', 'global_clear1')\npm.globals.set('global_to_clear2', 'global_clear2')\n", + "testScript": "export {};\n\npm.test('pm.environment.unset() removes specific variables', () => {\n pm.expect(pm.environment.has('to_unset1')).to.be.true\n pm.environment.unset('to_unset1')\n pm.expect(pm.environment.has('to_unset1')).to.be.false\n pm.expect(pm.environment.get('to_unset1')).to.be.undefined\n})\n\npm.test('pm.environment.unset() handles non-existent keys gracefully', () => {\n pm.environment.unset('non_existent_key')\n pm.expect(pm.environment.has('non_existent_key')).to.be.false\n})\n\npm.test('pm.globals.unset() removes specific global variables', () => {\n const hasGlobal = pm.globals.has('global_to_unset')\n if (hasGlobal) {\n pm.globals.unset('global_to_unset')\n pm.expect(pm.globals.has('global_to_unset')).to.be.false\n }\n})\n\npm.test('pm.environment.clear() removes ALL environment variables', () => {\n // Verify variables exist before clear\n pm.expect(pm.environment.has('to_clear1')).to.be.true\n pm.expect(pm.environment.has('to_clear2')).to.be.true\n pm.expect(pm.environment.has('to_clear3')).to.be.true\n \n // Clear all environment variables\n pm.environment.clear()\n \n // Verify ALL variables are removed\n const allVars = pm.environment.toObject()\n pm.expect(Object.keys(allVars).length).to.equal(0)\n pm.expect(pm.environment.has('to_clear1')).to.be.false\n pm.expect(pm.environment.has('to_clear2')).to.be.false\n pm.expect(pm.environment.has('to_clear3')).to.be.false\n})\n\npm.test('pm.globals.clear() removes ALL global variables', () => {\n // Verify globals exist before clear (might be empty in CLI)\n const globalsBeforeClear = pm.globals.toObject()\n \n pm.globals.clear()\n \n // Verify all globals are removed\n const globalsAfterClear = pm.globals.toObject()\n pm.expect(Object.keys(globalsAfterClear).length).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00pmmutate01", + "name": "pm-request-mutation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/original", + "params": [ + { + "key": "original_param", + "value": "original", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Original-Header", + "value": "original", + "active": true, + "description": "" + } + ], + "preRequestScript": "export {};\n// Test PM namespace mutability - URL string assignment\npm.request.url = 'https://echo.hoppscotch.io/mutated-via-string'\n\n// Test method mutation\npm.request.method = 'POST'\n\n// Test header mutations\npm.request.headers.add({ key: 'Added-Header', value: 'added-value' })\npm.request.headers.upsert({ key: 'Original-Header', value: 'mutated-value' })\n\n// Test body mutation via update()\npm.request.body.update({\n mode: 'raw',\n raw: JSON.stringify({ pmMutated: true, timestamp: Date.now() }),\n options: { raw: { language: 'json' } }\n})\n\n// Test auth mutation\npm.request.auth = {\n authType: 'bearer',\n authActive: true,\n token: 'pm-bearer-token-123'\n}\n", + "testScript": "export {};\n\npm.test('pm.request.url string assignment was applied', () => {\n const urlString = pm.request.url.toString()\n pm.expect(urlString).to.include('/mutated-via-string')\n pm.expect(urlString).to.not.include('/original')\n})\n\npm.test('pm.request.method mutation was applied', () => {\n pm.expect(pm.request.method).to.equal('POST')\n pm.expect(pm.request.method).to.not.equal('GET')\n})\n\npm.test('pm.request.headers.add() added new header', () => {\n pm.expect(pm.request.headers.has('Added-Header')).to.be.true\n pm.expect(pm.request.headers.get('Added-Header')).to.equal('added-value')\n})\n\npm.test('pm.request.headers.upsert() updated existing header', () => {\n pm.expect(pm.request.headers.has('Original-Header')).to.be.true\n pm.expect(pm.request.headers.get('Original-Header')).to.equal('mutated-value')\n pm.expect(pm.request.headers.get('Original-Header')).to.not.equal('original')\n})\n\npm.test('pm.request.body.update() changed body content', () => {\n pm.expect(pm.request.body.contentType).to.equal('application/json')\n const bodyString = typeof pm.request.body.body === 'string' \n ? pm.request.body.body \n : JSON.stringify(pm.request.body.body)\n pm.expect(bodyString).to.include('pmMutated')\n const bodyData = JSON.parse(bodyString)\n pm.expect(bodyData.pmMutated).to.be.true\n})\n\npm.test('pm.request.auth mutation was applied', () => {\n pm.expect(pm.request.auth.authType).to.equal('bearer')\n pm.expect(pm.request.auth.token).to.equal('pm-bearer-token-123')\n})\n\npm.test('pm.request.id and pm.request.name are accessible', () => {\n pm.expect(pm.request.id).to.be.a('string')\n pm.expect(pm.request.id.length).to.be.above(0)\n pm.expect(pm.request.name).to.equal('pm-request-mutation-test')\n})\n\nhopp.test('hopp.request reflects pm namespace mutations', () => {\n hopp.expect(hopp.request.url).toInclude('/mutated-via-string')\n hopp.expect(hopp.request.method).toBe('POST')\n const hasAddedHeader = hopp.request.headers.some(h => h.key === 'Added-Header')\n hopp.expect(hasAddedHeader).toBe(true)\n})\n", + "auth": { + "authType": "none", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00urlmutate01", + "name": "pm-url-property-mutations-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/original?old=value", + "params": [], + "headers": [], + "preRequestScript": "export {};\n// Test URL object property mutations\npm.request.url.protocol = 'http'\npm.request.url.host = ['echo', 'hoppscotch', 'io']\npm.request.url.path = ['v2', 'test']\npm.request.url.port = '443'\npm.request.url.query.add({ key: 'new', value: 'param' })\npm.request.url.query.remove('old')\n", + "testScript": "export {};\n\npm.test('URL protocol mutation works', () => {\n const url = pm.request.url\n pm.expect(url.protocol).to.equal('http')\n pm.expect(url.toString()).to.include('http://')\n})\n\npm.test('URL host mutation works', () => {\n const url = pm.request.url\n pm.expect(url.host).to.be.an('array')\n pm.expect(url.host.join('.')).to.equal('echo.hoppscotch.io')\n pm.expect(url.toString()).to.include('echo.hoppscotch.io')\n})\n\npm.test('URL path mutation works', () => {\n const url = pm.request.url\n pm.expect(url.path).to.be.an('array')\n pm.expect(url.path).to.include('v2')\n pm.expect(url.path).to.include('test')\n pm.expect(url.toString()).to.include('/v2/test')\n})\n\npm.test('URL query.add() adds parameter', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.have.property('new', 'param')\n})\n\npm.test('URL query.remove() removes parameter', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.not.have.property('old')\n})\n\npm.test('Full URL reflects all mutations', () => {\n const fullUrl = pm.request.url.toString()\n pm.expect(fullUrl).to.include('http://')\n pm.expect(fullUrl).to.include('echo.hoppscotch.io')\n pm.expect(fullUrl).to.include('/v2/test')\n pm.expect(fullUrl).to.include('new=param')\n pm.expect(fullUrl).to.not.include('old=value')\n})\n\nhopp.test('hopp.request reflects URL mutations', () => {\n hopp.expect(hopp.request.url).toInclude('echo.hoppscotch.io')\n hopp.expect(hopp.request.url).toInclude('/v2/test')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00unsupported01", + "name": "unsupported-features-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\npm.test('pm.require() throws descriptive error', () => {\n const throwFn = () => pm.require('lodash')\n pm.expect(throwFn).to.throw()\n pm.expect(throwFn).to.throw(/not supported in Hoppscotch/)\n})\n\npm.test('pm.execution.runRequest() throws descriptive error', () => {\n const throwFn = () => pm.execution.runRequest()\n pm.expect(throwFn).to.throw()\n pm.expect(throwFn).to.throw(/Collection Runner feature/)\n})\n\npm.test('pm.collectionVariables.replaceIn() throws descriptive error', () => {\n const throwFn = () => pm.collectionVariables.replaceIn('{{test}}')\n pm.expect(throwFn).to.throw()\n pm.expect(throwFn).to.throw(/Workspace feature/)\n})\n\npm.test('pm.iterationData.toJSON() throws descriptive error', () => {\n const throwFn = () => pm.iterationData.toJSON()\n pm.expect(throwFn).to.throw()\n pm.expect(throwFn).to.throw(/Collection Runner feature/)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "cmfhzf0op00urlpropertylist01", + "name": "url-propertylist-helpers-test", + "method": "GET", + "endpoint": "https://api.example.com:8080/v1/users?filter=active&sort=name&tag=js&tag=ts#section1", + "params": [ + { + "key": "filter", + "value": "active", + "active": true, + "description": "" + }, + { + "key": "sort", + "value": "name", + "active": true, + "description": "" + }, + { + "key": "tag", + "value": "js", + "active": true, + "description": "" + }, + { + "key": "tag", + "value": "ts", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + }, + { + "key": "Authorization", + "value": "Bearer test-token", + "active": true, + "description": "" + } + ], + "preRequestScript": "export {};\n// Test URL helper methods\npm.request.url.update('https://echo.hoppscotch.io/updated?test=value')\npm.request.url.addQueryParams([{ key: 'page', value: '1' }, { key: 'limit', value: '20' }])\npm.request.url.removeQueryParams('test')\n\n// Test hostname and hash properties\npm.request.url.hostname = 'echo.hoppscotch.io'\npm.request.url.hash = 'results'\n\n// Test query PropertyList methods\npm.request.url.query.upsert({ key: 'status', value: 'published' })\npm.request.url.query.add({ key: 'include', value: 'metadata' })\n", + "testScript": "export {};\n\npm.test('URL helper methods - getHost() returns hostname as string', () => {\n const host = pm.request.url.getHost()\n pm.expect(host).to.be.a('string')\n pm.expect(host).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL helper methods - getPath() returns path with leading slash', () => {\n const path = pm.request.url.getPath()\n pm.expect(path).to.be.a('string')\n pm.expect(path).to.include('/')\n pm.expect(path).to.equal('/updated')\n})\n\npm.test('URL helper methods - getPathWithQuery() includes query string', () => {\n const pathWithQuery = pm.request.url.getPathWithQuery()\n pm.expect(pathWithQuery).to.include('?')\n pm.expect(pathWithQuery).to.include('page=1')\n pm.expect(pathWithQuery).to.include('limit=20')\n})\n\npm.test('URL helper methods - getQueryString() returns query without ?', () => {\n const queryString = pm.request.url.getQueryString()\n pm.expect(queryString).to.be.a('string')\n pm.expect(queryString).to.not.include('?')\n pm.expect(queryString).to.include('page=1')\n})\n\npm.test('URL helper methods - getRemote() returns host with port', () => {\n const remote = pm.request.url.getRemote()\n pm.expect(remote).to.be.a('string')\n pm.expect(remote).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL helper methods - update() changes entire URL', () => {\n const url = pm.request.url.toString()\n pm.expect(url).to.include('echo.hoppscotch.io')\n pm.expect(url).to.include('/updated')\n})\n\npm.test('URL helper methods - addQueryParams() adds multiple params', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.have.property('page', '1')\n pm.expect(allParams).to.have.property('limit', '20')\n})\n\npm.test('URL helper methods - removeQueryParams() removes params', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.not.have.property('test')\n})\n\npm.test('URL properties - hostname getter returns string', () => {\n const hostname = pm.request.url.hostname\n pm.expect(hostname).to.be.a('string')\n pm.expect(hostname).to.equal('echo.hoppscotch.io')\n})\n\npm.test('URL properties - hostname matches host array', () => {\n const hostname = pm.request.url.hostname\n const hostString = pm.request.url.host.join('.')\n pm.expect(hostname).to.equal(hostString)\n})\n\npm.test('URL properties - hash getter returns string', () => {\n const hash = pm.request.url.hash\n pm.expect(hash).to.be.a('string')\n // Hash might not persist through URL mutations in E2E context\n})\n\npm.test('Query PropertyList - get() retrieves parameter value', () => {\n const pageValue = pm.request.url.query.get('page')\n pm.expect(pageValue).to.equal('1')\n})\n\npm.test('Query PropertyList - has() checks parameter existence', () => {\n pm.expect(pm.request.url.query.has('page')).to.be.true\n pm.expect(pm.request.url.query.has('nonexistent')).to.be.false\n})\n\npm.test('Query PropertyList - upsert() adds/updates parameter', () => {\n pm.expect(pm.request.url.query.has('status')).to.be.true\n pm.expect(pm.request.url.query.get('status')).to.equal('published')\n})\n\npm.test('Query PropertyList - count() returns parameter count', () => {\n const count = pm.request.url.query.count()\n pm.expect(count).to.be.a('number')\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Query PropertyList - each() iterates over parameters', () => {\n let iterationCount = 0\n pm.request.url.query.each((param) => {\n pm.expect(param).to.have.property('key')\n pm.expect(param).to.have.property('value')\n iterationCount++\n })\n pm.expect(iterationCount).to.be.above(0)\n})\n\npm.test('Query PropertyList - map() transforms parameters', () => {\n const keys = pm.request.url.query.map((param) => param.key)\n pm.expect(keys).to.be.an('array')\n pm.expect(keys).to.include('page')\n pm.expect(keys).to.include('limit')\n})\n\npm.test('Query PropertyList - filter() filters parameters', () => {\n const filtered = pm.request.url.query.filter((param) => param.key === 'page')\n pm.expect(filtered).to.be.an('array')\n pm.expect(filtered.length).to.be.above(0)\n pm.expect(filtered[0].key).to.equal('page')\n})\n\npm.test('Query PropertyList - idx() accesses by index', () => {\n const firstParam = pm.request.url.query.idx(0)\n pm.expect(firstParam).to.be.an('object')\n pm.expect(firstParam).to.have.property('key')\n pm.expect(firstParam).to.have.property('value')\n})\n\npm.test('Query PropertyList - idx() returns null for out of bounds', () => {\n const param = pm.request.url.query.idx(999)\n pm.expect(param).to.be.null\n})\n\npm.test('Query PropertyList - toObject() returns object', () => {\n const obj = pm.request.url.query.toObject()\n pm.expect(obj).to.be.an('object')\n pm.expect(obj).to.have.property('page')\n})\n\npm.test('Headers PropertyList - each() iterates over headers', () => {\n let count = 0\n pm.request.headers.each((header) => {\n pm.expect(header).to.have.property('key')\n pm.expect(header).to.have.property('value')\n count++\n })\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Headers PropertyList - map() transforms headers', () => {\n const keys = pm.request.headers.map((h) => h.key)\n pm.expect(keys).to.be.an('array')\n pm.expect(keys).to.include('Content-Type')\n})\n\npm.test('Headers PropertyList - filter() filters headers', () => {\n const filtered = pm.request.headers.filter((h) => h.key === 'Content-Type')\n pm.expect(filtered).to.be.an('array')\n pm.expect(filtered.length).to.be.above(0)\n})\n\npm.test('Headers PropertyList - count() returns header count', () => {\n const count = pm.request.headers.count()\n pm.expect(count).to.be.a('number')\n pm.expect(count).to.be.above(0)\n})\n\npm.test('Headers PropertyList - idx() accesses by index', () => {\n const firstHeader = pm.request.headers.idx(0)\n pm.expect(firstHeader).to.be.an('object')\n pm.expect(firstHeader).to.have.property('key')\n})\n\npm.test('Headers PropertyList - toObject() returns object', () => {\n const obj = pm.request.headers.toObject()\n pm.expect(obj).to.be.an('object')\n pm.expect(obj).to.have.property('Content-Type')\n})\n\nhopp.test('hopp namespace URL methods work identically', () => {\n const url = hopp.request.url\n hopp.expect(url).toInclude('echo.hoppscotch.io')\n hopp.expect(url).toInclude('/updated')\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "name": "propertylist-advanced-methods-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io/propertylist", + "params": [ + { + "key": "filter", + "value": "active", + "active": true, + "description": "" + }, + { + "key": "sort", + "value": "name", + "active": true, + "description": "" + }, + { + "key": "page", + "value": "1", + "active": true, + "description": "" + } + ], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true, + "description": "" + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true, + "description": "" + } + ], + "preRequestScript": "export {};\n// Test query.insert() - insert limit before page\npm.request.url.query.insert({ key: 'limit', value: '10' }, 'page')\n\n// Test query.append() - add new param at end\npm.request.url.query.append({ key: 'offset', value: '0' })\n\n// Test query.assimilate() - merge params\npm.request.url.query.assimilate({ include: 'metadata', status: 'active' })\n\n// Test headers.insert() - insert before Authorization\npm.request.headers.insert({ key: 'X-API-Key', value: 'secret123' }, 'Authorization')\n\n// Test headers.append() - add at end\npm.request.headers.append({ key: 'X-Request-ID', value: 'req-456' })\n\n// Test headers.assimilate() - merge headers\npm.request.headers.assimilate({ 'X-Custom-Header': 'custom-value' })\n", + "testScript": "export {};\n\npm.test('query.find() - finds param by string key', () => {\n const limitParam = pm.request.url.query.find('limit')\n if (limitParam) {\n pm.expect(limitParam).to.be.an('object')\n pm.expect(limitParam.key).to.equal('limit')\n } else {\n pm.expect(pm.request.url.query.has('limit')).to.be.true\n }\n})\n\npm.test('query.find() - finds param by predicate function', () => {\n const limitParam = pm.request.url.query.find((p) => p && p.key === 'limit')\n if (limitParam) {\n pm.expect(limitParam).to.be.an('object')\n pm.expect(limitParam.value).to.equal('10')\n } else {\n pm.expect(pm.request.url.query.get('limit')).to.equal('10')\n }\n})\n\npm.test('query.find() - returns null when not found', () => {\n const result = pm.request.url.query.find('nonexistent')\n pm.expect(result).to.be.null\n})\n\npm.test('query.indexOf() - returns index for existing params', () => {\n // Verify indexOf works - check params that exist in actual URL\n const allParams = pm.request.url.query.all()\n const keys = Object.keys(allParams)\n if (keys.length > 0) {\n const firstKey = keys[0]\n const idx = pm.request.url.query.indexOf(firstKey)\n pm.expect(idx).to.be.a('number')\n pm.expect(idx).to.be.at.least(0)\n }\n})\n\npm.test('query.indexOf() - returns index by object', () => {\n const allParams = pm.request.url.query.all()\n const keys = Object.keys(allParams)\n if (keys.length > 0) {\n const idx = pm.request.url.query.indexOf({ key: keys[0] })\n pm.expect(idx).to.be.a('number')\n pm.expect(idx).to.be.at.least(0)\n }\n})\n\npm.test('query.indexOf() - returns -1 when not found', () => {\n const idx = pm.request.url.query.indexOf('notfound')\n pm.expect(idx).to.equal(-1)\n})\n\npm.test('query.insert/append/assimilate - methods executed successfully', () => {\n // Verify the methods executed without errors in pre-request\n // Post-request sees actual sent URL, so we just verify params exist\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.be.an('object')\n pm.expect(pm.request.url.query.has('limit')).to.be.true\n pm.expect(pm.request.url.query.has('offset')).to.be.true\n})\n\npm.test('query.append() - adds param at end', () => {\n const offsetIdx = pm.request.url.query.indexOf('offset')\n pm.expect(offsetIdx).to.be.at.least(0)\n pm.expect(pm.request.url.query.get('offset')).to.equal('0')\n})\n\npm.test('query.assimilate() - adds/updates params', () => {\n pm.expect(pm.request.url.query.has('include')).to.be.true\n pm.expect(pm.request.url.query.get('include')).to.equal('metadata')\n pm.expect(pm.request.url.query.has('status')).to.be.true\n pm.expect(pm.request.url.query.get('status')).to.equal('active')\n})\n\npm.test('headers.find() - finds header by string (case-insensitive)', () => {\n const ct = pm.request.headers.find('content-type')\n pm.expect(ct).to.be.an('object')\n pm.expect(ct.key).to.equal('Content-Type')\n})\n\npm.test('headers.find() - finds header by predicate function', () => {\n const auth = pm.request.headers.find((h) => h.key === 'Authorization')\n pm.expect(auth).to.be.an('object')\n pm.expect(auth.value).to.include('Bearer')\n})\n\npm.test('headers.find() - returns null when not found', () => {\n const result = pm.request.headers.find('nonexistent')\n pm.expect(result).to.be.null\n})\n\npm.test('headers.indexOf() - returns correct index (case-insensitive)', () => {\n const authIdx = pm.request.headers.indexOf('authorization')\n pm.expect(authIdx).to.be.a('number')\n pm.expect(authIdx).to.be.at.least(0)\n})\n\npm.test('headers.indexOf() - returns correct index by object', () => {\n const ctIdx = pm.request.headers.indexOf({ key: 'Content-Type' })\n pm.expect(ctIdx).to.be.a('number')\n pm.expect(ctIdx).to.be.at.least(0)\n})\n\npm.test('headers.indexOf() - returns -1 when not found', () => {\n const idx = pm.request.headers.indexOf('NotFound')\n pm.expect(idx).to.equal(-1)\n})\n\npm.test('headers.insert() - inserts header before specified header', () => {\n const apiKeyIdx = pm.request.headers.indexOf('X-API-Key')\n const authIdx = pm.request.headers.indexOf('Authorization')\n pm.expect(apiKeyIdx).to.be.below(authIdx)\n})\n\npm.test('headers.append() - adds header at end', () => {\n pm.expect(pm.request.headers.has('X-Request-ID')).to.be.true\n pm.expect(pm.request.headers.get('X-Request-ID')).to.equal('req-456')\n})\n\npm.test('headers.assimilate() - adds/updates headers', () => {\n pm.expect(pm.request.headers.has('X-Custom-Header')).to.be.true\n pm.expect(pm.request.headers.get('X-Custom-Header')).to.equal('custom-value')\n})\n\npm.test('query PropertyList - all methods work together', () => {\n const allParams = pm.request.url.query.all()\n pm.expect(allParams).to.be.an('object')\n // At minimum we should have the params added in pre-request\n pm.expect(Object.keys(allParams).length).to.be.at.least(4)\n})\n\npm.test('headers PropertyList - all methods work together', () => {\n const allHeaders = pm.request.headers.all()\n pm.expect(allHeaders).to.be.an('object')\n pm.expect(Object.keys(allHeaders).length).to.be.at.least(5)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "5", + "id": "advanced-response-methods-test", + "name": "advanced-response-methods-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [ + { + "key": "Content-Type", + "value": "application/json", + "active": true + } + ], + "preRequestScript": "export {};\n", + "testScript": "export {};\n\n// Test pm.response.reason()\npm.test('pm.response.reason() returns HTTP reason phrase', () => {\n const reason = pm.response.reason()\n pm.expect(reason).to.be.a('string')\n pm.expect(reason).to.equal('OK')\n})\n\n// Test hopp.response.reason() for parity\npm.test('hopp.response.reason() returns HTTP reason phrase', () => {\n const reason = hopp.response.reason()\n hopp.expect(reason).toBeType('string')\n hopp.expect(reason).toBe('OK')\n})\n\n// Test pm.response.dataURI()\npm.test('pm.response.dataURI() converts response to data URI', () => {\n const dataURI = pm.response.dataURI()\n pm.expect(dataURI).to.be.a('string')\n pm.expect(dataURI).to.include('data:')\n pm.expect(dataURI).to.include('base64')\n})\n\n// Test hopp.response.dataURI() for parity\npm.test('hopp.response.dataURI() converts response to data URI', () => {\n const dataURI = hopp.response.dataURI()\n hopp.expect(dataURI).toBeType('string')\n hopp.expect(dataURI.startsWith('data:')).toBe(true)\n})\n\n// Test .nested property assertions\npm.test('pm.expect().to.have.nested.property() accesses nested properties', () => {\n const obj = { a: { b: { c: 'deep value' } } }\n pm.expect(obj).to.have.nested.property('a.b.c', 'deep value')\n pm.expect(obj).to.have.nested.property('a.b')\n})\n\n// Test hopp namespace nested property for parity\npm.test('hopp.expect().to.have.nested.property() accesses nested properties', () => {\n const obj = { x: { y: { z: 'nested' } } }\n hopp.expect(obj).to.have.nested.property('x.y.z', 'nested')\n hopp.expect(obj).to.have.nested.property('x.y')\n})\n\npm.test('pm.expect().to.have.nested.property() handles arrays', () => {\n const obj = { items: [{ name: 'first' }, { name: 'second' }] }\n pm.expect(obj).to.have.nested.property('items[0].name', 'first')\n pm.expect(obj).to.have.nested.property('items[1].name', 'second')\n})\n\npm.test('pm.expect().to.not.have.nested.property() negation works', () => {\n const obj = { a: { b: 'value' } }\n pm.expect(obj).to.not.have.nested.property('a.c')\n pm.expect(obj).to.not.have.nested.property('x.y.z')\n})\n\n// Test .by() chaining for change assertions\npm.test('pm.expect().to.change().by() validates exact delta', () => {\n const obj = { value: 10 }\n pm.expect(() => { obj.value = 25 }).to.change(obj, 'value').by(15)\n})\n\n// Test hopp namespace .by() chaining for parity\npm.test('hopp.expect().to.change().by() validates exact delta', () => {\n const obj = { val: 100 }\n hopp.expect(() => { obj.val = 150 }).to.change(obj, 'val').by(50)\n})\n\npm.test('pm.expect().to.increase().by() validates exact increase', () => {\n const obj = { count: 5 }\n pm.expect(() => { obj.count += 7 }).to.increase(obj, 'count').by(7)\n})\n\npm.test('pm.expect().to.decrease().by() validates exact decrease', () => {\n const obj = { score: 100 }\n pm.expect(() => { obj.score -= 30 }).to.decrease(obj, 'score').by(30)\n})\n\npm.test('pm.expect().to.change().by() with negative delta', () => {\n const obj = { value: 50 }\n pm.expect(() => { obj.value = 20 }).to.change(obj, 'value').by(-30)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\"test\": \"data\"}" + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "advanced-chai-map-set-test", + "name": "advanced-chai-map-set-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};", + "testScript": "export {};\n\n// Map & Set Assertions\npm.test('Map assertions - size property', () => {\n const map = new Map([['key1', 'value1'], ['key2', 'value2']])\n pm.expect(map).to.have.property('size', 2)\n pm.expect(map.size).to.equal(2)\n})\n\npm.test('Set assertions - size property', () => {\n const set = new Set([1, 2, 3, 4])\n pm.expect(set).to.have.property('size', 4)\n pm.expect(set.size).to.equal(4)\n})\n\npm.test('Map instanceOf assertion', () => {\n const map = new Map()\n pm.expect(map).to.be.instanceOf(Map)\n pm.expect(map).to.be.an.instanceOf(Map)\n})\n\npm.test('Set instanceOf assertion', () => {\n const set = new Set()\n pm.expect(set).to.be.instanceOf(Set)\n pm.expect(set).to.be.an.instanceOf(Set)\n})\n\n// Advanced Chai - closeTo\npm.test('closeTo - validates numbers within delta', () => {\n pm.expect(3.14159).to.be.closeTo(3.14, 0.01)\n pm.expect(10.5).to.be.closeTo(11, 1)\n})\n\npm.test('closeTo - negation works', () => {\n pm.expect(100).to.not.be.closeTo(50, 10)\n pm.expect(3.14).to.not.be.closeTo(10, 0.1)\n})\n\npm.test('approximately - alias for closeTo', () => {\n pm.expect(2.5).to.approximately(2.4, 0.2)\n pm.expect(99.99).to.approximately(100, 0.1)\n})\n\n// Advanced Chai - finite\npm.test('finite - validates finite numbers', () => {\n pm.expect(123).to.be.finite\n pm.expect(0).to.be.finite\n pm.expect(-456).to.be.finite\n})\n\npm.test('finite - negation for Infinity', () => {\n pm.expect(Infinity).to.not.be.finite\n pm.expect(-Infinity).to.not.be.finite\n pm.expect(NaN).to.not.be.finite\n})\n\n// Advanced Chai - satisfy\npm.test('satisfy - custom predicate function', () => {\n pm.expect(10).to.satisfy((num) => num > 5)\n pm.expect('hello').to.satisfy((str) => str.length === 5)\n})\n\npm.test('satisfy - complex validation', () => {\n const obj = { name: 'test', value: 100 }\n pm.expect(obj).to.satisfy((o) => o.value > 50 && o.name.length > 0)\n})\n\npm.test('satisfy - negation works', () => {\n pm.expect(5).to.not.satisfy((num) => num > 10)\n pm.expect('abc').to.not.satisfy((str) => str.length > 5)\n})\n\n// Advanced Chai - respondTo\npm.test('respondTo - validates method existence', () => {\n class TestClass {\n testMethod() { return 'test' }\n anotherMethod() { return 'another' }\n }\n pm.expect(TestClass).to.respondTo('testMethod')\n pm.expect(TestClass).to.respondTo('anotherMethod')\n})\n\npm.test('respondTo - with itself for static methods', () => {\n class MyClass {\n static staticMethod() { return 'static' }\n instanceMethod() { return 'instance' }\n }\n pm.expect(MyClass).itself.to.respondTo('staticMethod')\n pm.expect(MyClass).to.not.itself.respondTo('instanceMethod')\n pm.expect(MyClass).to.respondTo('instanceMethod')\n})\n\n// Property Ownership - own.property\npm.test('own.property - distinguishes own vs inherited', () => {\n const parent = { inherited: true }\n const obj = Object.create(parent)\n obj.own = true\n pm.expect(obj).to.have.own.property('own')\n pm.expect(obj).to.not.have.own.property('inherited')\n pm.expect(obj).to.have.property('inherited')\n})\n\npm.test('deep.own.property - deep check with ownership', () => {\n const proto = { shared: 'inherited' }\n const obj = Object.create(proto)\n obj.data = { nested: 'value' }\n pm.expect(obj).to.have.deep.own.property('data', { nested: 'value' })\n pm.expect(obj).to.not.have.deep.own.property('shared')\n})\n\npm.test('ownProperty - alias for own.property', () => {\n const obj = { prop: 'value' }\n pm.expect(obj).to.have.ownProperty('prop')\n pm.expect(obj).to.have.ownProperty('prop', 'value')\n})\n\n// Hopp namespace parity tests\npm.test('hopp.expect Map/Set support', () => {\n const map = new Map([['x', 1]])\n const set = new Set([1, 2])\n hopp.expect(map.size).toBe(1)\n hopp.expect(set.size).toBe(2)\n})\n\npm.test('hopp.expect closeTo support', () => {\n hopp.expect(3.14).to.be.closeTo(3.1, 0.1)\n hopp.expect(10).to.be.closeTo(10.5, 1)\n})\n\npm.test('hopp.expect finite support', () => {\n hopp.expect(42).to.be.finite\n hopp.expect(Infinity).to.not.be.finite\n})\n\npm.test('hopp.expect satisfy support', () => {\n hopp.expect(100).to.satisfy((n) => n > 50)\n hopp.expect('test').to.satisfy((s) => s.length === 4)\n})\n\npm.test('hopp.expect respondTo support', () => {\n class TestClass { method() {} }\n hopp.expect(TestClass).to.respondTo('method')\n})\n\npm.test('hopp.expect own.property support', () => {\n const obj = Object.create({ inherited: 1 })\n obj.own = 2\n hopp.expect(obj).to.have.own.property('own')\n hopp.expect(obj).to.not.have.own.property('inherited')\n})\n\npm.test('hopp.expect ordered.members support', () => {\n const arr = ['a', 'b', 'c']\n hopp.expect(arr).to.have.ordered.members(['a', 'b', 'c'])\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "16", + "id": "cmfhzf0op00typecoer01", + "name": "type-preservation-test", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n// For CLI E2E testing: We only set simple string values in pre-request\n// Complex types will be tested within the test script itself\n\npm.environment.set('string_value', 'hello')\n", + "testScript": "export {};\n\n// ========================================\n// TYPE PRESERVATION TESTS (CLI Compatible)\n// ========================================\n\n// IMPORTANT NOTE: Type preservation works perfectly WITHIN script execution scope\n// Values persisted across request boundaries (pre-request \u2192 test) may be serialized\n// This is expected CLI behavior for environment persistence/display\n\n// Test values set from pre-request\npm.test('string values work across scripts', () => {\n pm.expect(pm.environment.get('string_value')).to.equal('hello')\n})\n\n// ========================================\n// TYPE PRESERVATION WITHIN SINGLE SCRIPT\n// (This is where type preservation really shines!)\n// ========================================\n\npm.test('numbers are preserved as numbers (same script)', () => {\n pm.environment.set('num', 42)\n const value = pm.environment.get('num')\n pm.expect(value).to.equal(42)\n pm.expect(typeof value).to.equal('number')\n})\n\npm.test('booleans are preserved as booleans (same script)', () => {\n pm.environment.set('bool_true', true)\n pm.environment.set('bool_false', false)\n pm.expect(pm.environment.get('bool_true')).to.equal(true)\n pm.expect(pm.environment.get('bool_false')).to.equal(false)\n pm.expect(typeof pm.environment.get('bool_true')).to.equal('boolean')\n})\n\npm.test('null is preserved as actual null (same script)', () => {\n pm.environment.set('null_val', null)\n const value = pm.environment.get('null_val')\n pm.expect(value).to.equal(null)\n pm.expect(value === null).to.be.true\n pm.expect(typeof value).to.equal('object')\n})\n\npm.test('undefined is preserved as actual undefined (same script)', () => {\n pm.environment.set('undef_val', undefined)\n const value = pm.environment.get('undef_val')\n pm.expect(value).to.equal(undefined)\n pm.expect(typeof value).to.equal('undefined')\n pm.expect(pm.environment.has('undef_val')).to.be.true\n})\n\npm.test('arrays are preserved with direct access', () => {\n pm.environment.set('arr', [1, 2, 3])\n const value = pm.environment.get('arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(3)\n pm.expect(value[0]).to.equal(1)\n pm.expect(value[2]).to.equal(3)\n})\n\npm.test('single-element arrays remain arrays', () => {\n pm.environment.set('single', [42])\n const value = pm.environment.get('single')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(1)\n pm.expect(value[0]).to.equal(42)\n})\n\npm.test('empty arrays are preserved', () => {\n pm.environment.set('empty_arr', [])\n const value = pm.environment.get('empty_arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value.length).to.equal(0)\n})\n\npm.test('string arrays preserve all elements', () => {\n pm.environment.set('str_arr', ['a', 'b', 'c'])\n const value = pm.environment.get('str_arr')\n\n pm.expect(Array.isArray(value)).to.be.true\n pm.expect(value).to.deep.equal(['a', 'b', 'c'])\n})\n\npm.test('objects are preserved with accessible properties', () => {\n pm.environment.set('obj', { key: 'value', num: 123 })\n const value = pm.environment.get('obj')\n\n pm.expect(typeof value).to.equal('object')\n pm.expect(value.key).to.equal('value')\n pm.expect(value.num).to.equal(123)\n})\n\npm.test('empty objects are preserved', () => {\n pm.environment.set('empty_obj', {})\n const value = pm.environment.get('empty_obj')\n\n pm.expect(typeof value).to.equal('object')\n pm.expect(Object.keys(value).length).to.equal(0)\n})\n\npm.test('nested objects preserve structure', () => {\n pm.environment.set('nested', { user: { name: 'John', id: 1 }, meta: { active: true } })\n const value = pm.environment.get('nested')\n\n pm.expect(value.user.name).to.equal('John')\n pm.expect(value.user.id).to.equal(1)\n pm.expect(value.meta.active).to.equal(true)\n})\n\npm.test('complex nested structures work', () => {\n const data = {\n users: [\n { id: 1, name: 'Alice', scores: [90, 85, 88] },\n { id: 2, name: 'Bob', scores: [75, 80, 82] }\n ],\n metadata: { count: 2, page: 1, filters: ['active', 'verified'] }\n }\n\n pm.environment.set('complex', data)\n const retrieved = pm.environment.get('complex')\n\n pm.expect(retrieved.users).to.be.an('array')\n pm.expect(retrieved.users.length).to.equal(2)\n pm.expect(retrieved.users[0].name).to.equal('Alice')\n pm.expect(retrieved.users[0].scores[0]).to.equal(90)\n pm.expect(retrieved.metadata.filters).to.deep.equal(['active', 'verified'])\n})\n\n// ========================================\n// NAMESPACE SEPARATION\n// ========================================\n\npm.test('hopp.env.set rejects non-string values', () => {\n let errorCount = 0\n\n try { hopp.env.set('test', undefined) } catch (e) { errorCount++ }\n try { hopp.env.set('test', null) } catch (e) { errorCount++ }\n try { hopp.env.set('test', 42) } catch (e) { errorCount++ }\n try { hopp.env.set('test', true) } catch (e) { errorCount++ }\n try { hopp.env.set('test', [1, 2]) } catch (e) { errorCount++ }\n try { hopp.env.set('test', {}) } catch (e) { errorCount++ }\n\n pm.expect(errorCount).to.equal(6)\n})\n\npm.test('hopp.env.set only accepts strings', () => {\n hopp.env.set('hopp_str', 'valid')\n pm.expect(hopp.env.get('hopp_str')).to.equal('valid')\n})\n\npm.test('pm/hopp cross-namespace reading works', () => {\n pm.environment.set('cross_test', [1, 2, 3])\n\n // hopp can read PM-set values\n const fromHopp = hopp.env.get('cross_test')\n pm.expect(Array.isArray(fromHopp)).to.be.true\n pm.expect(fromHopp.length).to.equal(3)\n})\n\n// ========================================\n// PRACTICAL USE CASES\n// ========================================\n\npm.test('no JSON.parse needed for response data storage', () => {\n // Simulate storing parsed response data\n const responseData = {\n id: 123,\n name: 'Test User',\n permissions: ['read', 'write'],\n settings: { theme: 'dark', notifications: true }\n }\n\n pm.environment.set('user_data', responseData)\n const stored = pm.environment.get('user_data')\n\n // Direct access - no JSON.parse needed!\n pm.expect(stored.id).to.equal(123)\n pm.expect(stored.permissions).to.include('write')\n pm.expect(stored.settings.theme).to.equal('dark')\n})\n\npm.test('array iteration works directly', () => {\n pm.environment.set('items', ['apple', 'banana', 'cherry'])\n const items = pm.environment.get('items')\n\n let concatenated = ''\n items.forEach(item => {\n concatenated += item\n })\n\n pm.expect(concatenated).to.equal('applebananacherry')\n pm.expect(items.map(i => i.toUpperCase())).to.deep.equal(['APPLE', 'BANANA', 'CHERRY'])\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {} + }, + { + "v": "15", + "id": "type_preservation_ui_compat", + "name": "type-preservation-ui-compatibility-test", + "method": "POST", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "export {};\n// Type preservation tests run in test script scope", + "testScript": "export {};\n\n// ====== Type Preservation & UI Compatibility Tests ======\n// NOTE: Testing in same script scope (CLI limitation: complex types\n// may not persist across pre-request \u2192 test boundary)\n\npm.test('PM namespace preserves array types (not String coercion)', () => {\n pm.environment.set('simpleArray', [1, 2, 3])\n const arr = pm.environment.get('simpleArray')\n\n // CRITICAL: Should be actual array, not string \"1,2,3\"\n pm.expect(Array.isArray(arr)).to.equal(true)\n pm.expect(arr).to.have.lengthOf(3)\n pm.expect(arr[0]).to.equal(1)\n pm.expect(arr[1]).to.equal(2)\n pm.expect(arr[2]).to.equal(3)\n})\n\npm.test('PM namespace preserves object types (not \"[object Object]\")', () => {\n pm.environment.set('simpleObject', { foo: 'bar', num: 42 })\n const obj = pm.environment.get('simpleObject')\n\n // CRITICAL: Should be actual object, not string \"[object Object]\"\n pm.expect(typeof obj).to.equal('object')\n pm.expect(obj).to.not.be.null\n pm.expect(obj.foo).to.equal('bar')\n pm.expect(obj.num).to.equal(42)\n})\n\npm.test('PM namespace preserves null correctly', () => {\n pm.environment.set('nullValue', null)\n const val = pm.environment.get('nullValue')\n\n pm.expect(val).to.be.null\n})\n\npm.test('PM namespace preserves undefined correctly', () => {\n pm.environment.set('undefinedValue', undefined)\n const val = pm.environment.get('undefinedValue')\n\n pm.expect(val).to.be.undefined\n})\n\npm.test('PM namespace preserves primitives correctly', () => {\n pm.environment.set('stringValue', 'hello')\n pm.environment.set('numberValue', 123)\n pm.environment.set('booleanValue', true)\n\n pm.expect(pm.environment.get('stringValue')).to.equal('hello')\n pm.expect(pm.environment.get('numberValue')).to.equal(123)\n pm.expect(pm.environment.get('booleanValue')).to.equal(true)\n})\n\npm.test('PM namespace preserves nested structures', () => {\n pm.environment.set('nestedStructure', {\n users: [\n { id: 1, name: 'Alice' },\n { id: 2, name: 'Bob' }\n ],\n meta: { count: 2, tags: ['active', 'verified'] }\n })\n const nested = pm.environment.get('nestedStructure')\n\n pm.expect(nested).to.be.an('object')\n pm.expect(nested.users).to.be.an('array')\n pm.expect(nested.users).to.have.lengthOf(2)\n pm.expect(nested.users[0].name).to.equal('Alice')\n pm.expect(nested.users[1].name).to.equal('Bob')\n pm.expect(nested.meta.count).to.equal(2)\n pm.expect(nested.meta.tags).to.have.members(['active', 'verified'])\n})\n\npm.test('PM namespace handles mixed arrays (regression test for UI crash)', () => {\n pm.environment.set('mixedArray', [\n 'string',\n 42,\n true,\n null,\n undefined,\n [1, 2],\n { key: 'value' }\n ])\n const mixed = pm.environment.get('mixedArray')\n\n // This is the exact case that caused the UI crash\n pm.expect(Array.isArray(mixed)).to.equal(true)\n pm.expect(mixed).to.have.lengthOf(7)\n pm.expect(mixed[0]).to.equal('string')\n pm.expect(mixed[1]).to.equal(42)\n pm.expect(mixed[2]).to.equal(true)\n pm.expect(mixed[3]).to.be.null\n // mixed[4] is undefined in array, becomes null during JSON serialization\n pm.expect(Array.isArray(mixed[5])).to.equal(true)\n pm.expect(mixed[5]).to.have.lengthOf(2)\n pm.expect(typeof mixed[6]).to.equal('object')\n pm.expect(mixed[6].key).to.equal('value')\n})\n\npm.test('PM globals preserve arrays and objects', () => {\n pm.globals.set('globalArray', [10, 20, 30])\n pm.globals.set('globalObject', { env: 'prod', port: 8080 })\n\n const globalArr = pm.globals.get('globalArray')\n const globalObj = pm.globals.get('globalObject')\n\n pm.expect(Array.isArray(globalArr)).to.equal(true)\n pm.expect(globalArr).to.deep.equal([10, 20, 30])\n\n pm.expect(typeof globalObj).to.equal('object')\n pm.expect(globalObj.env).to.equal('prod')\n pm.expect(globalObj.port).to.equal(8080)\n})\n\npm.test('PM variables preserve arrays and objects', () => {\n pm.variables.set('varArray', [5, 10, 15])\n pm.variables.set('varObject', { status: 'active', count: 100 })\n\n const varArr = pm.variables.get('varArray')\n const varObj = pm.variables.get('varObject')\n\n pm.expect(Array.isArray(varArr)).to.equal(true)\n pm.expect(varArr).to.deep.equal([5, 10, 15])\n\n pm.expect(typeof varObj).to.equal('object')\n pm.expect(varObj.status).to.equal('active')\n pm.expect(varObj.count).to.equal(100)\n})\n\npm.test('Type preservation works with Postman compatibility', () => {\n pm.environment.set('testArr', [1, 2, 3])\n pm.environment.set('testObj', { foo: 'bar', num: 42 })\n\n const arr = pm.environment.get('testArr')\n const obj = pm.environment.get('testObj')\n\n // Should work like Postman: runtime types preserved\n pm.expect(arr.length).to.equal(3)\n pm.expect(obj.foo).to.equal('bar')\n\n // Verify no String() coercion happened\n pm.expect(arr).to.not.equal('1,2,3')\n pm.expect(obj).to.not.equal('[object Object]')\n})\n\npm.test('Type preservation: UI compatibility regression test', () => {\n // This test validates the fix for the reported bug:\n // \"TypeError: a.match is not a function at details.vue:387:10\"\n\n pm.environment.set('mixedTest', [\n 'string', 42, true, null, undefined, [1, 2], { key: 'value' }\n ])\n\n const mixed = pm.environment.get('mixedTest')\n\n // Should NOT throw any errors\n let errorCount = 0\n try {\n // Access all elements\n mixed.forEach(item => {\n // Should work with all types\n const type = typeof item\n const validTypes = ['string', 'number', 'boolean', 'object']\n if (!validTypes.includes(type)) {\n errorCount++\n }\n })\n } catch (e) {\n errorCount++\n }\n\n pm.expect(errorCount).to.equal(0)\n})\n", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": "application/json", + "body": "{\n \"test\": \"type preservation validation\"\n}" + }, + "requestVariables": [], + "responses": {} } ], "auth": { @@ -252,4 +796,4 @@ }, "headers": [], "variables": [] -} +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 9dbdd3e5..fee73f46 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -68,23 +68,36 @@ export const preRequestScriptRunner = ( const { selected, global } = updatedEnvs; return { - updatedEnvs: { + // Keep the original updatedEnvs with separate global and selected arrays + preRequestUpdatedEnvs: updatedEnvs, + // Create Environment format for getEffectiveRESTRequest + envForEffectiveRequest: { name: "Env", variables: [...(selected ?? []), ...(global ?? [])], }, updatedRequest: updatedRequest ?? {}, }; }), - TE.chainW(({ updatedEnvs, updatedRequest }) => { + TE.chainW(({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => { const finalRequest = { ...request, ...updatedRequest }; return TE.tryCatch( - () => - getEffectiveRESTRequest( + async () => { + const result = await getEffectiveRESTRequest( finalRequest, - updatedEnvs, + envForEffectiveRequest, collectionVariables - ), + ); + // Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script + // This preserves the global/selected separation + if (E.isRight(result)) { + return E.right({ + ...result.right, + updatedEnvs: preRequestUpdatedEnvs, + }); + } + return result; + }, (reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason }) ); }), diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 18151789..81c128f7 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -686,6 +686,8 @@ "from_postman": "Import from Postman", "from_postman_description": "Import from Postman collection", "from_postman_import_summary": "Collections, Requests and response examples will be imported.", + "import_scripts": "Import scripts", + "import_scripts_description": "Supports Postman Collection v2.0/v2.1.", "from_url": "Import from URL", "gist_url": "Enter Gist URL", "from_har": "Import from HAR", @@ -712,6 +714,9 @@ "import_summary_pre_request_scripts_title": "Pre-request scripts", "import_summary_post_request_scripts_title": "Post request scripts", "import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now.", + "import_summary_script_found": "script found but not imported", + "import_summary_scripts_found": "scripts found but not imported", + "import_summary_enable_experimental_sandbox": "To import Postman scripts, enable 'Experimental scripting sandbox' in settings. Note: This feature is experimental.", "cors_error_modal": { "title": "CORS Error Detected", "description": "The import failed due to CORS (Cross-Origin Resource Sharing) restrictions imposed by the server.", @@ -1314,6 +1319,7 @@ "download_failed": "Download failed", "download_started": "Download started", "enabled": "Enabled", + "experimental": "Experimental", "file_imported": "File imported", "finished_in": "Finished in {duration} ms", "hide": "Hide", diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index 5fda8674..b3dba8e8 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -59,7 +59,6 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo import { TeamWorkspace } from "~/services/workspace.service" import { invokeAction } from "~/helpers/actions" -const isPostmanImporterInProgress = ref(false) const isInsomniaImporterInProgress = ref(false) const isOpenAPIImporterInProgress = ref(false) const isRESTImporterInProgress = ref(false) @@ -171,6 +170,7 @@ const emit = defineEmits<{ const isHoppMyCollectionExporterInProgress = ref(false) const isHoppTeamCollectionExporterInProgress = ref(false) const isHoppGistCollectionExporterInProgress = ref(false) +const isPostmanImporterInProgress = ref(false) const isTeamWorkspace = computed(() => { return props.collectionsType.type === "team-collections" @@ -179,19 +179,83 @@ const isTeamWorkspace = computed(() => { const currentImportSummary: Ref<{ showImportSummary: boolean importedCollections: HoppCollection[] | null + scriptsImported?: boolean + originalScriptCounts?: { preRequest: number; test: number } }> = ref({ showImportSummary: false, importedCollections: null, + scriptsImported: false, + originalScriptCounts: undefined, }) -const setCurrentImportSummary = (collections: HoppCollection[]) => { +const setCurrentImportSummary = ( + collections: HoppCollection[], + scriptsImported?: boolean, + originalScriptCounts?: { preRequest: number; test: number } +) => { currentImportSummary.value.importedCollections = collections currentImportSummary.value.showImportSummary = true + currentImportSummary.value.scriptsImported = scriptsImported + currentImportSummary.value.originalScriptCounts = originalScriptCounts } const unsetCurrentImportSummary = () => { currentImportSummary.value.importedCollections = null currentImportSummary.value.showImportSummary = false + currentImportSummary.value.scriptsImported = false + currentImportSummary.value.originalScriptCounts = undefined +} + +// Count scripts in raw Postman collection JSON (before import strips them) +const countPostmanScripts = ( + content: string[] +): { preRequest: number; test: number } => { + let preRequestCount = 0 + let testCount = 0 + + const countInItem = (item: any) => { + // Only count if this is a request (has request object), not a folder + const isRequest = item?.request !== undefined + + if (isRequest && item?.event) { + const prerequest = item.event.find((e: any) => e.listen === "prerequest") + const test = item.event.find((e: any) => e.listen === "test") + + if ( + prerequest?.script?.exec && + Array.isArray(prerequest.script.exec) && + prerequest.script.exec.some((line: string) => line?.trim()) + ) { + preRequestCount++ + } + + if ( + test?.script?.exec && + Array.isArray(test.script.exec) && + test.script.exec.some((line: string) => line?.trim()) + ) { + testCount++ + } + } + + // Recursively count in nested items (folders) + if (item?.item && Array.isArray(item.item)) { + item.item.forEach(countInItem) + } + } + + content.forEach((fileContent) => { + try { + const collection = JSON.parse(fileContent) + if (collection?.item && Array.isArray(collection.item)) { + collection.item.forEach(countInItem) + } + } catch (e) { + // Invalid JSON, skip + } + }) + + return { preRequest: preRequestCount, test: testCount } } const HoppRESTImporter: ImporterOrExporter = { @@ -379,15 +443,20 @@ const HoppPostmanImporter: ImporterOrExporter = { caption: "import.from_file", acceptedFileTypes: ".json", description: "import.from_postman_import_summary", - onImportFromFile: async (content) => { + showPostmanScriptOption: true, + onImportFromFile: async (content: string[], importScripts?: boolean) => { isPostmanImporterInProgress.value = true - const res = await hoppPostmanImporter(content)() + // Count scripts from raw Postman JSON before importing + const originalCounts = + importScripts === undefined ? countPostmanScripts(content) : undefined + + const res = await hoppPostmanImporter(content, importScripts ?? false)() if (E.isRight(res)) { await handleImportToStore(res.right) - setCurrentImportSummary(res.right) + setCurrentImportSummary(res.right, importScripts, originalCounts) platform.analytics?.logEvent({ platform: "rest", diff --git a/packages/hoppscotch-common/src/components/importExport/Base.vue b/packages/hoppscotch-common/src/components/importExport/Base.vue index b13b4ba4..d7de6c12 100644 --- a/packages/hoppscotch-common/src/components/importExport/Base.vue +++ b/packages/hoppscotch-common/src/components/importExport/Base.vue @@ -202,6 +202,8 @@ props.importerModules.forEach((importer) => { props: () => ({ collections: importSummary.value.importedCollections, importFormat: importer.metadata.format, + scriptsImported: importSummary.value.scriptsImported, + originalScriptCounts: importSummary.value.originalScriptCounts, "on-close": () => { emit("hide-modal") }, diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue index 0659d4f3..1f85aa62 100644 --- a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/FileImport.vue @@ -52,13 +52,41 @@ }}

+ + +
+ + +
+
@@ -69,6 +97,7 @@ import { useI18n } from "@composables/i18n" import { useToast } from "@composables/toast" import { computed, ref } from "vue" import { platform } from "~/platform" +import { useSetting } from "~/composables/settings" const props = withDefaults( defineProps<{ @@ -76,16 +105,24 @@ const props = withDefaults( acceptedFileTypes: string loading?: boolean description?: string + showPostmanScriptOption?: boolean }>(), { loading: false, description: undefined, + showPostmanScriptOption: false, } ) const t = useI18n() const toast = useToast() +// Postman-specific: Script import state (only use case so far) +const importScripts = ref(false) +const experimentalScriptingEnabled = useSetting( + "EXPERIMENTAL_SCRIPTING_SANDBOX" +) + const ALLOWED_FILE_SIZE_LIMIT = platform.limits?.collectionImportSizeLimit ?? 10 // Default to 10 MB const importFilesCount = ref(0) @@ -97,7 +134,7 @@ const fileContent = ref([]) const inputChooseFileToImportFrom = ref() const emit = defineEmits<{ - (e: "importFromFile", content: string[]): void + (e: "importFromFile", content: string[], ...additionalArgs: any[]): void }>() // Disable the import CTA if no file is selected, the file size limit is exceeded, or during an import action indicated by the `isLoading` prop @@ -106,6 +143,16 @@ const disableImportCTA = computed( !hasFile.value || showFileSizeLimitExceededWarning.value || props.loading ) +const handleImport = () => { + // If Postman script option is enabled AND experimental sandbox is enabled, pass the importScripts value + // Otherwise, don't pass it (undefined) to indicate the feature wasn't available + if (props.showPostmanScriptOption && experimentalScriptingEnabled.value) { + emit("importFromFile", fileContent.value, importScripts.value) + } else { + emit("importFromFile", fileContent.value) + } +} + const onFileChange = async () => { // Reset the state on entering the handler to avoid any stale state if (showFileSizeLimitExceededWarning.value) { diff --git a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue index 80e030f0..72966e97 100644 --- a/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue +++ b/packages/hoppscotch-common/src/components/importExport/ImportExportSteps/ImportSummary.vue @@ -1,3 +1,120 @@ + + - - diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts index aed2ccff..2eb4ddaf 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/import-sources/FileSource.ts @@ -2,14 +2,18 @@ import FileImportVue from "~/components/importExport/ImportExportSteps/FileImpor import { defineStep } from "~/composables/step-components" import { v4 as uuidv4 } from "uuid" -import { Ref } from "vue" +import type { Ref } from "vue" export function FileSource(metadata: { acceptedFileTypes: string caption: string - onImportFromFile: (content: string[]) => any | Promise + onImportFromFile: ( + content: string[], + importScripts?: boolean + ) => any | Promise isLoading?: Ref description?: string + showPostmanScriptOption?: boolean }) { const stepID = uuidv4() @@ -19,5 +23,6 @@ export function FileSource(metadata: { onImportFromFile: metadata.onImportFromFile, loading: metadata.isLoading?.value, description: metadata.description, + showPostmanScriptOption: metadata.showPostmanScriptOption, })) } diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts index 623bd539..61f47ae8 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -39,6 +39,30 @@ const safeParseJSON = (jsonStr: string) => O.tryCatch(() => JSON.parse(jsonStr)) const isPMItem = (x: unknown): x is Item => Item.isItem(x) +/** + * Checks if the Postman collection schema version supports scripts (v2.0+) + * @param schema - The schema URL from collection.info.schema + * @returns true if v2.0 or v2.1, false otherwise + */ +const isSchemaVersionSupported = (schema?: string): boolean => { + if (!schema) return false + // Support both schema.getpostman.com and schema.postman.com + return schema.includes("/v2.0.") || schema.includes("/v2.1.") +} + +/** + * Extracts the collection schema from raw JSON data + * Note: PMCollection SDK doesn't expose .info.schema, so we parse raw JSON + */ +const getCollectionSchema = (jsonStr: string): string | null => { + try { + const data = JSON.parse(jsonStr) + return data?.info?.schema ?? null + } catch { + return null + } +} + const replacePMVarTemplating = flow( S.replace(/{{\s*/g, "<<"), S.replace(/\s*}}/g, ">>") @@ -482,7 +506,56 @@ const getHoppReqURL = (url: Item["request"]["url"] | null): string => { ) } -const getHoppRequest = (item: Item): HoppRESTRequest => { +/** + * Extracts script content from a Postman event + * Handles both string format and exec array format + */ +const extractScriptFromEvent = (event: any): string => { + if (!event?.script) return "" + + if (typeof event.script === "string") { + return event.script + } + + if (event.script.exec && Array.isArray(event.script.exec)) { + return event.script.exec.join("\n") + } + + return "" +} + +const getHoppScripts = ( + item: Item, + importScripts: boolean +): { preRequestScript: string; testScript: string } => { + if (!importScripts) { + return { preRequestScript: "", testScript: "" } + } + + let preRequestScript = "" + let testScript = "" + + // Postman stores scripts in the events array + if (item.events) { + const events = item.events.all() + events.forEach((event: any) => { + if (event.listen === "prerequest") { + preRequestScript = extractScriptFromEvent(event) + } else if (event.listen === "test") { + testScript = extractScriptFromEvent(event) + } + }) + } + + return { preRequestScript, testScript } +} + +const getHoppRequest = ( + item: Item, + importScripts: boolean +): HoppRESTRequest => { + const { preRequestScript, testScript } = getHoppScripts(item, importScripts) + return makeRESTRequest({ name: item.name, endpoint: getHoppReqURL(item.request.url), @@ -496,38 +569,68 @@ const getHoppRequest = (item: Item): HoppRESTRequest => { }), requestVariables: getHoppReqVariables(item.request.url.variables), responses: getHoppResponses(item.responses), - - // TODO: Decide about this - preRequestScript: "", - testScript: "", + preRequestScript, + testScript, }) } -const getHoppFolder = (ig: ItemGroup): HoppCollection => +const getHoppFolder = ( + ig: ItemGroup, + importScripts: boolean +): HoppCollection => makeCollection({ name: ig.name, folders: pipe( ig.items.all(), A.filter(isPMItemGroup), - A.map(getHoppFolder) + A.map((folder) => getHoppFolder(folder, importScripts)) + ), + requests: pipe( + ig.items.all(), + A.filter(isPMItem), + A.map((item) => getHoppRequest(item, importScripts)) ), - requests: pipe(ig.items.all(), A.filter(isPMItem), A.map(getHoppRequest)), auth: getHoppReqAuth(ig.auth), headers: [], variables: getHoppCollVariables(ig), }) -export const getHoppCollections = (collections: PMCollection[]) => { - return collections.map(getHoppFolder) +export const getHoppCollections = ( + collections: PMCollection[], + importScripts: boolean +) => { + return collections.map((collection) => + getHoppFolder(collection, importScripts) + ) } -export const hoppPostmanImporter = (fileContents: string[]) => +export const hoppPostmanImporter = ( + fileContents: string[], + importScripts = false +) => pipe( // Try reading fileContents, A.traverse(O.Applicative)(readPMCollection), - O.map(flow(getHoppCollections)), + O.map((collections) => { + // Validate schema version if importing scripts + if (importScripts && fileContents.length > 0) { + const schema = getCollectionSchema(fileContents[0]) + const isSupported = isSchemaVersionSupported(schema ?? undefined) + + if (!isSupported) { + console.warn( + `[Postman Import] Script import requested but collection schema "${schema ?? "unknown"}" does not support scripts. ` + + `Only Postman Collection Format v2.0 and v2.1 are supported. Scripts will be skipped.` + ) + // Skip script import for unsupported versions + return getHoppCollections(collections, false) + } + } + + return getHoppCollections(collections, importScripts) + }), TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) ) diff --git a/packages/hoppscotch-common/src/types/post-request.d.ts b/packages/hoppscotch-common/src/types/post-request.d.ts index c8fd9eea..e733c86f 100644 --- a/packages/hoppscotch-common/src/types/post-request.d.ts +++ b/packages/hoppscotch-common/src/types/post-request.d.ts @@ -230,13 +230,227 @@ type HoppRESTAuth = | HoppRESTAuthAkamaiEdgeGrid | HoppRESTAuthJWT -interface Expectation extends ExpectationMethods { - not: BaseExpectation +// Length property that can be used both as property and callable with comparison methods +// Matches actual runtime implementation in post-request.js lines 371-414 +interface ChaiLengthAssertion { + // Primary comparison methods (actually implemented in runtime) + above(n: number): ChaiExpectation + below(n: number): ChaiExpectation + within(min: number, max: number): ChaiExpectation + least(n: number): ChaiExpectation + most(n: number): ChaiExpectation + gte(n: number): ChaiExpectation + lte(n: number): ChaiExpectation + + // Aliases implemented in runtime (for compatibility) + greaterThan(n: number): ChaiExpectation // Alias for above() + lessThan(n: number): ChaiExpectation // Alias for below() + + // Postman-style .at.least() and .at.most() support + at: Readonly<{ + least(n: number): ChaiExpectation + most(n: number): ChaiExpectation + }> } -interface BaseExpectation extends ExpectationMethods {} +// Chai-powered assertion interface +interface ChaiExpectation { + // Negation + not: ChaiExpectation -interface ExpectationMethods { + // Language chains (improve readability without affecting assertions) + to: ChaiExpectation + be: ChaiExpectation + been: ChaiExpectation + is: ChaiExpectation + that: ChaiExpectation + which: ChaiExpectation + and: ChaiExpectation + has: ChaiExpectation + have: ChaiExpectation + with: ChaiExpectation + at: ChaiExpectation + of: ChaiExpectation + same: ChaiExpectation + but: ChaiExpectation + does: ChaiExpectation + still: ChaiExpectation + also: ChaiExpectation + + // Modifiers (can be used as both properties and methods) + deep: ChaiExpectation + nested: ChaiExpectation + own: ChaiExpectation + ordered: ChaiExpectation + any: ChaiExpectation + all: ChaiExpectation + + // Include/contain can be used as both properties AND methods + include: ChaiExpectation & ((value: any) => ChaiExpectation) + contain: ChaiExpectation & ((value: any) => ChaiExpectation) + includes: ChaiExpectation & ((value: any) => ChaiExpectation) + contains: ChaiExpectation & ((value: any) => ChaiExpectation) + + // Type assertions - can be used as both property and method + a: ChaiExpectation & ((type: string) => ChaiExpectation) + an: ChaiExpectation & ((type: string) => ChaiExpectation) + + // Equality assertions + equal(value: any): ChaiExpectation + equals(value: any): ChaiExpectation + eq(value: any): ChaiExpectation + eql(value: any): ChaiExpectation + + // Truthiness assertions + true: ChaiExpectation + false: ChaiExpectation + ok: ChaiExpectation + null: ChaiExpectation + undefined: ChaiExpectation + NaN: ChaiExpectation + exist: ChaiExpectation + empty: ChaiExpectation + arguments: ChaiExpectation + + // Numerical comparison assertions + above(n: number): ChaiExpectation + gt(n: number): ChaiExpectation + greaterThan(n: number): ChaiExpectation + below(n: number): ChaiExpectation + lt(n: number): ChaiExpectation + lessThan(n: number): ChaiExpectation + least(n: number): ChaiExpectation + gte(n: number): ChaiExpectation + greaterThanOrEqual(n: number): ChaiExpectation + most(n: number): ChaiExpectation + lte(n: number): ChaiExpectation + lessThanOrEqual(n: number): ChaiExpectation + within(start: number, finish: number): ChaiExpectation + closeTo(expected: number, delta: number): ChaiExpectation + approximately(expected: number, delta: number): ChaiExpectation + + // Property assertions + property(name: string, value?: any): ChaiExpectation + ownProperty(name: string, value?: any): ChaiExpectation + haveOwnProperty(name: string, value?: any): ChaiExpectation + ownPropertyDescriptor( + name: string, + descriptor?: PropertyDescriptor + ): ChaiExpectation + + // Length assertions - SPECIAL: Can be used as property or called with comparison methods + // Allow .length to be called as function or used as property with comparison methods + length: ChaiLengthAssertion & number & ((n: number) => ChaiExpectation) + lengthOf: ((n: number) => ChaiExpectation) & ChaiLengthAssertion + + // String/Array inclusion assertions + string(str: string): ChaiExpectation + match(regex: RegExp): ChaiExpectation + matches(regex: RegExp): ChaiExpectation + members(set: any[]): ChaiExpectation + oneOf(list: any[]): ChaiExpectation + + // Key assertions + keys(...keys: string[] | [string[]]): ChaiExpectation + key(key: string | string[]): ChaiExpectation + + // Function/Error assertions + throw( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + throws( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + Throw( + errorLike?: any, + errMsgMatcher?: string | RegExp, + message?: string + ): ChaiExpectation + respondTo(method: string): ChaiExpectation + respondsTo(method: string): ChaiExpectation + itself: ChaiExpectation + satisfy(matcher: (value: any) => boolean): ChaiExpectation + satisfies(matcher: (value: any) => boolean): ChaiExpectation + + // Object state assertions + sealed: ChaiExpectation + frozen: ChaiExpectation + extensible: ChaiExpectation + finite: ChaiExpectation + + // instanceof assertion + instanceof(constructor: any): ChaiExpectation + instanceOf(constructor: any): ChaiExpectation + + // Side-effect assertions + // Side effect assertions - support both getter function and object+property patterns + change( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + change( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + increase( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + increase( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + decrease( + getter: () => any + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + decrease( + obj: any, + prop: string + ): ChaiExpectation & { by(delta: number): ChaiExpectation } + + // Postman custom Chai assertions (available via pm.expect()) + /** + * Assert that value matches JSON Schema + * @param schema - JSON Schema object + */ + jsonSchema(schema: { + type?: string + required?: string[] + properties?: Record + items?: any + enum?: any[] + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + pattern?: string + minItems?: number + maxItems?: number + }): ChaiExpectation + + /** + * Assert that string value contains specific charset/encoding + * @param expectedCharset - Expected charset (e.g., 'utf-8', 'iso-8859-1') + */ + charset(expectedCharset: string): ChaiExpectation + + /** + * Assert that cookie exists and optionally has specific value + * @param cookieName - Name of the cookie + * @param cookieValue - Optional expected value + */ + cookie(cookieName: string, cookieValue?: string): ChaiExpectation + + /** + * Assert that JSON path exists and optionally has specific value + * @param path - JSONPath expression (e.g., '$.users[0].name') + * @param expectedValue - Optional expected value at path + */ + jsonPath(path: string, expectedValue?: any): ChaiExpectation + + // Legacy methods (kept for backward compatibility) toBe(value: any): void toBeLevel2xx(): void toBeLevel3xx(): void @@ -247,9 +461,32 @@ interface ExpectationMethods { toInclude(value: any): void } +// Legacy expectation interface for pw namespace (backward compatibility only) +interface LegacyExpectation extends LegacyExpectationMethods { + not: LegacyBaseExpectation +} + +interface LegacyBaseExpectation extends LegacyExpectationMethods {} + +interface LegacyExpectationMethods { + toBe(value: any): void + toBeLevel2xx(): void + toBeLevel3xx(): void + toBeLevel4xx(): void + toBeLevel5xx(): void + toBeType(type: string): void + toHaveLength(length: number): void + toInclude(value: any): void +} + +// Backward compatibility types for hopp and pm namespaces +interface Expectation extends ChaiExpectation {} +interface BaseExpectation extends ChaiExpectation {} +interface ExpectationMethods extends ChaiExpectation {} + declare namespace pw { function test(name: string, func: () => void): void - function expect(value: any): Expectation + function expect(value: any): LegacyExpectation const response: Readonly<{ status: number body: any @@ -269,15 +506,27 @@ declare namespace hopp { get(key: string): string | null getRaw(key: string): string | null getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void active: Readonly<{ get(key: string): string | null getRaw(key: string): string | null getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void }> global: Readonly<{ get(key: string): string | null getRaw(key: string): string | null getInitialRaw(key: string): string | null + set(key: string, value: string): void + delete(key: string): void + reset(key: string): void + setInitial(key: string, value: string): void }> }> @@ -300,9 +549,35 @@ declare namespace hopp { readonly responseTime: number body: Readonly<{ asText(): string - asJSON(): Record + asJSON(): any bytes(): Uint8Array }> + /** + * Get response body as text string + * @returns Response body as string + */ + text(): string + /** + * Get response body as parsed JSON + * @returns Parsed JSON object + */ + json(): any + /** + * Get HTTP reason phrase (status text) + * @returns HTTP reason phrase (e.g., "OK", "Not Found") + */ + reason(): string + /** + * Convert response to data URI format + * @returns Data URI string with base64 encoded content + */ + dataURI(): string + /** + * Parse JSONP response + * @param callbackName - Optional callback function name (default: "callback") + * @returns Parsed JSON object from JSONP wrapper + */ + jsonp(callbackName?: string): any }> const cookies: Readonly<{ @@ -315,7 +590,17 @@ declare namespace hopp { }> function test(name: string, testFunction: () => void): void - function expect(value: any): Expectation + + interface HoppExpectFunction { + (value: any): ChaiExpectation + /** + * Fail the test with a custom message + * @param message - Optional message to display on failure + */ + fail(message?: string): never + } + + const expect: HoppExpectFunction const info: Readonly<{ readonly eventName: "post-request" @@ -328,37 +613,258 @@ declare namespace hopp { declare namespace pm { const environment: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void + readonly name: string + get(key: string): any + set(key: string, value: any): void unset(key: string): void has(key: string): boolean - clear(): never - toObject(): never - }> - - const globals: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void - unset(key: string): void - has(key: string): boolean - clear(): never - toObject(): never - }> - - const variables: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void - has(key: string): boolean + clear(): void + toObject(): Record replaceIn(template: string): string }> + const globals: Readonly<{ + get(key: string): any + /** + * Set a global variable + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + unset(key: string): void + has(key: string): boolean + clear(): void + toObject(): Record + replaceIn(template: string): string + }> + + const variables: Readonly<{ + get(key: string): any + /** + * Set a variable in the active environment scope + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + has(key: string): boolean + replaceIn(template: string): string + toObject(): Record + }> + const request: Readonly<{ - readonly url: { toString(): string } + readonly url: Readonly<{ + toString(): string + readonly protocol: string + readonly host: string[] + readonly port: string + readonly path: string[] + readonly hash: string + + // URL Helper Methods (Postman-compatible) + /** + * Get the hostname as a string (e.g., "api.example.com") + * @returns The hostname portion of the URL + */ + getHost(): string + /** + * Get the path with leading slash (e.g., "/v1/users") + * @param unresolved - If true, returns unresolved path with variables (currently ignored) + * @returns The path portion of the URL + */ + getPath(unresolved?: boolean): string + /** + * Get the path with query string (e.g., "/v1/users?page=1") + * @returns Path and query string combined + */ + getPathWithQuery(): string + /** + * Get the query string without leading ? (e.g., "page=1&limit=20") + * @param options - Optional configuration (currently ignored) + * @returns Query string without the leading question mark + */ + getQueryString(options?: Record): string + /** + * Get the hostname with port (e.g., "api.example.com:8080") + * @param forcePort - If true, includes standard ports (80/443) + * @returns Hostname with port if non-standard or forced + */ + getRemote(forcePort?: boolean): string + + readonly query: Readonly<{ + /** + * Get the value of a query parameter by key + * @param key - Parameter key to retrieve + * @returns Parameter value or null if not found + */ + get(key: string): string | null + /** + * Check if a query parameter exists + * @param key - Parameter key to check + * @returns true if parameter exists, false otherwise + */ + has(key: string): boolean + /** + * Get all query parameters as a key-value object + * @returns Object with all query parameters + */ + all(): Record + /** + * Convert query parameters to object (alias for all()) + * @returns Object with all query parameters + */ + toObject(): Record + /** + * Get the number of query parameters + * @returns Count of parameters + */ + count(): number + /** + * Get a parameter by index + * @param index - Zero-based index + * @returns Parameter object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + /** + * Iterate over all query parameters + * @param callback - Function to call for each parameter + */ + each(callback: (param: { key: string; value: string }) => void): void + /** + * Map query parameters to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (param: { key: string; value: string }) => T): T[] + /** + * Filter query parameters + * @param callback - Predicate function + * @returns Array of parameters matching the predicate + */ + filter( + callback: (param: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a query parameter by string key or function predicate + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching parameter or null if not found + */ + find( + rule: string | ((param: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a query parameter + * @param item - String key or parameter object to find + * @returns Index of parameter, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + }> + }> + + /** + * Client certificate used for mutual TLS authentication + * In Postman, certificates are configured at the app/collection level, not programmatically in scripts + * Returns undefined in Hoppscotch as certificate configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/certificates/ + */ + readonly certificate: + | { + readonly name: string + readonly matches: string[] + readonly key: { readonly src: string } + readonly cert: { readonly src: string } + readonly passphrase?: string + } + | undefined + + /** + * Proxy configuration for the request + * In Postman, proxy is configured at the app level, not programmatically in scripts + * Returns undefined in Hoppscotch as proxy configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/capturing-request-data/proxy/ + */ + readonly proxy: + | { + readonly host: string + readonly port: number + readonly tunnel: boolean + readonly disabled: boolean + } + | undefined + readonly method: string readonly headers: Readonly<{ + /** + * Get the value of a header by name (case-insensitive) + * @param name - Header name to retrieve + * @returns Header value or null if not found + */ get(name: string): string | null + /** + * Check if a header exists (case-insensitive) + * @param name - Header name to check + * @returns true if header exists, false otherwise + */ has(name: string): boolean + /** + * Get all headers as a key-value object + * @returns Object with all headers (keys in lowercase) + */ all(): Record + /** + * Convert headers to object (alias for all()) + * @returns Object with all headers + */ + toObject(): Record + /** + * Get the number of headers + * @returns Count of headers + */ + count(): number + /** + * Get a header by index + * @param index - Zero-based index + * @returns Header object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + /** + * Iterate over all headers + * @param callback - Function to call for each header + */ + each(callback: (header: { key: string; value: string }) => void): void + /** + * Map headers to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (header: { key: string; value: string }) => T): T[] + /** + * Filter headers + * @param callback - Predicate function + * @returns Array of headers matching the predicate + */ + filter( + callback: (header: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a header by string name or function predicate (case-insensitive) + * @param rule - String name or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching header or null if not found + */ + find( + rule: string | ((header: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a header (case-insensitive) + * @param item - String name or header object to find + * @returns Index of header, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number }> readonly body: HoppRESTReqBody readonly auth: HoppRESTAuth @@ -368,29 +874,104 @@ declare namespace pm { readonly code: number readonly status: string readonly responseTime: number + readonly responseSize: number text(): string - json(): Record + json(): any stream: Uint8Array + /** + * Get HTTP reason phrase (status text) + * @returns HTTP reason phrase (e.g., "OK", "Not Found") + */ + reason(): string + /** + * Convert response to data URI format + * @returns Data URI string with base64 encoded content + */ + dataURI(): string + /** + * Parse JSONP response + * @param callbackName - Optional callback function name (default: "callback") + * @returns Parsed JSON object from JSONP wrapper + */ + jsonp(callbackName?: string): any headers: Readonly<{ get(name: string): string | null has(name: string): boolean all(): HoppRESTResponseHeader[] }> cookies: Readonly<{ - get(name: string): any - has(name: string): any - toObject(): any + get(name: string): string | null + has(name: string): boolean + toObject(): Record + }> + to: Readonly<{ + have: Readonly<{ + status(expectedCode: number): void + header(headerName: string, headerValue?: string): void + body(expectedBody: string): void + jsonBody(): void + jsonBody(key: string): void + jsonBody(key: string, expectedValue: any): void + jsonBody(schema: object): void + responseTime: Readonly<{ + below(ms: number): void + above(ms: number): void + }> + jsonSchema(schema: { + type?: string + required?: string[] + properties?: Record + items?: any + enum?: any[] + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + pattern?: string + minItems?: number + maxItems?: number + }): void + charset(expectedCharset: string): void + cookie(cookieName: string, cookieValue?: string): void + jsonPath(path: string, expectedValue?: any): void + }> + be: Readonly<{ + ok(): void + success(): void + accepted(): void + badRequest(): void + unauthorized(): void + forbidden(): void + notFound(): void + rateLimited(): void + serverError(): void + clientError(): void + json(): void + html(): void + xml(): void + text(): void + }> }> }> const cookies: Readonly<{ get(name: string): any set(name: string, value: string, options?: any): any - jar(): any + jar(): never }> function test(name: string, testFunction: () => void): void - function expect(value: any): Expectation + + interface ExpectFunction { + (value: any): ChaiExpectation + /** + * Fail the test with a custom message + * @param message - Optional message to display on failure + */ + fail(message?: string): never + } + + const expect: ExpectFunction const info: Readonly<{ readonly eventName: "post-request" @@ -400,28 +981,111 @@ declare namespace pm { readonly iterationCount: never }> - const sendRequest: () => never + /** + * Send an HTTP request (unsupported) + * @throws Error - sendRequest is not supported in Hoppscotch + */ + function sendRequest( + request: string | { url: string; method?: string; [key: string]: any }, + callback?: (err: any, response: any) => void + ): never + + /** + * Visualizer API (unsupported) + * The Postman Visualizer allows you to present response data as HTML templates with styling. + * This feature is not supported in Hoppscotch as it requires a browser-based visualization UI. + * @see https://learning.postman.com/docs/sending-requests/response-data/visualizer/ + */ + const visualizer: Readonly<{ + /** + * Set a Handlebars template to visualize response data (unsupported) + * @param layout - HTML template string with Handlebars syntax + * @param data - Data object to pass to the template + * @param options - Optional configuration object + * @throws Error - Visualizer is not supported in Hoppscotch + */ + set( + layout: string, + data?: Record, + options?: Record + ): never + + /** + * Clear the current visualization (unsupported) + * @throws Error - Visualizer is not supported in Hoppscotch + */ + clear(): never + }> + + /** + * Collection variables (unsupported - Workspace feature) + * Collection variables are not supported in Hoppscotch as they are a Postman Workspace feature + */ const collectionVariables: Readonly<{ - get(): never - set(): never - unset(): never - has(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never clear(): never toObject(): never + replaceIn(template: string): never }> + + /** + * Postman Vault (unsupported) + * Vault is not supported in Hoppscotch as it is a Postman-specific feature + */ const vault: Readonly<{ - get(): never - set(): never - unset(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never }> + + /** + * Iteration data (unsupported - Collection Runner feature) + * Iteration data is not supported in Hoppscotch as it requires Collection Runner + */ const iterationData: Readonly<{ - get(): never - set(): never - unset(): never - has(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never toObject(): never + toJSON(): never }> + + /** + * Execution control + */ const execution: Readonly<{ - setNextRequest(): never + /** + * Execution location identifier + * Always returns ["Hoppscotch"] with current = "Hoppscotch" + */ + readonly location: readonly string[] & { + readonly current: string + } + /** + * Set next request to execute (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the next request + */ + setNextRequest(requestNameOrId: string | null): never + /** + * Skip current request execution (unsupported - Collection Runner feature) + */ + skipRequest(): never + /** + * Run a request (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the request to run + */ + runRequest(requestNameOrId: string): never }> + + /** + * Import packages from Package Library (unsupported) + * @param packageName - Name of the package to import (e.g., '@team-domain/package-name' or 'npm:package-name@version') + * @returns The imported package module + * @throws Error - Package imports are not supported in Hoppscotch + */ + function require(packageName: string): never } diff --git a/packages/hoppscotch-common/src/types/pre-request.d.ts b/packages/hoppscotch-common/src/types/pre-request.d.ts index 3842a89b..50de8bbf 100644 --- a/packages/hoppscotch-common/src/types/pre-request.d.ts +++ b/packages/hoppscotch-common/src/types/pre-request.d.ts @@ -288,7 +288,7 @@ declare namespace hopp { * - Complete replacement: When all fields are provided, replaces entire body * * @param body - Partial or complete HoppRESTReqBody object - * + *` * @example * // Partial update - just change content type * hopp.request.setBody({ contentType: "application/xml" }) @@ -348,41 +348,465 @@ declare namespace hopp { declare namespace pm { const environment: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void + /** + * Get an environment variable value + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set an environment variable + * @param key - Variable key + * @param value - Variable value + */ + set(key: string, value: any): void + /** + * Remove an environment variable + * @param key - Variable key to remove + */ unset(key: string): void + /** + * Check if an environment variable exists + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ has(key: string): boolean - clear(): never - toObject(): never + /** + * Clear all environment variables in the active environment + */ + clear(): void + /** + * Get all environment variables as an object + * @returns Object with all environment variables as key-value pairs + */ + toObject(): Record }> const globals: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void + /** + * Get a global variable value + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set a global variable + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + /** + * Remove a global variable + * @param key - Variable key to remove + */ unset(key: string): void + /** + * Check if a global variable exists + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ has(key: string): boolean - clear(): never - toObject(): never + /** + * Clear all global variables + */ + clear(): void + /** + * Get all global variables as an object + * @returns Object with all global variables as key-value pairs + */ + toObject(): Record }> const variables: Readonly<{ - get(key: string): string | null - set(key: string, value: string): void + /** + * Get a variable value from either environment or global scope + * Environment variables take precedence over global variables + * @param key - Variable key + * @returns Variable value or undefined if not found + */ + get(key: string): any + /** + * Set a variable in the active environment scope + * @param key - Variable key + * @param value - Variable value (undefined is preserved, other types are coerced to strings) + */ + set(key: string, value: any): void + /** + * Check if a variable exists in either environment or global scope + * @param key - Variable key to check + * @returns true if variable exists, false otherwise + */ has(key: string): boolean + /** + * Replace variables in a template string + * @param template - Template string with {{variable}} placeholders + * @returns String with variables replaced with their values + */ replaceIn(template: string): string }> - const request: Readonly<{ - readonly url: { toString(): string } - readonly method: string - readonly headers: Readonly<{ + /** + * Request object with full Postman compatibility + * All properties are mutable in pre-request scripts to match Postman behavior + */ + let request: { + // ID and name (read-only) + readonly id: string + readonly name: string + + /** + * Client certificate used for mutual TLS authentication + * In Postman, certificates are configured at the app/collection level, not programmatically in scripts + * Returns undefined in Hoppscotch as certificate configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/certificates/ + */ + readonly certificate: + | { + readonly name: string + readonly matches: string[] + readonly key: { readonly src: string } + readonly cert: { readonly src: string } + readonly passphrase?: string + } + | undefined + + /** + * Proxy configuration for the request + * In Postman, proxy is configured at the app level, not programmatically in scripts + * Returns undefined in Hoppscotch as proxy configuration is handled at the application level + * @see https://learning.postman.com/docs/sending-requests/capturing-request-data/proxy/ + */ + readonly proxy: + | { + readonly host: string + readonly port: number + readonly tunnel: boolean + readonly disabled: boolean + } + | undefined + + // URL - Fully mutable with Postman URL object structure + // Intersection type allows both string assignment AND object access + url: string & { + toString(): string + protocol: string + host: string[] + port: string + path: string[] + hash: string + + // URL Helper Methods (Postman-compatible) + /** + * Get the hostname as a string (e.g., "api.example.com") + * @returns The hostname portion of the URL + */ + getHost(): string + /** + * Get the path with leading slash (e.g., "/v1/users") + * @param unresolved - If true, returns unresolved path with variables (currently ignored) + * @returns The path portion of the URL + */ + getPath(unresolved?: boolean): string + /** + * Get the path with query string (e.g., "/v1/users?page=1") + * @returns Path and query string combined + */ + getPathWithQuery(): string + /** + * Get the query string without leading ? (e.g., "page=1&limit=20") + * @param options - Optional configuration (currently ignored) + * @returns Query string without the leading question mark + */ + getQueryString(options?: Record): string + /** + * Get the hostname with port (e.g., "api.example.com:8080") + * @param forcePort - If true, includes standard ports (80/443) + * @returns Hostname with port if non-standard or forced + */ + getRemote(forcePort?: boolean): string + /** + * Update the entire URL from a string or object with toString() + * @param url - New URL string or object with toString() method + */ + update(url: string | { toString(): string }): void + /** + * Add multiple query parameters to the URL + * @param params - Array of parameter objects with key/value pairs + */ + addQueryParams(params: Array<{ key: string; value?: string }>): void + /** + * Remove query parameters by name + * @param params - Single parameter name or array of names to remove + */ + removeQueryParams(params: string | string[]): void + + query: { + // Read methods + /** + * Get the value of a query parameter by key + * @param key - Parameter key to retrieve + * @returns Parameter value or null if not found + */ + get(key: string): string | null + /** + * Check if a query parameter exists + * @param key - Parameter key to check + * @returns true if parameter exists, false otherwise + */ + has(key: string): boolean + /** + * Get all query parameters as a key-value object + * @returns Object with all query parameters + */ + all(): Record + /** + * Convert query parameters to object (alias for all()) + * @returns Object with all query parameters + */ + toObject(): Record + /** + * Get the number of query parameters + * @returns Count of parameters + */ + count(): number + /** + * Get a parameter by index + * @param index - Zero-based index + * @returns Parameter object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + // Iteration methods + /** + * Iterate over all query parameters + * @param callback - Function to call for each parameter + */ + each(callback: (param: { key: string; value: string }) => void): void + /** + * Map query parameters to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (param: { key: string; value: string }) => T): T[] + /** + * Filter query parameters + * @param callback - Predicate function + * @returns Array of parameters matching the predicate + */ + filter( + callback: (param: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a query parameter by string key or function predicate + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching parameter or null if not found + */ + find( + rule: string | ((param: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a query parameter + * @param item - String key or parameter object to find + * @returns Index of parameter, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + + // Mutation methods + /** + * Add a new query parameter + * @param param - Parameter to add + */ + add(param: { key: string; value: string }): void + /** + * Remove a query parameter by key + * @param key - Parameter key to remove + */ + remove(key: string): void + /** + * Update an existing parameter or add a new one + * @param param - Parameter to upsert + */ + upsert(param: { key: string; value: string }): void + /** + * Remove all query parameters + */ + clear(): void + /** + * Insert a query parameter before another parameter + * @param item - Parameter to insert + * @param before - String key or parameter object to insert before + */ + insert( + item: { key: string; value: string }, + before: string | { key: string; value: string } + ): void + /** + * Move a parameter to the end or append a new one + * @param item - Parameter to append + */ + append(item: { key: string; value: string }): void + /** + * Merge parameters from an array or object + * @param source - Array of parameters or key-value object + * @param prune - If true, remove parameters not in source + */ + assimilate( + source: + | Array<{ key: string; value: string }> + | Record, + prune?: boolean + ): void + } + } + + // Method - Mutable + method: string + + // Headers - With Postman mutation methods + headers: { + // Read methods + /** + * Get the value of a header by name (case-insensitive) + * @param name - Header name to retrieve + * @returns Header value or null if not found + */ get(name: string): string | null + /** + * Check if a header exists (case-insensitive) + * @param name - Header name to check + * @returns true if header exists, false otherwise + */ has(name: string): boolean - all(): HoppRESTHeader[] - }> - readonly body: any - readonly auth: any - }> + /** + * Get all headers as a key-value object + * @returns Object with all headers (keys in lowercase) + */ + all(): Record + /** + * Convert headers to object (alias for all()) + * @returns Object with all headers (keys in lowercase) + */ + toObject(): Record + /** + * Get the number of headers + * @returns Count of headers + */ + count(): number + /** + * Get a header by index + * @param index - Zero-based index + * @returns Header object or null if out of bounds + */ + idx(index: number): { key: string; value: string } | null + + // Iteration methods + /** + * Iterate over all headers + * @param callback - Function to call for each header + */ + each(callback: (header: { key: string; value: string }) => void): void + /** + * Map headers to a new array + * @param callback - Transform function + * @returns Array of transformed values + */ + map(callback: (header: { key: string; value: string }) => T): T[] + /** + * Filter headers + * @param callback - Predicate function + * @returns Array of headers matching the predicate + */ + filter( + callback: (header: { key: string; value: string }) => boolean + ): Array<{ key: string; value: string }> + /** + * Find a header by string key or function predicate (case-insensitive) + * @param rule - String key or predicate function + * @param context - Optional context to bind the predicate function + * @returns Matching header or null if not found + */ + find( + rule: string | ((header: { key: string; value: string }) => boolean), + context?: any + ): { key: string; value: string } | null + /** + * Get the index of a header (case-insensitive) + * @param item - String key or header object to find + * @returns Index of header, or -1 if not found + */ + indexOf(item: string | { key: string; value: string }): number + + // Mutation methods + /** + * Add a new header + * @param header - Header to add + */ + add(header: { key: string; value: string }): void + /** + * Remove a header by name (case-insensitive) + * @param headerName - Header name to remove + */ + remove(headerName: string): void + /** + * Update an existing header or add a new one + * @param header - Header to upsert + */ + upsert(header: { key: string; value: string }): void + /** + * Remove all headers + */ + clear(): void + /** + * Insert a header before another header + * @param item - Header to insert + * @param before - String key or header object to insert before + */ + insert( + item: { key: string; value: string }, + before: string | { key: string; value: string } + ): void + /** + * Move a header to the end or append a new one + * @param item - Header to append + */ + append(item: { key: string; value: string }): void + /** + * Merge headers from an array or object (case-insensitive) + * @param source - Array of headers or key-value object + * @param prune - If true, remove headers not in source + */ + assimilate( + source: Array<{ key: string; value: string }> | Record, + prune?: boolean + ): void + } + + // Body - With Postman update() method + // Uses HoppRESTReqBody for type safety with Postman's update() extension + body: HoppRESTReqBody & { + update( + body: + | string + | { + mode?: "raw" | "urlencoded" | "formdata" | "file" + raw?: string + urlencoded?: Array<{ key: string; value: string }> + formdata?: Array<{ key: string; value: string | File }> + file?: File + options?: { + raw?: { + language?: "json" | "text" | "html" | "xml" + } + } + } + ): void + } + + // Auth - Mutable with proper type safety + auth: HoppRESTAuth + } const info: Readonly<{ readonly eventName: "pre-request" @@ -393,27 +817,88 @@ declare namespace pm { }> const sendRequest: () => never + + /** + * Collection variables (unsupported - Workspace feature) + * Collection variables are not supported in Hoppscotch as they are a Postman Workspace feature + */ const collectionVariables: Readonly<{ - get(): never - set(): never - unset(): never - has(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never clear(): never toObject(): never + replaceIn(template: string): never }> + + /** + * Postman Vault (unsupported) + * Vault is not supported in Hoppscotch as it is a Postman-specific feature + */ const vault: Readonly<{ - get(): never - set(): never - unset(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never }> + + /** + * Iteration data (unsupported - Collection Runner feature) + * Iteration data is not supported in Hoppscotch as it requires Collection Runner + */ const iterationData: Readonly<{ - get(): never - set(): never - unset(): never - has(): never + get(key: string): never + set(key: string, value: string): never + unset(key: string): never + has(key: string): never toObject(): never + toJSON(): never }> + + /** + * Visualizer API (unsupported) + * The Postman Visualizer allows you to present response data as HTML templates with styling. + * This feature is not supported in Hoppscotch as it requires a browser-based visualization UI. + * @see https://learning.postman.com/docs/sending-requests/response-data/visualizer/ + */ + const visualizer: Readonly<{ + /** + * Set a Handlebars template to visualize response data (unsupported) + * @param layout - HTML template string with Handlebars syntax + * @param data - Data object to pass to the template + * @param options - Optional configuration object + * @throws Error - Visualizer is not supported in Hoppscotch + */ + set( + layout: string, + data?: Record, + options?: Record + ): never + + /** + * Clear the current visualization (unsupported) + * @throws Error - Visualizer is not supported in Hoppscotch + */ + clear(): never + }> + + /** + * Execution control + */ const execution: Readonly<{ - setNextRequest(): never + /** + * Set next request to execute (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the next request + */ + setNextRequest(requestNameOrId: string | null): never + /** + * Skip current request execution (unsupported - Collection Runner feature) + */ + skipRequest(): never + /** + * Run a request (unsupported - Collection Runner feature) + * @param requestNameOrId - Name or ID of the request to run + */ + runRequest(requestNameOrId: string): never }> } diff --git a/packages/hoppscotch-js-sandbox/package.json b/packages/hoppscotch-js-sandbox/package.json index 320a7f6e..cbc730c8 100644 --- a/packages/hoppscotch-js-sandbox/package.json +++ b/packages/hoppscotch-js-sandbox/package.json @@ -53,6 +53,7 @@ "dependencies": { "@hoppscotch/data": "workspace:^", "@types/lodash-es": "4.17.12", + "chai": "6.2.0", "faraday-cage": "0.1.0", "fp-ts": "2.16.11", "lodash": "4.17.21", @@ -61,6 +62,7 @@ "devDependencies": { "@digitak/esrun": "3.2.26", "@relmify/jest-fp-ts": "2.1.1", + "@types/chai": "5.2.2", "@types/jest": "30.0.0", "@types/lodash": "4.17.20", "@types/node": "24.9.1", diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts new file mode 100644 index 00000000..ce426039 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/async-await-support.spec.ts @@ -0,0 +1,149 @@ +/** + * Async/Await Support Tests + * + * Tests that pm.test() and hopp.test() properly support async functions with await, + * which is critical for Postman script imports that use asynchronous patterns. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.test() - Async/Await Support", (namespace) => { + test("should support async function with await", () => { + return expect( + runTest(` + ${namespace}.test("async with await", async function() { + const promise = new Promise((resolve) => { + resolve(42) + }) + const result = await promise + ${namespace}.expect(result).to.equal(42) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) + + test("should support async arrow function", () => { + return expect( + runTest(` + ${namespace}.test("async arrow", async () => { + const result = await Promise.resolve("success") + ${namespace}.expect(result).to.equal("success") + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) + + test("should support Promise.all with await", () => { + return expect( + runTest(` + ${namespace}.test("Promise.all", async function() { + const results = await Promise.all([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ]) + ${namespace}.expect(results).to.eql([1, 2, 3]) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) + + test("should support async error handling", () => { + return expect( + runTest(` + ${namespace}.test("async error", async function() { + try { + await Promise.reject(new Error("test error")) + ${namespace}.expect.fail("Should not reach here") + } catch (error) { + ${namespace}.expect(error.message).to.equal("test error") + } + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) + + test("should support multiple sequential awaits", () => { + return expect( + runTest(` + ${namespace}.test("sequential awaits", async function() { + const a = await Promise.resolve(10) + const b = await Promise.resolve(20) + const c = await Promise.resolve(30) + ${namespace}.expect(a + b + c).to.equal(60) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) + + test("should support async IIFE pattern", () => { + return expect( + runTest(` + ${namespace}.test("async IIFE", async function() { + const result = await (async () => { + const data = await Promise.resolve({ value: 100 }) + return data.value * 2 + })() + ${namespace}.expect(result).to.equal(200) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts new file mode 100644 index 00000000..fa49e24a --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/core-chai-assertions.spec.ts @@ -0,0 +1,607 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Core Chai Assertions", (namespace) => { + describe("Equality Assertions", () => { + test("should support `.equal()` for strict equality", () => { + return expect( + runTest(` + ${namespace}.test("equality assertions", () => { + ${namespace}.expect(42).to.equal(42) + ${namespace}.expect('test').to.equal('test') + ${namespace}.expect(true).to.equal(true) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "equality assertions", + expectResults: [ + { status: "pass", message: "Expected 42 to equal 42" }, + { status: "pass", message: "Expected 'test' to equal 'test'" }, + { status: "pass", message: "Expected true to equal true" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.eql()` for deep equality", () => { + return expect( + runTest(` + ${namespace}.test("deep equality", () => { + ${namespace}.expect({a: 1}).to.eql({a: 1}) + ${namespace}.expect([1, 2, 3]).to.eql([1, 2, 3]) + ${namespace}.expect({nested: {value: 42}}).to.eql({nested: {value: 42}}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep equality", + expectResults: [ + { status: "pass", message: "Expected {a: 1} to eql {a: 1}" }, + { + status: "pass", + message: "Expected [1, 2, 3] to eql [1, 2, 3]", + }, + { status: "pass", message: expect.stringContaining("to eql") }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Type Assertions", () => { + test("should assert primitive types with `.a()` and `.an()`", () => { + return expect( + runTest(` + ${namespace}.test("type assertions", () => { + ${namespace}.expect('foo').to.be.a('string') + ${namespace}.expect({a: 1}).to.be.an('object') + ${namespace}.expect([1, 2, 3]).to.be.an('array') + ${namespace}.expect(42).to.be.a('number') + ${namespace}.expect(true).to.be.a('boolean') + ${namespace}.expect(null).to.be.a('null') + ${namespace}.expect(undefined).to.be.an('undefined') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "type assertions", + expectResults: [ + { status: "pass", message: "Expected 'foo' to be a string" }, + { status: "pass", message: "Expected {a: 1} to be an object" }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { status: "pass", message: "Expected 42 to be a number" }, + { status: "pass", message: "Expected true to be a boolean" }, + { status: "pass", message: "Expected null to be a null" }, + { + status: "pass", + message: "Expected undefined to be an undefined", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Truthiness Assertions", () => { + test("should support `.true`, `.false`, `.ok` assertions", () => { + return expect( + runTest(` + ${namespace}.test("truthiness", () => { + ${namespace}.expect(true).to.be.true + ${namespace}.expect(false).to.be.false + ${namespace}.expect(1).to.be.ok + ${namespace}.expect('hello').to.be.ok + ${namespace}.expect(0).to.not.be.ok + ${namespace}.expect('').to.not.be.ok + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "truthiness", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Numerical Comparisons", () => { + test("should support `.above()` and `.below()` comparisons", () => { + return expect( + runTest(` + ${namespace}.test("numerical comparisons", () => { + ${namespace}.expect(10).to.be.above(5) + ${namespace}.expect(5).to.be.below(10) + ${namespace}.expect(5).to.not.be.above(10) + ${namespace}.expect(10).to.not.be.below(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "numerical comparisons", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.within()` for range comparisons", () => { + return expect( + runTest(` + ${namespace}.test("within range", () => { + ${namespace}.expect(5).to.be.within(1, 10) + ${namespace}.expect(1).to.be.within(1, 10) + ${namespace}.expect(10).to.be.within(1, 10) + ${namespace}.expect(0).to.not.be.within(1, 10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "within range", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.closeTo()` for floating point comparisons", () => { + return expect( + runTest(` + ${namespace}.test("close to", () => { + ${namespace}.expect(1.5).to.be.closeTo(1.0, 0.6) + ${namespace}.expect(10.5).to.be.closeTo(10.0, 0.5) + ${namespace}.expect(5.1).to.not.be.closeTo(5.0, 0.05) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "close to", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Property Assertions", () => { + test("should support `.property()` for property existence and value", () => { + return expect( + runTest(` + ${namespace}.test("property assertions", () => { + ${namespace}.expect({a: 1}).to.have.property('a') + ${namespace}.expect({a: 1}).to.have.property('a', 1) + ${namespace}.expect({x: {y: 2}}).to.have.property('x') + ${namespace}.expect({}).to.not.have.property('missing') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "property assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.ownProperty()` for own properties", () => { + return expect( + runTest(` + ${namespace}.test("own property", () => { + ${namespace}.expect({a: 1}).to.have.ownProperty('a') + ${namespace}.expect({a: 1}).to.have.ownProperty('a', 1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "own property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Collection Assertions", () => { + test("should support `.include()` for arrays and strings", () => { + return expect( + runTest(` + ${namespace}.test("include assertions", () => { + ${namespace}.expect([1, 2, 3]).to.include(2) + ${namespace}.expect('hoppscotch').to.include('hopp') + ${namespace}.expect({a: 1, b: 2}).to.include({a: 1}) + ${namespace}.expect([1, 2, 3]).to.not.include(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Negation with `.not`", () => { + test("should support negation for all assertion types", () => { + return expect( + runTest(` + ${namespace}.test("negation works", () => { + ${namespace}.expect(42).to.not.equal(43) + ${namespace}.expect('foo').to.not.be.a('number') + ${namespace}.expect(false).to.not.be.true + ${namespace}.expect(5).to.not.be.above(10) + ${namespace}.expect({a: 1}).to.not.have.property('b') + ${namespace}.expect([1, 2]).to.not.include(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Deep Equality Modifiers", () => { + test("should support `.deep.equal()` and `.deep.property()`", () => { + return expect( + runTest(` + ${namespace}.test("deep modifiers", () => { + ${namespace}.expect({a: {b: 1}}).to.deep.equal({a: {b: 1}}) + ${namespace}.expect({a: {b: 1}}).to.have.deep.property('a', {b: 1}) + ${namespace}.expect([{x: 1}]).to.deep.include({x: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep modifiers", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("Language Chains", () => { + test("should support basic language chain properties (`.to`, `.be`, `.that`)", () => { + return expect( + runTest(` + ${namespace}.test("basic language chains", () => { + ${namespace}.expect(2).to.equal(2) + ${namespace}.expect(2).to.be.equal(2) + ${namespace}.expect(2).to.be.a('number').that.equals(2) + ${namespace}.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "basic language chains", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain type assertion with include using `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and chain - type and include", () => { + ${namespace}.expect('hello world').to.be.a('string').and.include('world') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and chain - type and include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain type assertion with lengthOf using `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and chain - type and length", () => { + ${namespace}.expect([1, 2, 3]).to.be.an('array').and.have.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and chain - type and length", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support complex chaining with multiple `.and` and `.that`", () => { + return expect( + runTest(` + ${namespace}.test("complex and chaining", () => { + ${namespace}.expect({ name: 'John', age: 30 }) + .to.be.an('object') + .and.have.property('name') + .that.is.a('string') + .and.equals('John') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex and chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should work with `.and.not` for negation", () => { + return expect( + runTest(` + ${namespace}.test("and with negation", () => { + ${namespace}.expect({ a: 1, b: 2 }) + .to.be.an('object') + .and.not.be.empty + .and.not.have.property('c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and with negation", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should chain numeric comparisons with `.and`", () => { + return expect( + runTest(` + ${namespace}.test("and with numbers", () => { + ${namespace}.expect(200) + .to.be.a('number') + .and.be.within(200, 299) + .and.equal(200) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "and with numbers", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) +}) + +describe.each(NAMESPACES)( + "%s.expect() - Basic Function Assertions", + (namespace) => { + test("should support `.throw()` for error throwing", () => { + return expect( + runTest(` + ${namespace}.test("throw assertions", () => { + ${namespace}.expect(() => { throw new Error('oops') }).to.throw() + ${namespace}.expect(() => { throw new Error('oops') }).to.throw(Error) + ${namespace}.expect(() => {}).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.respondTo()` for method existence", () => { + return expect( + runTest(` + ${namespace}.test("respondTo assertions", () => { + const obj = { method: () => {} } + ${namespace}.expect(obj).to.respondTo('method') + ${namespace}.expect([]).to.respondTo('push') + ${namespace}.expect('').to.respondTo('charAt') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` for custom predicates", () => { + return expect( + runTest(` + ${namespace}.test("satisfy assertions", () => { + ${namespace}.expect(2).to.satisfy((n) => n > 0) + ${namespace}.expect(10).to.satisfy((n) => n % 2 === 0) + ${namespace}.expect('hello').to.satisfy((s) => s.length > 3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy assertions", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts new file mode 100644 index 00000000..987fe543 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/deep-include-keys-assertions.spec.ts @@ -0,0 +1,364 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - deep.include() - Object Property Inclusion", + (namespace) => { + test("should pass when object deeply includes partial match", async () => { + const testScript = ` + ${namespace}.test("deep.include() - object property inclusion", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.deep.include({ a: 1, b: 2 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - object property inclusion", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when object does not include properties", async () => { + const testScript = ` + ${namespace}.test("deep.include() - negated", (namespace) => { + ${namespace}.expect({ a: 1, b: 2 }).to.not.deep.include({ a: 1, c: 3 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with nested objects", async () => { + const testScript = ` + ${namespace}.test("deep.include() - nested objects", (namespace) => { + const obj = { a: { b: { c: 1 } }, d: 2 }; + ${namespace}.expect(obj).to.deep.include({ a: { b: { c: 1 } } }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - nested objects", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - deep.include() - Array Object Inclusion", + (namespace) => { + test("should pass when array deeply includes object", async () => { + const testScript = ` + ${namespace}.test("deep.include() - array object inclusion", (namespace) => { + ${namespace}.expect([{ id: 1 }, { id: 2 }]).to.deep.include({ id: 1 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - array object inclusion", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when array does not include object", async () => { + const testScript = ` + ${namespace}.test("deep.include() - array negated", (namespace) => { + ${namespace}.expect([{ id: 1 }, { id: 2 }]).to.not.deep.include({ id: 3 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - array negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with exact nested objects in arrays", async () => { + const testScript = ` + ${namespace}.test("deep.include() - complex array objects", (namespace) => { + const arr = [ + { name: 'John', age: 30, address: { city: 'NYC' } }, + { name: 'Jane', age: 25, address: { city: 'LA' } } + ]; + // deep.include on arrays requires exact object match + ${namespace}.expect(arr).to.deep.include({ name: 'John', age: 30, address: { city: 'NYC' } }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.include() - complex array objects", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - include.deep() - Alternative Syntax", + (namespace) => { + test("should work with include.deep syntax", async () => { + const testScript = ` + ${namespace}.test("include.deep() - alternative syntax", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.deep({ a: 1, b: 2 }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.deep() - alternative syntax", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - include.keys() - Partial Key Matching", + (namespace) => { + test("should pass when object has at least the specified keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - partial key matching", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys('a', 'b'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - partial key matching", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass even when object has extra keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - with extra keys", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3, d: 4, e: 5 }).to.include.keys('a', 'b'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - with extra keys", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass with negation when object does not have key", async () => { + const testScript = ` + ${namespace}.test("include.keys() - negated", (namespace) => { + ${namespace}.expect({ a: 1, b: 2 }).to.not.include.keys('c'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - negated", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with array of keys", async () => { + const testScript = ` + ${namespace}.test("include.keys() - array syntax", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys(['a', 'b']); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - array syntax", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with single key", async () => { + const testScript = ` + ${namespace}.test("include.keys() - single key", (namespace) => { + ${namespace}.expect({ a: 1, b: 2, c: 3 }).to.include.keys('a'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.keys() - single key", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)("%s.expect() - Combination Patterns", (namespace) => { + test("should work with chained assertions", async () => { + const testScript = ` + ${namespace}.test("chained deep.include", (namespace) => { + const response = { status: 'success', data: { user: { id: 1, name: 'John' } } }; + ${namespace}.expect(response).to.be.an('object') + .and.deep.include({ status: 'success' }); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "chained deep.include", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with response validation patterns", async () => { + const testScript = ` + ${namespace}.test("response validation with deep.include", (namespace) => { + const jsonData = { status: 200, message: 'OK', data: { results: [] } }; + ${namespace}.expect(jsonData).to.deep.include({ status: 200, message: 'OK' }); + ${namespace}.expect(jsonData).to.include.keys('status', 'message', 'data'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "response validation with deep.include", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts new file mode 100644 index 00000000..c78f0f5a --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/instanceof-assertions.spec.ts @@ -0,0 +1,503 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Built-in types", + (namespace) => { + test("should support instanceof Object for plain objects", async () => { + const testScript = ` + ${namespace}.test("instanceof Object", () => { + const obj = { key: "value" }; + ${namespace}.expect(obj).to.be.an.instanceof(Object); + ${namespace}.expect(obj).to.be.instanceOf(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Object", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Array", async () => { + const testScript = ` + ${namespace}.test("instanceof Array", () => { + const arr = [1, 2, 3]; + ${namespace}.expect(arr).to.be.an.instanceof(Array); + ${namespace}.expect(arr).to.be.instanceOf(Array); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Array", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Date", async () => { + const testScript = ` + ${namespace}.test("instanceof Date", () => { + const date = new Date(); + ${namespace}.expect(date).to.be.an.instanceof(Date); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Date", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof RegExp", async () => { + const testScript = ` + ${namespace}.test("instanceof RegExp", () => { + const regex = /test/; + ${namespace}.expect(regex).to.be.an.instanceof(RegExp); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof RegExp", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof Error", async () => { + const testScript = ` + ${namespace}.test("instanceof Error", () => { + const err = new Error("test error"); + ${namespace}.expect(err).to.be.an.instanceof(Error); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof Error", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support arrays as instanceof Object", async () => { + const testScript = ` + ${namespace}.test("array instanceof Object", () => { + const arr = [1, 2, 3]; + ${namespace}.expect(arr).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array instanceof Object", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Custom classes", + (namespace) => { + test("should support instanceof with custom class definitions", async () => { + const testScript = ` + ${namespace}.test("custom class instanceof", () => { + class Person { + constructor(name) { + this.name = name; + } + } + + const john = new Person("John"); + ${namespace}.expect(john).to.be.an.instanceof(Person); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class instanceof", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof with ES6 class syntax", async () => { + const testScript = ` + ${namespace}.test("ES6 class instanceof", () => { + class Animal { + constructor(type) { + this.type = type; + } + } + + class Dog extends Animal { + constructor(name) { + super("dog"); + this.name = name; + } + } + + const rover = new Dog("Rover"); + ${namespace}.expect(rover).to.be.an.instanceof(Dog); + ${namespace}.expect(rover).to.be.an.instanceof(Animal); + ${namespace}.expect(rover).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ES6 class instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support instanceof with constructor functions", async () => { + const testScript = ` + ${namespace}.test("constructor function instanceof", () => { + function Car(make, model) { + this.make = make; + this.model = model; + } + + const tesla = new Car("Tesla", "Model 3"); + ${namespace}.expect(tesla).to.be.an.instanceof(Car); + ${namespace}.expect(tesla).to.be.an.instanceof(Object); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "constructor function instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes in response data context", async () => { + const testScript = ` + ${namespace}.test("custom class with response data", () => { + class ResponseData { + constructor(data) { + this.data = data; + } + } + + const responseData = pm.response.json(); + const wrapped = new ResponseData(responseData); + + ${namespace}.expect(wrapped).to.be.an.instanceof(ResponseData); + ${namespace}.expect(wrapped).to.be.an.instanceof(Object); + }); + ` + + const mockResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [], + body: { test: "data" }, + } + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class with response data", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Custom classes with other assertions", + (namespace) => { + test("should work with custom classes in property assertions", async () => { + const testScript = ` + ${namespace}.test("custom class with properties", () => { + class User { + constructor(name, age) { + this.name = name; + this.age = age; + } + } + + const user = new User("Alice", 30); + + ${namespace}.expect(user).to.be.an.instanceof(User); + ${namespace}.expect(user).to.have.property("name", "Alice"); + ${namespace}.expect(user).to.have.property("age", 30); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class with properties", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes in array assertions", async () => { + const testScript = ` + ${namespace}.test("array of custom class instances", () => { + class Item { + constructor(id) { + this.id = id; + } + } + + const items = [new Item(1), new Item(2), new Item(3)]; + + ${namespace}.expect(items).to.be.an.instanceof(Array); + ${namespace}.expect(items).to.have.lengthOf(3); + ${namespace}.expect(items[0]).to.be.an.instanceof(Item); + ${namespace}.expect(items[1]).to.be.an.instanceof(Item); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array of custom class instances", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with custom classes and deep equality", async () => { + const testScript = ` + ${namespace}.test("custom class deep equality", () => { + class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + } + + const p1 = new Point(10, 20); + const p2 = new Point(10, 20); + + ${namespace}.expect(p1).to.be.an.instanceof(Point); + ${namespace}.expect(p2).to.be.an.instanceof(Point); + ${namespace}.expect(p1).to.deep.equal(p2); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom class deep equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - instanceof assertions - Negation and failure cases", + (namespace) => { + test("should support negation with .not.instanceof", async () => { + const testScript = ` + ${namespace}.test("negated instanceof", () => { + const str = "hello"; + const num = 42; + const arr = [1, 2, 3]; + + ${namespace}.expect(str).to.not.be.an.instanceof(Number); + ${namespace}.expect(num).to.not.be.an.instanceof(String); + ${namespace}.expect(arr).to.not.be.an.instanceof(String); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "negated instanceof", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when instanceof check fails", async () => { + const testScript = ` + ${namespace}.test("instanceof failure", () => { + const str = "hello"; + ${namespace}.expect(str).to.be.an.instanceof(Array); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "instanceof failure", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts new file mode 100644 index 00000000..8309bdc4 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/keys-members-assertions.spec.ts @@ -0,0 +1,443 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/issues/5489 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Keys Assertions", (namespace) => { + describe("keys() method", () => { + test("should accept array syntax: keys(['a', 'b'])", () => { + return expect( + runTest(` + ${namespace}.test('Keys with array syntax', function () { + ${namespace}.expect({a: 1, b: 2}).to.have.keys(['a','b']); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Keys with array syntax", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should accept spread syntax: keys('a', 'b')", () => { + return expect( + runTest(` + ${namespace}.test('Keys with spread syntax', function () { + ${namespace}.expect({a: 1, b: 2}).to.have.keys('a','b'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Keys with spread syntax", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation with array syntax", () => { + return expect( + runTest(` + ${namespace}.test('Negated keys with array', function () { + ${namespace}.expect({a: 1}).to.not.have.keys(['b','c']); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated keys with array", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to not have keys 'b', 'c'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation with spread syntax", () => { + return expect( + runTest(` + ${namespace}.test('Negated keys with spread', function () { + ${namespace}.expect({x:5}).to.not.have.keys('y','z'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated keys with spread", + expectResults: [ + { + status: "pass", + message: "Expected {x: 5} to not have keys 'y', 'z'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("key() singular method", () => { + test("should accept single string argument", () => { + return expect( + runTest(` + ${namespace}.test('Single key check', function () { + ${namespace}.expect({name: 'test'}).to.have.key('name'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Single key check", + expectResults: [ + { + status: "pass", + message: "Expected {name: 'test'} to have keys 'name'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation: not.have.key('z')", () => { + return expect( + runTest(` + ${namespace}.test('Negated key assertion', function () { + ${namespace}.expect({x:5}).to.not.have.key('z'); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated key assertion", + expectResults: [ + { + status: "pass", + message: "Expected {x: 5} to not have keys 'z'", + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) +}) + +describe.each(NAMESPACES)("%s.expect() - Members Assertions", (namespace) => { + describe("members() method", () => { + test("should match members in any order", () => { + return expect( + runTest(` + ${namespace}.test('Members matching', function () { + ${namespace}.expect([1,2,3]).to.have.members([3,2,1]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Members matching", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [3, 2, 1]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation", () => { + return expect( + runTest(` + ${namespace}.test('Negated members', function () { + ${namespace}.expect([1,2,3]).to.not.have.members([4,5,6]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to not have members [4, 5, 6]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should fail when members don't match", () => { + return expect( + runTest(` + ${namespace}.test('Members mismatch', function () { + ${namespace}.expect([1,2]).to.have.members([1,2,3]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Members mismatch", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to have members"), + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("include.members() method", () => { + test("should match subset of members", () => { + return expect( + runTest(` + ${namespace}.test('Include members', function () { + ${namespace}.expect([1,2,3]).to.include.members([2]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Include members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include members [2]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should match multiple subset members", () => { + return expect( + runTest(` + ${namespace}.test('Multiple include members', function () { + ${namespace}.expect([1,2,3,4,5]).to.include.members([2,4]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Multiple include members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3, 4, 5] to include members [2, 4]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should support negation: not.include.members([5])", () => { + return expect( + runTest(` + ${namespace}.test('Negated include.members', function () { + ${namespace}.expect([1,2,3]).to.not.include.members([5]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Negated include.members", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to not include members [5]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + + test("should fail when subset members not present", () => { + return expect( + runTest(` + ${namespace}.test('Missing subset members', function () { + ${namespace}.expect([1,2,3]).to.include.members([4,5]); + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Missing subset members", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to include members"), + }, + ], + children: [], + }, + ], + }, + ]) + }) + }) +}) + +describe.each(NAMESPACES)( + "%s.expect() - Combined Keys and Members", + (namespace) => { + test("should handle all assertions from the reported issue", () => { + return expect( + runTest(` + ${namespace}.test('Contains and includes', function () { + var arr = [1,2,3]; + ${namespace}.expect(arr).to.include(2); + ${namespace}.expect('hoppscotch').to.include('hopp'); + ${namespace}.expect({a: 1, b: 2}).to.have.keys(['a','b']); + ${namespace}.expect({x:5}).to.not.have.key('z'); + }); + + ${namespace}.test('Members matching', function () { + ${namespace}.expect([1,2,3]).to.have.members([3,2,1]); + ${namespace}.expect([1,2,3]).to.include.members([2]); + ${namespace}.expect([1,2,3]).to.not.include.members([5]); + }); + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "Contains and includes", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include 2", + }, + { + status: "pass", + message: "Expected 'hoppscotch' to include 'hopp'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have keys 'a', 'b'", + }, + { + status: "pass", + message: "Expected {x: 5} to not have keys 'z'", + }, + ], + children: [], + }, + { + descriptor: "Members matching", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [3, 2, 1]", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to include members [2]", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to not include members [5]", + }, + ], + children: [], + }, + ], + }, + ]) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts new file mode 100644 index 00000000..8713e2b4 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/length-assertions.spec.ts @@ -0,0 +1,604 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)("%s.expect() - Length Assertions", (namespace) => { + const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [{ key: "content-type", value: "application/json" }], + body: { + items: ["apple", "banana", "cherry"], + emptyArray: [], + singleItem: ["solo"], + data: { + nested: { + values: [1, 2, 3, 4, 5], + }, + }, + }, + } + + describe(".length getter - Basic comparison methods", () => { + test("should support .length.above() for arrays", async () => { + const testScript = ` + ${namespace}.test("length.above()", () => { + ${namespace}.expect([1, 2, 3, 4]).to.have.length.above(3); + ${namespace}.expect([1, 2]).to.not.have.length.above(5); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.above()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .length.above() for strings", async () => { + const testScript = ` + ${namespace}.test("string length.above()", () => { + ${namespace}.expect('hello world').to.have.length.above(5); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "string length.above()", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .length.below() for arrays", async () => { + const testScript = ` + ${namespace}.test("length.below()", () => { + ${namespace}.expect([1, 2]).to.have.length.below(5); + ${namespace}.expect([1, 2, 3, 4]).to.not.have.length.below(3); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.below()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .length.within() for range checks", async () => { + const testScript = ` + ${namespace}.test("length.within()", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.within(2, 5); + ${namespace}.expect('test').to.have.length.within(1, 10); + ${namespace}.expect([1]).to.not.have.length.within(5, 10); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length.within()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.at.least() and .length.at.most() - Postman chain syntax", () => { + test("should pass when array length meets minimum (.at.least)", async () => { + const script = ` + ${namespace}.test("Array length at least", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.least(1) + ${namespace}.expect(items).to.have.length.at.least(3) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array length at least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when array length below minimum (.at.least)", async () => { + const script = ` + ${namespace}.test("Array too short", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.least(10) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array too short", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass when array length within maximum (.at.most)", async () => { + const script = ` + ${namespace}.test("Array length at most", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.most(10) + ${namespace}.expect(items).to.have.length.at.most(3) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array length at most", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when array length exceeds maximum (.at.most)", async () => { + const script = ` + ${namespace}.test("Array too long", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.at.most(2) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Array too long", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.least() and .length.most() - Direct methods without .at", () => { + test("should support .length.least() without .at chain", async () => { + const script = ` + ${namespace}.test("Direct least", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.least(1) + ${namespace}.expect([1, 2, 3]).to.have.length.least(3) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Direct least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support mixed syntax with and without .at", async () => { + const script = ` + ${namespace}.test("Mixed syntax", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.length.least(1) + ${namespace}.expect(items).to.have.length.at.least(1) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Mixed syntax", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length.gte() and .length.lte() - Aliases", () => { + test("should support .length.gte() as alias for .least()", async () => { + const script = ` + ${namespace}.test("GTE alias", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.gte(3) + ${namespace}.expect([1, 2, 3]).to.have.length.gte(1) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "GTE alias", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support .length.lte() as alias for .most()", async () => { + const script = ` + ${namespace}.test("LTE alias", () => { + ${namespace}.expect([1, 2, 3]).to.have.length.lte(3) + ${namespace}.expect([1, 2, 3]).to.have.length.lte(10) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "LTE alias", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe(".length(n) - Callable method for exact length", () => { + test("should support .length(n) as method for exact length", async () => { + const testScript = ` + ${namespace}.test("length as method", () => { + ${namespace}.expect([1, 2, 3]).to.have.length(3); + ${namespace}.expect('abc').to.have.length(3); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length as method", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".lengthOf(n) - Method for exact length", () => { + test("should support .lengthOf(n) for exact length", async () => { + const testScript = ` + ${namespace}.test("lengthOf()", () => { + ${namespace}.expect('hello').to.have.lengthOf(5); + ${namespace}.expect([1, 2, 3, 4, 5]).to.have.lengthOf(5); + ${namespace}.expect('').to.have.lengthOf(0); + }); + ` + + const result = await runTest(testScript, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf()", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe(".lengthOf.at.least() and .lengthOf.at.most() - Alternative syntax", () => { + const lengthOfMockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 100, + headers: [], + body: { + items: ["a", "b", "c", "d"], + }, + } + + test("should support .lengthOf.at.least()", async () => { + const script = ` + ${namespace}.test("lengthOf.at.least", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.lengthOf.at.least(1) + ${namespace}.expect(items).to.have.lengthOf.at.least(4) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + lengthOfMockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf.at.least", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support .lengthOf.at.most()", async () => { + const script = ` + ${namespace}.test("lengthOf.at.most", () => { + const items = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.items + ${namespace}.expect(items).to.have.lengthOf.at.most(10) + ${namespace}.expect(items).to.have.lengthOf.at.most(4) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + lengthOfMockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "lengthOf.at.most", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe("Edge cases and special scenarios", () => { + test("should work with empty arrays", async () => { + const script = ` + ${namespace}.test("Empty array", () => { + const empty = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.emptyArray + ${namespace}.expect(empty).to.have.length.at.least(0) + ${namespace}.expect(empty).to.have.length.at.most(0) + ${namespace}.expect(empty).to.have.length(0) + ${namespace}.expect(empty).to.have.lengthOf(0) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Empty array", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with strings", async () => { + const script = ` + ${namespace}.test("String length", () => { + const str = "hello world" + ${namespace}.expect(str).to.have.length.at.least(5) + ${namespace}.expect(str).to.have.length.at.most(20) + ${namespace}.expect(str).to.have.length(11) + ${namespace}.expect(str).to.have.lengthOf(11) + }) + ` + + const result = await runTest(script, { global: [], selected: [] })() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "String length", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with nested arrays", async () => { + const script = ` + ${namespace}.test("Nested array length", () => { + const nested = ${namespace === "pm" ? "pm.response.json()" : "hopp.response.body.asJSON()"}.data.nested.values + ${namespace}.expect(nested).to.have.length.at.least(5) + ${namespace}.expect(nested).to.have.lengthOf(5) + }) + ` + + const result = await runTest( + script, + { global: [], selected: [] }, + mockResponse + )() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Nested array length", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts new file mode 100644 index 00000000..56ece7d5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/side-effects-assertions.spec.ts @@ -0,0 +1,426 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +const NAMESPACES = ["pm", "hopp"] as const + +describe.each(NAMESPACES)( + "%s.expect() - Side Effect Assertions (Standard Pattern)", + (namespace) => { + describe("`.change()` with object and property", () => { + test("should detect property changes", () => { + return expect( + runTest(` + ${namespace}.test("change assertions work", () => { + const obj = { val: 0 } + ${namespace}.expect(() => { obj.val = 5 }).to.change(obj, 'val') + ${namespace}.expect(() => { obj.val = 5 }).to.not.change(obj, 'val') + ${namespace}.expect(() => {}).to.not.change(obj, 'val') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect changes by specific delta using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("change by delta works", () => { + const obj = { val: 0 } + ${namespace}.expect(() => { obj.val += 5 }).to.change(obj, 'val').by(5) + ${namespace}.expect(() => { obj.val -= 3 }).to.change(obj, 'val').by(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change by delta works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support negative delta", () => { + return expect( + runTest(` + ${namespace}.test("change with negative delta", () => { + const obj = { value: 50 } + const decreaseValue = () => { obj.value = 30 } + ${namespace}.expect(decreaseValue).to.change(obj, "value").by(-20) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change with negative delta", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.increase()` with object and property", () => { + test("should detect property increases", () => { + return expect( + runTest(` + ${namespace}.test("increase assertions work", () => { + const obj = { count: 0 } + ${namespace}.expect(() => { obj.count++ }).to.increase(obj, 'count') + ${namespace}.expect(() => { obj.count += 5 }).to.increase(obj, 'count') + ${namespace}.expect(() => { obj.count-- }).to.not.increase(obj, 'count') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect increases by specific amount using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("increase by amount works", () => { + const obj = { count: 0 } + ${namespace}.expect(() => { obj.count += 3 }).to.increase(obj, 'count').by(3) + ${namespace}.expect(() => { obj.count += 7 }).to.increase(obj, 'count').by(7) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase by amount works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.decrease()` with object and property", () => { + test("should detect property decreases", () => { + return expect( + runTest(` + ${namespace}.test("decrease assertions work", () => { + const obj = { count: 10 } + ${namespace}.expect(() => { obj.count-- }).to.decrease(obj, 'count') + ${namespace}.expect(() => { obj.count -= 3 }).to.decrease(obj, 'count') + ${namespace}.expect(() => { obj.count++ }).to.not.decrease(obj, 'count') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease assertions work", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should detect decreases by specific amount using `.by()`", () => { + return expect( + runTest(` + ${namespace}.test("decrease by amount works", () => { + const obj = { count: 10 } + ${namespace}.expect(() => { obj.count -= 2 }).to.decrease(obj, 'count').by(2) + ${namespace}.expect(() => { obj.count -= 4 }).to.decrease(obj, 'count').by(4) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease by amount works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + } +) + +describe.each(NAMESPACES)( + "%s.expect() - Side Effect Assertions (Getter Function Pattern)", + (namespace) => { + describe("`.change()` with getter function", () => { + test("should detect when getter value changes", () => { + return expect( + runTest(` + ${namespace}.test("change with getter function", () => { + let value = 0 + const changeFn = () => { value = 1 } + ${namespace}.expect(changeFn).to.change(() => value) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not change", () => { + return expect( + runTest(` + ${namespace}.test("change negated when no change", () => { + let value = 0 + const noChangeFn = () => { value = 0 } + ${namespace}.expect(noChangeFn).to.not.change(() => value) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change negated when no change", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("change by with getter function", () => { + let value = 5 + const addFive = () => { value += 5 } + ${namespace}.expect(addFive).to.change(() => value).by(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "change by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.increase()` with getter function", () => { + test("should detect when getter value increases", () => { + return expect( + runTest(` + ${namespace}.test("increase with getter function", () => { + let counter = 0 + const incrementFn = () => { counter++ } + ${namespace}.expect(incrementFn).to.increase(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not increase", () => { + return expect( + runTest(` + ${namespace}.test("increase negated when no increase", () => { + let counter = 5 + const noIncreaseFn = () => { counter-- } + ${namespace}.expect(noIncreaseFn).to.not.increase(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase negated when no increase", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("increase by with getter function", () => { + let value = 5 + const addFive = () => { value += 5 } + ${namespace}.expect(addFive).to.increase(() => value).by(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "increase by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + describe("`.decrease()` with getter function", () => { + test("should detect when getter value decreases", () => { + return expect( + runTest(` + ${namespace}.test("decrease with getter function", () => { + let counter = 10 + const decrementFn = () => { counter-- } + ${namespace}.expect(decrementFn).to.decrease(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should pass with negation when value does not decrease", () => { + return expect( + runTest(` + ${namespace}.test("decrease negated when no decrease", () => { + let counter = 5 + const noDecreaseFn = () => { counter++ } + ${namespace}.expect(noDecreaseFn).to.not.decrease(() => counter) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease negated when no decrease", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.by()` chaining with getter", () => { + return expect( + runTest(` + ${namespace}.test("decrease by with getter function", () => { + let value = 10 + const subtractThree = () => { value -= 3 } + ${namespace}.expect(subtractThree).to.decrease(() => value).by(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "decrease by with getter function", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + } +) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts new file mode 100644 index 00000000..079c9163 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/core-assertions.spec.ts @@ -0,0 +1,616 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Core Chai Assertions", () => { + describe("Language Chains", () => { + test("should support all language chain properties (`to`, `be`, `that`, `and`, `has`, etc.)", () => { + return expect( + runTest(` + hopp.test("language chains work", () => { + hopp.expect(2).to.equal(2) + hopp.expect(2).to.be.equal(2) + hopp.expect(2).to.be.a('number').that.equals(2) + hopp.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "language chains work", + expectResults: [ + { status: "pass", message: "Expected 2 to equal 2" }, + { status: "pass", message: "Expected 2 to be equal 2" }, + { status: "pass", message: "Expected 2 to be a number" }, + { + status: "pass", + message: "Expected 2 to be a number that equals 2", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { + status: "pass", + message: + "Expected [1, 2, 3] to be an array that has lengthOf 3", + }, + ], + }), + ], + }), + ]) + }) + + test("should support multiple modifier combinations with language chains", () => { + return expect( + runTest(` + hopp.test("complex chains work", () => { + hopp.expect([1,2,3]).to.be.an('array') + hopp.expect([1,2,3]).to.have.lengthOf(3) + hopp.expect([1,2,3]).to.include(2) + hopp.expect({a: 1, b: 2}).to.be.an('object') + hopp.expect({a: 1, b: 2}).to.have.property('a') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex chains work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + { status: "pass", message: expect.stringMatching(/object/) }, + { + status: "pass", + message: expect.stringMatching(/property 'a'/), + }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Type Assertions", () => { + test("should assert primitive types correctly (`.a()`, `.an()`)", () => { + return expect( + runTest(` + hopp.test("type assertions work", () => { + hopp.expect('foo').to.be.a('string') + hopp.expect({a: 1}).to.be.an('object') + hopp.expect([1, 2, 3]).to.be.an('array') + hopp.expect(null).to.be.a('null') + hopp.expect(undefined).to.be.an('undefined') + hopp.expect(42).to.be.a('number') + hopp.expect(true).to.be.a('boolean') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "type assertions work", + expectResults: [ + { status: "pass", message: "Expected 'foo' to be a string" }, + { status: "pass", message: "Expected {a: 1} to be an object" }, + { + status: "pass", + message: "Expected [1, 2, 3] to be an array", + }, + { status: "pass", message: "Expected null to be a null" }, + { + status: "pass", + message: "Expected undefined to be an undefined", + }, + { status: "pass", message: "Expected 42 to be a number" }, + { status: "pass", message: "Expected true to be a boolean" }, + ], + }), + ], + }), + ]) + }) + + test("should assert Symbol and BigInt types", () => { + return expect( + runTest(` + hopp.test("modern type assertions work", () => { + hopp.expect(Symbol('test')).to.be.a('symbol') + hopp.expect(Symbol.for('shared')).to.be.a('symbol') + hopp.expect(BigInt(123)).to.be.a('bigint') + hopp.expect(BigInt('999999999999999999')).to.be.a('bigint') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "modern type assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected Symbol\(test\) to be a symbol/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected Symbol\(shared\) to be a symbol/ + ), + }, + { + status: "pass", + message: "Expected 123n to be a bigint", + }, + { + status: "pass", + message: "Expected 999999999999999999n to be a bigint", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Equality Assertions", () => { + test("should support `.equal()`, `.equals()`, `.eq()` for strict equality", () => { + return expect( + runTest(` + hopp.test("equality works", () => { + hopp.expect(42).to.equal(42) + hopp.expect('test').to.equals('test') + hopp.expect(true).to.eq(true) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "equality works", + expectResults: [ + { status: "pass", message: "Expected 42 to equal 42" }, + { status: "pass", message: "Expected 'test' to equals 'test'" }, + { status: "pass", message: "Expected true to eq true" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.eql()` for deep equality", () => { + return expect( + runTest(` + hopp.test("deep equality works", () => { + hopp.expect({a: 1}).to.eql({a: 1}) + hopp.expect([1, 2, 3]).to.eql([1, 2, 3]) + hopp.expect({a: {b: 2}}).to.eql({a: {b: 2}}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep equality works", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to eql {a: 1}", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to eql [1, 2, 3]", + }, + { + status: "pass", + message: "Expected {a: {b: 2}} to eql {a: {b: 2}}", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Truthiness Assertions", () => { + test("should support `.true`, `.false`, `.ok` assertions", () => { + return expect( + runTest(` + hopp.test("truthiness assertions work", () => { + hopp.expect(true).to.be.true + hopp.expect(false).to.be.false + hopp.expect(1).to.be.ok + hopp.expect('hello').to.be.ok + hopp.expect({}).to.be.ok + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "truthiness assertions work", + expectResults: [ + { status: "pass", message: "Expected true to be true" }, + { status: "pass", message: "Expected false to be false" }, + { status: "pass", message: "Expected 1 to be ok" }, + { status: "pass", message: "Expected 'hello' to be ok" }, + { status: "pass", message: "Expected {} to be ok" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Nullish Assertions", () => { + test("should support `.null`, `.undefined`, `.NaN` assertions", () => { + return expect( + runTest(` + hopp.test("nullish assertions work", () => { + hopp.expect(null).to.be.null + hopp.expect(undefined).to.be.undefined + hopp.expect(NaN).to.be.NaN + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nullish assertions work", + expectResults: [ + { status: "pass", message: "Expected null to be null" }, + { + status: "pass", + message: "Expected undefined to be undefined", + }, + { status: "pass", message: "Expected NaN to be NaN" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.exist` assertion", () => { + return expect( + runTest(` + hopp.test("exist assertion works", () => { + hopp.expect(0).to.exist + hopp.expect('').to.exist + hopp.expect(false).to.exist + hopp.expect({}).to.exist + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "exist assertion works", + expectResults: [ + { status: "pass", message: "Expected 0 to exist" }, + { status: "pass", message: "Expected '' to exist" }, + { status: "pass", message: "Expected false to exist" }, + { status: "pass", message: "Expected {} to exist" }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Numerical Comparisons", () => { + test("should support `.above()` and `.below()` comparisons", () => { + return expect( + runTest(` + hopp.test("numerical comparisons work", () => { + hopp.expect(10).to.be.above(5) + hopp.expect(5).to.be.below(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "numerical comparisons work", + expectResults: [ + { status: "pass", message: "Expected 10 to be above 5" }, + { status: "pass", message: "Expected 5 to be below 10" }, + ], + }), + ], + }), + ]) + }) + + test("should support comparison aliases (`.gt()`, `.gte()`, `.lt()`, `.lte()`)", () => { + return expect( + runTest(` + hopp.test("comparison aliases work", () => { + hopp.expect(10).to.be.gt(5) + hopp.expect(10).to.be.gte(10) + hopp.expect(5).to.be.lt(10) + hopp.expect(5).to.be.lte(5) + hopp.expect(10).to.be.greaterThan(5) + hopp.expect(10).to.be.greaterThanOrEqual(10) + hopp.expect(5).to.be.lessThan(10) + hopp.expect(5).to.be.lessThanOrEqual(5) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "comparison aliases work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/above|gt/) }, + { + status: "pass", + message: expect.stringMatching(/above|gte|at least/), + }, + { status: "pass", message: expect.stringMatching(/below|lt/) }, + { + status: "pass", + message: expect.stringMatching(/below|lte|at most/), + }, + ]), + }), + ], + }), + ]) + }) + + test("should support `.within()` for range comparisons", () => { + return expect( + runTest(` + hopp.test("within range works", () => { + hopp.expect(5).to.be.within(1, 10) + hopp.expect(7).to.be.within(7, 7) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "within range works", + expectResults: [ + { status: "pass", message: "Expected 5 to be within 1, 10" }, + { status: "pass", message: "Expected 7 to be within 7, 7" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.closeTo()` and `.approximately()` for floating point comparisons", () => { + return expect( + runTest(` + hopp.test("close to works", () => { + hopp.expect(1.5).to.be.closeTo(1.0, 0.6) + hopp.expect(1.5).to.be.approximately(1.0, 0.6) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "close to works", + expectResults: [ + { + status: "pass", + message: "Expected 1.5 to be closeTo 1, 0.6", + }, + { + status: "pass", + message: "Expected 1.5 to be approximately 1, 0.6", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Special Value Assertions", () => { + test("should support `.empty` assertion for various types", () => { + return expect( + runTest(` + hopp.test("empty assertion works", () => { + hopp.expect('').to.be.empty + hopp.expect([]).to.be.empty + hopp.expect({}).to.be.empty + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "empty assertion works", + expectResults: [ + { status: "pass", message: "Expected '' to be empty" }, + { status: "pass", message: "Expected [] to be empty" }, + { status: "pass", message: "Expected {} to be empty" }, + ], + }), + ], + }), + ]) + }) + + test("should support `.finite` assertion for numbers", () => { + return expect( + runTest(` + hopp.test("finite assertion works", () => { + hopp.expect(42).to.be.finite + hopp.expect(0).to.be.finite + hopp.expect(-100.5).to.be.finite + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "finite assertion works", + expectResults: [ + { status: "pass", message: "Expected 42 to be finite" }, + { status: "pass", message: "Expected 0 to be finite" }, + { status: "pass", message: "Expected -100.5 to be finite" }, + ], + }), + ], + }), + ]) + }) + + test("should detect Infinity and reject `.finite`", () => { + return expect( + runTest(` + hopp.test("infinity is not finite", () => { + hopp.expect(Infinity).to.not.be.finite + hopp.expect(-Infinity).to.not.be.finite + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "infinity is not finite", + expectResults: [ + { + status: "pass", + message: "Expected Infinity to not be finite", + }, + { + status: "pass", + message: "Expected -Infinity to not be finite", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Negation with `.not`", () => { + test("should support negation for all assertion types", () => { + return expect( + runTest(` + hopp.test("negation works", () => { + hopp.expect(1).to.not.equal(2) + hopp.expect('foo').to.not.be.a('number') + hopp.expect(false).to.not.be.true + hopp.expect('foo').to.not.be.empty + }) + `)() + ).resolves.toEqualRight([ + { + descriptor: "root", + expectResults: [], + children: [ + { + descriptor: "negation works", + expectResults: [ + { status: "pass", message: "Expected 1 to not equal 2" }, + { + status: "pass", + message: "Expected 'foo' to not be a number", + }, + { status: "pass", message: "Expected false to not be true" }, + { status: "pass", message: "Expected 'foo' to not be empty" }, + ], + children: [], + }, + ], + }, + ]) + }) + }) + + describe("Boundary Value Testing", () => { + test("should handle boundary values correctly in comparisons", () => { + return expect( + runTest(` + hopp.test("boundary values work", () => { + hopp.expect(Number.MAX_SAFE_INTEGER).to.be.a('number') + hopp.expect(Number.MIN_SAFE_INTEGER).to.be.a('number') + hopp.expect(Number.EPSILON).to.be.above(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "boundary values work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/number/) }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Failure Cases", () => { + test("should produce meaningful error messages on assertion failures", () => { + return expect( + runTest(` + hopp.test("failures have good messages", () => { + hopp.expect(1).to.equal(2) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "failures have good messages", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected 1 to equal 2"), + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts new file mode 100644 index 00000000..9add9c2b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/exotic-objects.spec.ts @@ -0,0 +1,1042 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +// Behavior validation for exotic objects (Proxy, etc.) within sandbox serialization constraints +describe("hopp.expect - Exotic Objects & Error Edge Cases", () => { + describe("Proxy Objects", () => { + test("should handle basic Proxy objects", () => { + return expect( + runTest(` + hopp.test("proxy object assertions work", () => { + const target = { value: 42 } + const proxy = new Proxy(target, {}) + + hopp.expect(proxy).to.be.an("object") + hopp.expect(proxy).to.have.property("value", 42) + hopp.expect(proxy.value).to.equal(42) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy object assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an object/), + }, + { + status: "pass", + message: expect.stringMatching(/to have property 'value'/), + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Proxy with custom traps", () => { + return expect( + runTest(` + hopp.test("proxy with custom traps work", () => { + const target = { value: 10 } + const proxy = new Proxy(target, { + get(target, prop) { + if (prop === 'value') return target[prop] * 2 + return target[prop] + } + }) + + hopp.expect(proxy.value).to.equal(20) + hopp.expect(target.value).to.equal(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy with custom traps work", + expectResults: [ + { + status: "pass", + message: "Expected 20 to equal 20", + }, + { + status: "pass", + message: "Expected 10 to equal 10", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle revocable Proxy", () => { + return expect( + runTest(` + hopp.test("revocable proxy assertions work", () => { + const target = { value: 42 } + const { proxy, revoke } = Proxy.revocable(target, {}) + + hopp.expect(proxy.value).to.equal(42) + + revoke() + + hopp.expect(() => proxy.value).to.throw(TypeError) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "revocable proxy assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("WeakMap & WeakSet", () => { + test("should handle WeakMap behavior", () => { + return expect( + runTest(` + hopp.test("weakmap behavior tests work", () => { + const wm = new WeakMap() + const key1 = {} + const key2 = { id: 1 } + + wm.set(key1, 'value1') + wm.set(key2, 42) + + hopp.expect(wm.has(key1)).to.be.true + hopp.expect(wm.get(key1)).to.equal('value1') + hopp.expect(wm.get(key2)).to.equal(42) + hopp.expect(wm.has({})).to.be.false + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "weakmap behavior tests work", + expectResults: [ + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected 'value1' to equal 'value1'", + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: "Expected false to be false", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle WeakSet behavior", () => { + return expect( + runTest(` + hopp.test("weakset behavior tests work", () => { + const ws = new WeakSet() + const obj1 = { id: 1 } + const obj2 = { id: 2 } + + ws.add(obj1) + ws.add(obj2) + + hopp.expect(ws.has(obj1)).to.be.true + hopp.expect(ws.has(obj2)).to.be.true + hopp.expect(ws.has({})).to.be.false + + // WeakSet doesn't have reference equality for new objects + const newObj = { id: 1 } + hopp.expect(ws.has(newObj)).to.be.false + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "weakset behavior tests work", + expectResults: [ + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected true to be true", + }, + { + status: "pass", + message: "Expected false to be false", + }, + { + status: "pass", + message: "Expected false to be false", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("TypedArrays", () => { + test("should handle Uint8Array", () => { + return expect( + runTest(` + hopp.test("uint8array tests work", () => { + const arr = new Uint8Array([1, 2, 3, 4, 5]) + + hopp.expect(arr).to.be.an('object') + hopp.expect(arr.length).to.equal(5) + hopp.expect(arr[0]).to.equal(1) + hopp.expect(arr[4]).to.equal(5) + hopp.expect(arr[10]).to.equal(undefined) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "uint8array tests work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an object/), + }, + { + status: "pass", + message: "Expected 5 to equal 5", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 5 to equal 5", + }, + { + status: "pass", + message: "Expected undefined to equal undefined", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Int16Array", () => { + return expect( + runTest(` + hopp.test("int16array tests work", () => { + const arr = new Int16Array([-1000, 0, 1000]) + + hopp.expect(arr[0]).to.equal(-1000) + hopp.expect(arr[2]).to.equal(1000) + hopp.expect(arr.byteLength).to.equal(6) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "int16array tests work", + expectResults: [ + { + status: "pass", + message: "Expected -1000 to equal -1000", + }, + { + status: "pass", + message: "Expected 1000 to equal 1000", + }, + { + status: "pass", + message: "Expected 6 to equal 6", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Float32Array", () => { + return expect( + runTest(` + hopp.test("float32array tests work", () => { + const arr = new Float32Array([3.14, 2.718, 1.414]) + + hopp.expect(arr[0]).to.be.closeTo(3.14, 0.01) + hopp.expect(arr[1]).to.be.closeTo(2.718, 0.001) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "float32array tests work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be closeTo 3\.14/), + }, + { + status: "pass", + message: expect.stringMatching(/to be closeTo 2\.718/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle multiple TypedArray types", () => { + return expect( + runTest(` + hopp.test("various typedarray types work", () => { + const u32 = new Uint32Array([1, 2, 3]) + const i8 = new Int8Array([-1, 0, 1]) + const f64 = new Float64Array([1.1, 2.2]) + + hopp.expect(u32.byteLength).to.equal(12) + hopp.expect(i8.length).to.equal(3) + hopp.expect(f64.byteLength).to.equal(16) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "various typedarray types work", + expectResults: [ + { + status: "pass", + message: "Expected 12 to equal 12", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + { + status: "pass", + message: "Expected 16 to equal 16", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("ArrayBuffer & DataView", () => { + test("should handle ArrayBuffer", () => { + return expect( + runTest(` + hopp.test("arraybuffer tests work", () => { + const buffer = new ArrayBuffer(16) + const view = new Uint8Array(buffer) + + hopp.expect(buffer.byteLength).to.equal(16) + hopp.expect(view.length).to.equal(16) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arraybuffer tests work", + expectResults: [ + { + status: "pass", + message: "Expected 16 to equal 16", + }, + { + status: "pass", + message: "Expected 16 to equal 16", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle DataView", () => { + return expect( + runTest(` + hopp.test("dataview tests work", () => { + const buffer = new ArrayBuffer(8) + const view = new DataView(buffer) + + view.setInt32(0, 42) + view.setFloat32(4, 3.14) + + hopp.expect(view.byteLength).to.equal(8) + hopp.expect(view.getInt32(0)).to.equal(42) + hopp.expect(view.getFloat32(4)).to.be.closeTo(3.14, 0.01) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "dataview tests work", + expectResults: [ + { + status: "pass", + message: "Expected 8 to equal 8", + }, + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to be closeTo 3\.14/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Error Objects & Custom Errors", () => { + test("should handle standard Error types", () => { + return expect( + runTest(` + hopp.test("standard error types work", () => { + const err = new Error('Test error') + const typeErr = new TypeError('Type error') + const rangeErr = new RangeError('Range error') + const refErr = new ReferenceError('Reference error') + + hopp.expect(err).to.be.an.instanceof(Error) + hopp.expect(typeErr).to.be.an.instanceof(TypeError) + hopp.expect(rangeErr).to.be.an.instanceof(RangeError) + hopp.expect(refErr).to.be.an.instanceof(ReferenceError) + hopp.expect(err.message).to.equal('Test error') + hopp.expect(typeErr.message).to.equal('Type error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "standard error types work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof TypeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof RangeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /to be an instanceof ReferenceError/ + ), + }, + { + status: "pass", + message: "Expected 'Test error' to equal 'Test error'", + }, + { + status: "pass", + message: "Expected 'Type error' to equal 'Type error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle custom Error classes", () => { + return expect( + runTest(` + hopp.test("custom error classes work", () => { + class CustomError extends Error { + constructor(message, code) { + super(message) + this.name = 'CustomError' + this.code = code + } + } + + const err = new CustomError('Custom error', 500) + + hopp.expect(err).to.be.an.instanceof(Error) + hopp.expect(err.name).to.equal('CustomError') + hopp.expect(err.code).to.equal(500) + hopp.expect(err.message).to.equal('Custom error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "custom error classes work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching( + /to equal 'CustomError'|to equal CustomError/ + ), + }, + { + status: "pass", + message: "Expected 500 to equal 500", + }, + { + status: "pass", + message: "Expected 'Custom error' to equal 'Custom error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Error with cause (ES2022)", () => { + return expect( + runTest(` + hopp.test("error with cause work", () => { + const original = new Error('Original error') + const wrapped = new Error('Wrapped error', { cause: original }) + + hopp.expect(wrapped).to.be.an.instanceof(Error) + hopp.expect(wrapped.message).to.equal('Wrapped error') + hopp.expect(wrapped.cause).to.exist + hopp.expect(wrapped.cause.message).to.equal('Original error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "error with cause work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: "Expected 'Wrapped error' to equal 'Wrapped error'", + }, + { + status: "pass", + message: expect.stringMatching(/to exist/), + }, + { + status: "pass", + message: + "Expected 'Original error' to equal 'Original error'", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle throw with Error objects", () => { + return expect( + runTest(` + hopp.test("throwing error objects work", () => { + const thrower = () => { throw new TypeError('Invalid type') } + + hopp.expect(thrower).to.throw(TypeError) + hopp.expect(thrower).to.throw(TypeError, 'Invalid type') + hopp.expect(thrower).to.throw('Invalid type') + hopp.expect(thrower).to.throw(/Invalid/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throwing error objects work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw TypeError/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw 'Invalid type'/), + }, + { + status: "pass", + message: expect.stringMatching(/to throw \/Invalid\//), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Promise Objects", () => { + test("should handle Promise state checking", () => { + return expect( + runTest(` + hopp.test("promise tests work", async () => { + const resolved = Promise.resolve(42) + const rejected = Promise.reject(new Error('Failed')) + + hopp.expect(await resolved).to.equal(42) + + try { + await rejected + } catch (e) { + hopp.expect(e).to.be.an.instanceof(Error) + hopp.expect(e.message).to.equal('Failed') + } + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [ + { + status: "pass", + message: "Expected 42 to equal 42", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: "Expected 'Failed' to equal 'Failed'", + }, + ], + children: [ + expect.objectContaining({ + descriptor: "promise tests work", + expectResults: [], + }), + ], + }), + ]) + }) + + test("should handle Promise.all", () => { + return expect( + runTest(` + hopp.test("promise.all tests work", async () => { + const p1 = Promise.resolve(1) + const p2 = Promise.resolve(2) + const p3 = Promise.resolve(3) + + const results = await Promise.all([p1, p2, p3]) + + hopp.expect(results).to.have.lengthOf(3) + hopp.expect(results[0]).to.equal(1) + hopp.expect(results[2]).to.equal(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to have lengthOf 3/), + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + ], + children: [ + expect.objectContaining({ + descriptor: "promise.all tests work", + expectResults: [], + }), + ], + }), + ]) + }) + }) + + describe("Symbol.iterator & Iterables", () => { + test("should handle custom iterables", () => { + return expect( + runTest(` + hopp.test("custom iterable tests work", () => { + const iterable = { + *[Symbol.iterator]() { + yield 1 + yield 2 + yield 3 + } + } + + hopp.expect(iterable).to.be.an('object') + hopp.expect(typeof iterable[Symbol.iterator]).to.equal('function') + + const values = [...iterable] + hopp.expect(values[0]).to.equal(1) + hopp.expect(values[1]).to.equal(2) + hopp.expect(values[2]).to.equal(3) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "custom iterable tests work", + expectResults: [ + { + status: "pass", + message: "Expected {} to be an object", + }, + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + { + status: "pass", + message: "Expected 2 to equal 2", + }, + { + status: "pass", + message: "Expected 3 to equal 3", + }, + ], + }), + ], + }), + ]) + }) + + test("should handle generator functions", () => { + return expect( + runTest(` + hopp.test("generator function tests work", () => { + function* gen() { + yield 'a' + yield 'b' + yield 'c' + } + + hopp.expect(typeof gen).to.equal('function') + + const iterator = gen() + hopp.expect(iterator).to.be.an('object') + hopp.expect(typeof iterator.next).to.equal('function') + + const first = iterator.next() + hopp.expect(first.value).to.equal('a') + hopp.expect(first.done).to.be.false + + iterator.next() + iterator.next() + const last = iterator.next() + hopp.expect(last.done).to.be.true + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "generator function tests work", + expectResults: [ + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected {} to be an object", + }, + { + status: "pass", + message: "Expected function to equal function", + }, + { + status: "pass", + message: "Expected 'a' to equal 'a'", + }, + { + status: "pass", + message: "Expected false to be false", + }, + { + status: "pass", + message: "Expected true to be true", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Complex Compositions", () => { + test("should handle nested exotic objects", () => { + return expect( + runTest(` + hopp.test("nested exotic objects work", () => { + const buffer = new ArrayBuffer(3) + const view = new Uint8Array(buffer) + view[0] = 1 + view[1] = 2 + view[2] = 3 + + const mySet = new Set([1, 2, 3]) + const myMap = new Map([['key', 'value']]) + + const composed = { + buffer, + view, + set: mySet, + map: myMap + } + + hopp.expect(composed.buffer.byteLength).to.equal(3) + hopp.expect(composed.view[1]).to.equal(2) + hopp.expect(composed.set).to.be.an.instanceof(Set) + hopp.expect(composed.map).to.be.an.instanceof(Map) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nested exotic objects work", + expectResults: [ + { + status: "pass", + message: "Expected 3 to equal 3", + }, + { + status: "pass", + message: "Expected 2 to equal 2", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Set/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Map/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle Proxy wrapping exotic objects", () => { + return expect( + runTest(` + hopp.test("proxy wrapping exotic objects work", () => { + const arr = new Uint8Array([10, 20, 30]) + const proxy = new Proxy(arr, { + get(target, prop) { + if (typeof prop === 'string' && !isNaN(prop)) { + return target[prop] * 2 + } + return target[prop] + } + }) + + hopp.expect(proxy[0]).to.equal(20) + hopp.expect(proxy[1]).to.equal(40) + hopp.expect(arr[0]).to.equal(10) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "proxy wrapping exotic objects work", + expectResults: [ + { + status: "pass", + message: "Expected 20 to equal 20", + }, + { + status: "pass", + message: "Expected 40 to equal 40", + }, + { + status: "pass", + message: "Expected 10 to equal 10", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Edge Cases", () => { + test("should handle Error stack traces", () => { + return expect( + runTest(` + hopp.test("error stack traces work", () => { + const err = new Error('Test error') + + hopp.expect(err).to.have.property('stack') + hopp.expect(err.stack).to.be.a('string') + hopp.expect(err.stack.length).to.be.above(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "error stack traces work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to have property 'stack'/), + }, + { + status: "pass", + message: expect.stringMatching(/to be a string/), + }, + { + status: "pass", + message: expect.stringMatching(/to be above 0/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle TypedArray negative indexing patterns", () => { + return expect( + runTest(` + hopp.test("typedarray negative patterns work", () => { + const arr = new Int8Array([-128, -1, 0, 1, 127]) + + hopp.expect(arr[0]).to.equal(-128) + hopp.expect(arr[arr.length - 1]).to.equal(127) + hopp.expect(arr.slice(-2)[0]).to.equal(1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "typedarray negative patterns work", + expectResults: [ + { + status: "pass", + message: "Expected -128 to equal -128", + }, + { + status: "pass", + message: "Expected 127 to equal 127", + }, + { + status: "pass", + message: "Expected 1 to equal 1", + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts new file mode 100644 index 00000000..6fe82ea5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/functions-errors.spec.ts @@ -0,0 +1,1352 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Functions and Error Assertions", () => { + describe("Throw Assertions", () => { + test("should support `.throw()` without arguments for any error", () => { + return expect( + runTest(` + hopp.test("basic throw works", () => { + hopp.expect(() => { throw new Error('test') }).to.throw() + hopp.expect(() => {}).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "basic throw works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw$/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to not throw/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with error type matching", () => { + return expect( + runTest(` + hopp.test("throw with type works", () => { + hopp.expect(() => { throw new TypeError('type error') }).to.throw(TypeError) + hopp.expect(() => { throw new RangeError('range error') }).to.throw(RangeError) + hopp.expect(() => { throw new ReferenceError('ref error') }).to.throw(ReferenceError) + hopp.expect(() => { throw new Error('generic') }).to.throw(Error) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with type works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw TypeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw RangeError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw ReferenceError/ + ), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw Error/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with string message matching", () => { + return expect( + runTest(` + hopp.test("throw with string message works", () => { + hopp.expect(() => { throw new Error('salmon') }).to.throw('salmon') + hopp.expect(() => { throw new Error('exact message') }).to.throw('exact message') + hopp.expect(() => { throw new Error('contains test word') }).to.throw('test') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with string message works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw 'salmon'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw 'exact message'/ + ), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw 'test'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with regex message matching", () => { + return expect( + runTest(` + hopp.test("throw with regex works", () => { + hopp.expect(() => { throw new Error('illegal salmon!') }).to.throw(/salmon/) + hopp.expect(() => { throw new Error('TEST123') }).to.throw(/test\\d+/i) + hopp.expect(() => { throw new Error('error: something went wrong') }).to.throw(/^error:/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with regex works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/salmon\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/test\\d\+\/i/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw \/\^error:\// + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throw()` with both error type and message", () => { + return expect( + runTest(` + hopp.test("throw with type and message works", () => { + hopp.expect(() => { throw new RangeError('out of range') }).to.throw(RangeError, 'out of range') + hopp.expect(() => { throw new TypeError('wrong type') }).to.throw(TypeError, /wrong/) + hopp.expect(() => { throw new Error('specific error') }).to.throw(Error, 'specific error') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw with type and message works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw RangeError, 'out of range'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw TypeError, \/wrong\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw Error, 'specific error'/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support custom error classes with `.throw()`", () => { + return expect( + runTest(` + hopp.test("custom error classes work", () => { + class CustomError extends Error { + constructor(message) { + super(message) + this.name = 'CustomError' + } + } + + hopp.expect(() => { throw new CustomError('custom message') }).to.throw(CustomError) + hopp.expect(() => { throw new CustomError('specific text') }).to.throw(CustomError, /specific/) + hopp.expect(() => { throw new CustomError('exact') }).to.throw(CustomError, 'exact') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "custom error classes work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError, \/specific\// + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to throw CustomError, 'exact'/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.throws()` and `.Throw()` aliases", () => { + return expect( + runTest(` + hopp.test("throw aliases work", () => { + hopp.expect(() => { throw new Error('test1') }).to.throws() + hopp.expect(() => { throw new Error('test2') }).to.Throw() + hopp.expect(() => { throw new TypeError() }).to.throws(TypeError) + hopp.expect(() => { throw new Error('message') }).to.Throw('message') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw aliases work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "pass", + message: expect.stringMatching(/Expected .* to throw/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("instanceof Assertions", () => { + test("should support `.instanceof()` with built-in types", () => { + return expect( + runTest(` + hopp.test("instanceof with built-in types works", () => { + hopp.expect([1, 2]).to.be.an.instanceof(Array) + hopp.expect(new Date()).to.be.an.instanceof(Date) + hopp.expect(new Error()).to.be.an.instanceof(Error) + hopp.expect(new RegExp('test')).to.be.an.instanceof(RegExp) + hopp.expect(new Map()).to.be.an.instanceof(Map) + hopp.expect(new Set()).to.be.an.instanceof(Set) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof with built-in types works", + expectResults: expect.arrayContaining([ + { + status: "pass", + message: "Expected [1, 2] to be an instanceof Array", + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Date/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Error/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof RegExp/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Map/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof Set/), + }, + ]), + }), + ], + }), + ]) + }) + + test("should support `.instanceof()` with error types", () => { + return expect( + runTest(` + hopp.test("instanceof with error types works", () => { + hopp.expect(new Error()).to.be.an.instanceof(Error) + hopp.expect(new TypeError()).to.be.an.instanceof(TypeError) + hopp.expect(new RangeError()).to.be.an.instanceof(RangeError) + hopp.expect(new TypeError()).to.be.an.instanceof(Error) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof with error types works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.instanceOf()` alias", () => { + return expect( + runTest(` + hopp.test("instanceOf alias works", () => { + hopp.expect([]).to.be.an.instanceof(Array) + hopp.expect(new Date()).to.be.an.instanceof(Date) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceOf alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + { + status: "pass", + message: expect.stringMatching(/to be an instanceof/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.instanceof()`", () => { + return expect( + runTest(` + hopp.test("not instanceof works", () => { + hopp.expect({}).to.not.be.an.instanceof(Array) + hopp.expect([]).to.not.be.an.instanceof(Date) + hopp.expect('string').to.not.be.an.instanceof(Number) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not instanceof works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("respondTo Assertions", () => { + test("should support `.respondTo()` for prototype methods", () => { + return expect( + runTest(` + hopp.test("respondTo with prototype methods works", () => { + function Cat() {} + Cat.prototype.meow = function() { return 'meow' } + + const cat = new Cat() + hopp.expect(cat).to.respondTo('meow') + hopp.expect(Cat).to.respondTo('meow') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo with prototype methods works", + expectResults: [ + { + status: "pass", + message: "Expected {} to respondTo 'meow'", + }, + { + status: "pass", + message: "Expected function Cat() {} to respondTo 'meow'", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.itself.respondTo()` for static methods", () => { + return expect( + runTest(` + hopp.test("itself respondTo for static methods works", () => { + function Dog() {} + Dog.prototype.bark = function() { return 'bark' } + Dog.woof = function() { return 'woof' } + + hopp.expect(Dog).itself.to.respondTo('woof') + hopp.expect(Dog).to.respondTo('bark') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "itself respondTo for static methods works", + expectResults: [ + { + status: "pass", + message: "Expected function Dog() {} to respondTo 'woof'", + }, + { + status: "pass", + message: "Expected function Dog() {} to respondTo 'bark'", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.respondTo()` with constructor functions", () => { + return expect( + runTest(` + hopp.test("respondTo with constructor functions works", () => { + function Animal() {} + Animal.prototype.speak = function() { return 'sound' } + Animal.create = function() { return new Animal() } + + const animal = new Animal() + hopp.expect(animal).to.respondTo('speak') + hopp.expect(Animal).to.respondTo('speak') + hopp.expect(Animal).itself.to.respondTo('create') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondTo with constructor functions works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to respondTo 'speak'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'speak'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'create'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.respondsTo()` alias", () => { + return expect( + runTest(` + hopp.test("respondsTo alias works", () => { + function TestObj() {} + TestObj.prototype.method = function() { return 'test' } + + const obj = new TestObj() + hopp.expect(obj).to.respondsTo('method') + hopp.expect(TestObj).respondsTo('method') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "respondsTo alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to respondTo 'method'/), + }, + { + status: "pass", + message: expect.stringMatching(/to respondTo 'method'/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.respondTo()`", () => { + return expect( + runTest(` + hopp.test("not respondTo works", () => { + function Bird() {} + Bird.prototype.fly = function() { return 'flying' } + + const bird = new Bird() + hopp.expect(bird).to.not.respondTo('swim') + hopp.expect(Bird).to.not.respondTo('nonexistent') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not respondTo works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not respondTo 'swim'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to not respondTo 'nonexistent'/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("satisfy Assertions", () => { + test("should support `.satisfy()` with simple predicates", () => { + return expect( + runTest(` + hopp.test("satisfy with simple predicates works", () => { + hopp.expect(1).to.satisfy(function(num) { return num > 0 }) + hopp.expect(10).to.satisfy(function(num) { return num % 2 === 0 }) + hopp.expect('hello').to.satisfy(function(str) { return str.length === 5 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with simple predicates works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 1 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 10 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'hello' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with complex object validation", () => { + return expect( + runTest(` + hopp.test("satisfy with complex objects works", () => { + hopp.expect({name: 'Alice', age: 30, active: true}).to.satisfy(function(obj) { + return obj.name.length > 3 && obj.age >= 18 && obj.active === true + }) + + hopp.expect([1, 2, 3, 4, 5]).to.satisfy(function(arr) { + return arr.every(n => n > 0) && arr.length === 5 + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with complex objects works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected \{name: 'Alice', age: 30, active: true\} to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected \[1, 2, 3, 4, 5\] to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with regex validation", () => { + return expect( + runTest(` + hopp.test("satisfy with regex works", () => { + hopp.expect('user@example.com').to.satisfy(function(email) { + return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) + }) + + hopp.expect('abc123').to.satisfy(function(str) { + return /^[a-z]+\\d+$/.test(str) + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with regex works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 'user@example\.com' to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'abc123' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfy()` with multi-condition predicates", () => { + return expect( + runTest(` + hopp.test("satisfy with multi-conditions works", () => { + hopp.expect(42).to.satisfy(function(n) { + return n % 2 === 0 && n > 40 && n < 50 + }) + + hopp.expect([1,2,3]).to.satisfy(function(arr) { + return arr.length === 3 && arr[0] === 1 && arr.reduce((a, b) => a + b) === 6 + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy with multi-conditions works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 42 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected \[1, 2, 3\] to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.satisfies()` alias", () => { + return expect( + runTest(` + hopp.test("satisfies alias works", () => { + hopp.expect(5).to.satisfies(function(n) { return n > 0 }) + hopp.expect('test').satisfies(function(s) { return s.length > 0 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfies alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 5 to satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'test' to satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should support negation with `.not.satisfy()`", () => { + return expect( + runTest(` + hopp.test("not satisfy works", () => { + hopp.expect(1).to.not.satisfy(function(num) { return num < 0 }) + hopp.expect('hello').to.not.satisfy(function(str) { return str.length > 10 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "not satisfy works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected 1 to not satisfy function/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected 'hello' to not satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Arguments Object Detection", () => { + test("should support `.arguments` assertion for arguments objects", () => { + return expect( + runTest(` + hopp.test("arguments detection works", () => { + function testFunc() { + hopp.expect(arguments).to.be.arguments + } + testFunc(1, 2, 3) + + hopp.expect([1, 2, 3]).to.not.be.arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arguments detection works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to be arguments/), + }, + { + status: "pass", + message: "Expected [1, 2, 3] to not be arguments", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.Arguments` alias", () => { + return expect( + runTest(` + hopp.test("Arguments alias works", () => { + function testFunc() { + hopp.expect(arguments).to.be.Arguments + } + testFunc(1, 2, 3) + + hopp.expect({0: 1, 1: 2, length: 2}).to.not.be.Arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Arguments alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be Arguments/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be Arguments/), + }, + ], + }), + ], + }), + ]) + }) + + test("should distinguish arguments from array-like objects", () => { + return expect( + runTest(` + hopp.test("arguments vs array-like objects works", () => { + function testFunc() { + hopp.expect(arguments).to.be.arguments + } + testFunc() + + // Array-like objects should not be considered arguments + hopp.expect([]).to.not.be.arguments + hopp.expect({length: 0}).to.not.be.arguments + hopp.expect({0: 'a', 1: 'b', length: 2}).to.not.be.arguments + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "arguments vs array-like objects works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/Expected .* to be arguments/), + }, + { + status: "pass", + message: "Expected [] to not be arguments", + }, + { + status: "pass", + message: "Expected {length: 0} to not be arguments", + }, + { + status: "pass", + message: + "Expected {0: 'a', 1: 'b', length: 2} to not be arguments", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Object State Assertions", () => { + test("should support `.extensible` assertion for extensible objects", () => { + return expect( + runTest(` + hopp.test("extensible assertion works", () => { + hopp.expect({a: 1}).to.be.extensible + hopp.expect([1, 2, 3]).to.be.extensible + + const obj = {} + hopp.expect(obj).to.be.extensible + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "extensible assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.sealed` assertion for sealed objects", () => { + return expect( + runTest(` + hopp.test("sealed assertion works", () => { + hopp.expect(Object.seal({})).to.be.sealed + hopp.expect(Object.seal({a: 1})).to.be.sealed + + const obj = {} + hopp.expect(obj).to.not.be.sealed + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "sealed assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be sealed/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.frozen` assertion for frozen objects", () => { + return expect( + runTest(` + hopp.test("frozen assertion works", () => { + hopp.expect(Object.freeze({})).to.be.frozen + hopp.expect(Object.freeze({a: 1})).to.be.frozen + + const obj = {} + hopp.expect(obj).to.not.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "frozen assertion works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + ], + }), + ], + }), + ]) + }) + + test("should understand that frozen objects are also sealed", () => { + return expect( + runTest(` + hopp.test("frozen implies sealed works", () => { + const frozen = Object.freeze({a: 1}) + hopp.expect(frozen).to.be.frozen + hopp.expect(frozen).to.be.sealed + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "frozen implies sealed works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle primitives as sealed and frozen", () => { + return expect( + runTest(` + hopp.test("primitives are sealed and frozen", () => { + hopp.expect(1).to.be.sealed + hopp.expect(1).to.be.frozen + hopp.expect('string').to.be.sealed + hopp.expect('string').to.be.frozen + hopp.expect(true).to.be.sealed + hopp.expect(true).to.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "primitives are sealed and frozen", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + ], + }), + ], + }), + ]) + }) + + test("should understand the relationship between extensible, sealed, and frozen", () => { + return expect( + runTest(` + hopp.test("object state relationships work", () => { + const extensible = {a: 1} + const sealed = Object.seal({b: 2}) + const frozen = Object.freeze({c: 3}) + + // Extensible is not sealed or frozen + hopp.expect(extensible).to.be.extensible + hopp.expect(extensible).to.not.be.sealed + hopp.expect(extensible).to.not.be.frozen + + // Sealed is not extensible, not frozen (but can be) + hopp.expect(sealed).to.not.be.extensible + hopp.expect(sealed).to.be.sealed + hopp.expect(sealed).to.not.be.frozen + + // Frozen is not extensible, but is sealed + hopp.expect(frozen).to.not.be.extensible + hopp.expect(frozen).to.be.sealed + hopp.expect(frozen).to.be.frozen + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "object state relationships work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be frozen/), + }, + { + status: "pass", + message: expect.stringMatching(/to not be extensible/), + }, + { + status: "pass", + message: expect.stringMatching(/to be sealed/), + }, + { + status: "pass", + message: expect.stringMatching(/to be frozen/), + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Edge Cases and Error Scenarios", () => { + test("should handle throw assertion failures gracefully", () => { + return expect( + runTest(` + hopp.test("throw failures work", () => { + hopp.expect(() => {}).to.throw() + hopp.expect(() => { throw new Error() }).to.not.throw() + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "throw failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching(/Expected .* to throw/), + }, + { + status: "fail", + message: expect.stringMatching(/Expected .* to not throw/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle instanceof failures gracefully", () => { + return expect( + runTest(` + hopp.test("instanceof failures work", () => { + hopp.expect([]).to.be.an.instanceof(Date) + hopp.expect(new Date()).to.not.be.an.instanceof(Date) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "instanceof failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching( + /Expected .* to be an instanceof/ + ), + }, + { + status: "fail", + message: expect.stringMatching( + /Expected .* to not be an instanceof/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle satisfy failures with clear messages", () => { + return expect( + runTest(` + hopp.test("satisfy failures work", () => { + hopp.expect(5).to.satisfy(function(n) { return n < 0 }) + hopp.expect('hello').to.not.satisfy(function(s) { return s.length === 5 }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "satisfy failures work", + expectResults: [ + { + status: "fail", + message: expect.stringMatching( + /Expected 5 to satisfy function/ + ), + }, + { + status: "fail", + message: expect.stringMatching( + /Expected 'hello' to not satisfy function/ + ), + }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts new file mode 100644 index 00000000..87ec921b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/properties-collections.spec.ts @@ -0,0 +1,1075 @@ +/** + * @see https://github.com/hoppscotch/hoppscotch/discussions/5221 + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`hopp.expect` - Properties and Collections Assertions", () => { + describe("Property Assertions", () => { + test("should assert basic property existence and values using `.property()`", () => { + return expect( + runTest(` + hopp.test("property assertions work", () => { + hopp.expect({a: 1}).to.have.property('a') + hopp.expect({a: 1}).to.have.property('a', 1) + hopp.expect({x: {a: 1}}).to.have.deep.property('x', {a: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "property assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to have property 'a'", + }, + { + status: "pass", + message: "Expected {a: 1} to have property 'a', 1", + }, + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep property 'x', {a: 1}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert own properties using `.own.property()`", () => { + return expect( + runTest(` + hopp.test("own property assertions work", () => { + hopp.expect({a: 1}).to.have.own.property('a') + hopp.expect({a: 1}).to.have.own.property('a', 1) + hopp.expect({x: {a: 1}}).to.have.deep.own.property('x', {a: 1}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "own property assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to have own property 'a'", + }, + { + status: "pass", + message: "Expected {a: 1} to have own property 'a', 1", + }, + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep own property 'x', {a: 1}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert property descriptors using `.ownPropertyDescriptor()`", () => { + return expect( + runTest(` + hopp.test("ownPropertyDescriptor assertions work", () => { + const obj = {} + Object.defineProperty(obj, 'test', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }) + + hopp.expect(obj).to.have.ownPropertyDescriptor('test') + hopp.expect(obj).to.have.ownPropertyDescriptor('test', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "ownPropertyDescriptor assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {} to have ownPropertyDescriptor 'test'", + }, + { + status: "pass", + message: "Expected {} to have ownPropertyDescriptor 'test'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Nested Property Assertions", () => { + test("should assert nested properties using `.nested.property()`", () => { + return expect( + runTest(` + hopp.test("nested property assertions work", () => { + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[1]') + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[1]', 'y') + hopp.expect({a: {b: ['x', 'y']}}).to.have.nested.property('a.b[0]', 'x') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "nested property assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[1]'", + }, + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[1]', 'y'", + }, + { + status: "pass", + message: + "Expected {a: {b: ['x', 'y']}} to have nested property 'a.b[0]', 'x'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert complex nested properties using `.deep.nested.property()`", () => { + return expect( + runTest(` + hopp.test("deep nested property testing work", () => { + const complexObj = { + user: { + profile: { + settings: { + theme: 'dark', + notifications: { + email: true, + push: false + } + }, + metadata: ['tag1', 'tag2', 'tag3'] + } + }, + array: [ + {nested: {deep: 'value'}}, + {nested: {deeper: {deepest: 42}}} + ] + } + + hopp.expect(complexObj).to.have.deep.nested.property('user.profile.settings', { + theme: 'dark', + notifications: { + email: true, + push: false + } + }) + + hopp.expect(complexObj).to.have.nested.property('user.profile.metadata[1]', 'tag2') + hopp.expect(complexObj).to.have.nested.property('array[0].nested.deep', 'value') + hopp.expect(complexObj).to.have.nested.property('array[1].nested.deeper.deepest', 42) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep nested property testing work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have deep nested property 'user\.profile\.settings'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'user\.profile\.metadata\[1\]', 'tag2'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'array\[0\]\.nested\.deep', 'value'/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have nested property 'array\[1\]\.nested\.deeper\.deepest', 42/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should negate nested property assertions", () => { + return expect( + runTest(` + hopp.test("negation with nested properties work", () => { + hopp.expect({a: 1}).to.not.have.own.property('b') + hopp.expect({a: {b: 1}}).to.not.have.nested.property('a.c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with nested properties work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1} to not have own property 'b'", + }, + { + status: "pass", + message: + "Expected {a: {b: 1}} to not have nested property 'a.c'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Keys Assertions", () => { + test("should assert all keys using `.all.keys()`", () => { + return expect( + runTest(` + hopp.test("all keys assertions work", () => { + hopp.expect({a: 1, b: 2}).to.have.all.keys('a', 'b') + hopp.expect(['x', 'y']).to.have.all.keys(0, 1) + hopp.expect({a: 1, b: 2}).to.have.all.keys(['a', 'b']) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "all keys assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have all keys 'a', 'b'", + }, + { + status: "pass", + message: "Expected ['x', 'y'] to have all keys '0', '1'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have all keys 'a', 'b'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert any keys using `.any.keys()`", () => { + return expect( + runTest(` + hopp.test("any keys assertions work", () => { + hopp.expect({a: 1, b: 2}).to.have.any.keys('a', 'c') + hopp.expect({a: 1, b: 2}).to.have.any.keys(['a', 'z']) + hopp.expect({a: 1, b: 2, c: 3, d: 4}).to.have.any.keys('x', 'y', 'z', 'a') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "any keys assertions work", + expectResults: [ + { + status: "pass", + message: "Expected {a: 1, b: 2} to have any keys 'a', 'c'", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to have any keys 'a', 'z'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3, d: 4} to have any keys 'x', 'y', 'z', 'a'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert included keys using `.include.keys()`", () => { + return expect( + runTest(` + hopp.test("include keys assertions work", () => { + hopp.expect({a: 1, b: 2, c: 3}).to.include.all.keys('a', 'b') + hopp.expect({a: 1, b: 2, c: 3, d: 4}).to.include.any.keys('a', 'z') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include keys assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3} to include all keys 'a', 'b'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3, d: 4} to include any keys 'a', 'z'", + }, + ], + }), + ], + }), + ]) + }) + + test("should negate keys assertions", () => { + return expect( + runTest(` + hopp.test("negation with keys work", () => { + hopp.expect({a: 1, b: 2}).to.not.have.any.keys('x', 'y', 'z') + hopp.expect({a: 1, b: 2}).to.not.have.all.keys('a', 'b', 'c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with keys work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2} to not have any keys 'x', 'y', 'z'", + }, + { + status: "pass", + message: + "Expected {a: 1, b: 2} to not have all keys 'a', 'b', 'c'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Members Assertions", () => { + test("should assert array members using `.members()`", () => { + return expect( + runTest(` + hopp.test("member assertions work", () => { + hopp.expect([1, 2, 3]).to.have.members([2, 1, 3]) + hopp.expect([1, 2, 2]).to.have.members([2, 1, 2]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "member assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have members [2, 1, 3]", + }, + { + status: "pass", + message: "Expected [1, 2, 2] to have members [2, 1, 2]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert included members using `.include.members()`", () => { + return expect( + runTest(` + hopp.test("include members assertions work", () => { + hopp.expect([1, 2, 3]).to.include.members([1, 2]) + hopp.expect([1, 2, 3, 4]).to.include.members([2, 3]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include members assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to include members [1, 2]", + }, + { + status: "pass", + message: "Expected [1, 2, 3, 4] to include members [2, 3]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep members using `.deep.members()`", () => { + return expect( + runTest(` + hopp.test("deep members assertions work", () => { + hopp.expect([{a: 1}]).to.have.deep.members([{a: 1}]) + hopp.expect([{a: 1}, {b: 2}]).to.have.deep.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep members assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [{a: 1}] to have deep members [{a: 1}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to have deep members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert ordered members using `.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("ordered members assertions work", () => { + hopp.expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]) + hopp.expect([1, 2]).to.not.have.ordered.members([2, 1]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [1, 2, 3] to have ordered members [1, 2, 3]", + }, + { + status: "pass", + message: "Expected [1, 2] to not have ordered members [2, 1]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep ordered members using `.deep.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("deep ordered members assertions work", () => { + hopp.expect([{a: 1}, {b: 2}]).to.have.deep.ordered.members([{a: 1}, {b: 2}]) + hopp.expect([{a: 1}, {b: 2}]).to.not.have.deep.ordered.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to have deep ordered members [{a: 1}, {b: 2}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}] to not have deep ordered members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert include deep ordered members using `.include.deep.ordered.members()`", () => { + return expect( + runTest(` + hopp.test("include deep ordered members assertions work", () => { + hopp.expect([{a: 1}, {b: 2}, {c: 3}]).to.include.deep.ordered.members([{a: 1}, {b: 2}]) + hopp.expect([{a: 1}, {b: 2}, {c: 3}]).to.not.include.deep.ordered.members([{b: 2}, {a: 1}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "include deep ordered members assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}, {c: 3}] to include deep ordered members [{a: 1}, {b: 2}]", + }, + { + status: "pass", + message: + "Expected [{a: 1}, {b: 2}, {c: 3}] to not include deep ordered members [{b: 2}, {a: 1}]", + }, + ], + }), + ], + }), + ]) + }) + + test("should negate members assertions", () => { + return expect( + runTest(` + hopp.test("negation with members work", () => { + hopp.expect([{a: 1}]).to.not.have.deep.members([{b: 2}]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "negation with members work", + expectResults: [ + { + status: "pass", + message: + "Expected [{a: 1}] to not have deep members [{b: 2}]", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Length Assertions", () => { + test("should assert length for arrays using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("array length assertions work", () => { + hopp.expect([1, 2, 3]).to.have.lengthOf(3) + hopp.expect([1, 2, 3]).to.have.length(3) + hopp.expect([]).to.have.lengthOf(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "array length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [1, 2, 3] to have lengthOf 3", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to have length 3", + }, + { + status: "pass", + message: "Expected [] to have lengthOf 0", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for strings using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("string length assertions work", () => { + hopp.expect('foo').to.have.lengthOf(3) + hopp.expect('hello world').to.have.lengthOf(11) + hopp.expect('').to.have.lengthOf(0) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 'foo' to have lengthOf 3", + }, + { + status: "pass", + message: "Expected 'hello world' to have lengthOf 11", + }, + { + status: "pass", + message: "Expected '' to have lengthOf 0", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for Set using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("Set length assertions work", () => { + hopp.expect(new Set([1, 2, 3])).to.have.lengthOf(3) + hopp.expect(new Set()).to.have.lengthOf(0) + hopp.expect(new Set([1, 2, 3])).to.be.an.instanceof(Set) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Set length assertions work", + expectResults: [ + { + status: "pass", + message: "Expected new Set([1, 2, 3]) to have lengthOf 3", + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 0/ + ), + }, + { + status: "pass", + message: + "Expected new Set([1, 2, 3]) to be an instanceof Set", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert length for Map using `.lengthOf()`", () => { + return expect( + runTest(` + hopp.test("Map length assertions work", () => { + hopp.expect(new Map([['a', 1], ['b', 2], ['c', 3]])).to.have.lengthOf(3) + hopp.expect(new Map()).to.have.lengthOf(0) + hopp.expect(new Map([['a', 1]])).to.be.an.instanceof(Map) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Map length assertions work", + expectResults: [ + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 3/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to have lengthOf 0/ + ), + }, + { + status: "pass", + message: expect.stringMatching( + /Expected .* to be an instanceof Map/ + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should work with length in language chains", () => { + return expect( + runTest(` + hopp.test("language chains with length work", () => { + hopp.expect([1,2,3]).to.be.an('array').that.has.lengthOf(3) + hopp.expect([1,2,3]).to.be.an('array').and.have.lengthOf(3).and.include(2) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "language chains with length work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + ]), + }), + ], + }), + ]) + }) + }) + + describe("Include/Contain Assertions", () => { + test("should assert inclusion in strings using `.include()`", () => { + return expect( + runTest(` + hopp.test("string inclusion assertions work", () => { + hopp.expect('foobar').to.include('foo') + hopp.expect('foobar').to.contain('bar') + hopp.expect('hello world').to.include('world') + hopp.expect('test123').to.contain('123') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string inclusion assertions work", + expectResults: [ + { + status: "pass", + message: "Expected 'foobar' to include 'foo'", + }, + { + status: "pass", + message: "Expected 'foobar' to include 'bar'", + }, + { + status: "pass", + message: "Expected 'hello world' to include 'world'", + }, + { + status: "pass", + message: "Expected 'test123' to include '123'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert inclusion in arrays using `.include()`", () => { + return expect( + runTest(` + hopp.test("array inclusion assertions work", () => { + hopp.expect([1, 2, 3]).to.include(2) + hopp.expect([1, 2, 3]).to.contain(3) + hopp.expect([1, 2, 3]).to.not.include(4) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "array inclusion assertions work", + expectResults: [ + { status: "pass", message: "Expected [1, 2, 3] to include 2" }, + { status: "pass", message: "Expected [1, 2, 3] to include 3" }, + { + status: "pass", + message: "Expected [1, 2, 3] to not include 4", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert inclusion in objects using `.include()`", () => { + return expect( + runTest(` + hopp.test("object inclusion assertions work", () => { + hopp.expect({a: 1, b: 2, c: 3}).to.include({a: 1, b: 2}) + hopp.expect({a: 1, b: 2}).to.not.have.property('c') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "object inclusion assertions work", + expectResults: [ + { + status: "pass", + message: + "Expected {a: 1, b: 2, c: 3} to include {a: 1, b: 2}", + }, + { + status: "pass", + message: "Expected {a: 1, b: 2} to not have property 'c'", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert deep inclusion using `.deep.include()`", () => { + return expect( + runTest(` + hopp.test("deep include assertions work", () => { + hopp.expect([{a: 1}]).to.deep.include({a: 1}) + hopp.expect({x: {a: 1}}).to.deep.include({x: {a: 1}}) + hopp.expect({a: {b: 2}, c: 3}).to.deep.include({a: {b: 2}}) + hopp.expect([{name: 'Alice'}, {name: 'Bob'}]).to.deep.include({name: 'Alice'}) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep include assertions work", + expectResults: [ + { + status: "pass", + message: "Expected [{a: 1}] to deep include {a: 1}", + }, + { + status: "pass", + message: "Expected {x: {a: 1}} to deep include {x: {a: 1}}", + }, + { + status: "pass", + message: + "Expected {a: {b: 2}, c: 3} to deep include {a: {b: 2}}", + }, + { + status: "pass", + message: + "Expected [{name: 'Alice'}, {name: 'Bob'}] to deep include {name: 'Alice'}", + }, + ], + }), + ], + }), + ]) + }) + + test("should assert oneOf inclusion using `.contain.oneOf()`", () => { + return expect( + runTest(` + hopp.test("oneOf inclusion assertions work", () => { + hopp.expect(1).to.be.oneOf([1, 2, 3]) + hopp.expect('foo').to.be.oneOf(['foo', 'bar', 'baz']) + hopp.expect('Today is sunny').to.contain.oneOf(['sunny', 'cloudy']) + hopp.expect([1,2,3]).to.contain.oneOf([3,4,5]) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "oneOf inclusion assertions work", + expectResults: [ + { status: "pass", message: "Expected 1 to be oneOf [1, 2, 3]" }, + { + status: "pass", + message: "Expected 'foo' to be oneOf ['foo', 'bar', 'baz']", + }, + { + status: "pass", + message: + "Expected 'Today is sunny' to include oneOf ['sunny', 'cloudy']", + }, + { + status: "pass", + message: "Expected [1, 2, 3] to include oneOf [3, 4, 5]", + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.includes()` and `.contains()` aliases", () => { + return expect( + runTest(` + hopp.test("inclusion aliases work", () => { + hopp.expect([1, 2, 3]).to.includes(2) + hopp.expect('hello').to.contains('ell') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "inclusion aliases work", + expectResults: [ + { status: "pass", message: "Expected [1, 2, 3] to include 2" }, + { + status: "pass", + message: "Expected 'hello' to include 'ell'", + }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Combined Modifier Scenarios", () => { + test("should combine deep and own modifiers with properties", () => { + return expect( + runTest(` + hopp.test("deep + own combinations work", () => { + hopp.expect({x: {a: 1}}).to.have.deep.own.property('x', {a: 1}) + hopp.expect({x: {a: 1}}).to.not.have.own.property('y') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "deep + own combinations work", + expectResults: [ + { + status: "pass", + message: + "Expected {x: {a: 1}} to have deep own property 'x', {a: 1}", + }, + { + status: "pass", + message: "Expected {x: {a: 1}} to not have own property 'y'", + }, + ], + }), + ], + }), + ]) + }) + + test("should use properties in complex language chains", () => { + return expect( + runTest(` + hopp.test("complex chains with properties work", () => { + hopp.expect([1,2,3]).to.be.an('array').and.have.lengthOf(3).and.include(2) + hopp.expect({a: 1, b: 2}).to.be.an('object').that.has.property('a').which.equals(1) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "complex chains with properties work", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.stringMatching(/array/) }, + { + status: "pass", + message: expect.stringMatching(/lengthOf 3/), + }, + { status: "pass", message: expect.stringMatching(/include 2/) }, + { status: "pass", message: expect.stringMatching(/object/) }, + { + status: "pass", + message: expect.stringMatching(/property 'a'/), + }, + ]), + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts new file mode 100644 index 00000000..73675590 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/chai-powered-assertions/strings-regex.spec.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("hopp.expect - String and Regex Methods", () => { + describe("String Inclusion (.string())", () => { + test("should support .string() for substring inclusion", () => { + return expect( + runTest(` + hopp.test("string inclusion works", () => { + hopp.expect('hello world').to.have.string('world') + hopp.expect('foobar').to.have.string('foo') + hopp.expect('foobar').to.have.string('bar') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "string inclusion works", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + { + status: "pass", + message: expect.stringContaining("to have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .string() negation", () => { + return expect( + runTest(` + hopp.test("string negation works", () => { + hopp.expect('hello').to.not.have.string('goodbye') + hopp.expect('foo').to.not.have.string('bar') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "string negation works", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to not have string"), + }, + { + status: "pass", + message: expect.stringContaining("to not have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail on missing substring", () => { + return expect( + runTest(` + hopp.test("string assertion fails correctly", () => { + hopp.expect('hello').to.have.string('goodbye') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "string assertion fails correctly", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("to have string"), + }, + ], + }), + ], + }), + ]) + }) + + test("should work with empty strings", () => { + return expect( + runTest(` + hopp.test("empty string edge case", () => { + hopp.expect('hello').to.have.string('') + hopp.expect('').to.have.string('') + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "empty string edge case", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Regex Matching (.match())", () => { + test("should support .match() with regex patterns", () => { + return expect( + runTest(` + hopp.test("regex matching works", () => { + hopp.expect('hello123').to.match(/\\d+/) + hopp.expect('test@example.com').to.match(/^[^@]+@[^@]+\\.[^@]+$/) + hopp.expect('ABC').to.match(/[A-Z]+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex matching works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .match() negation", () => { + return expect( + runTest(` + hopp.test("regex negation works", () => { + hopp.expect('hello').to.not.match(/\\d+/) + hopp.expect('abc').to.not.match(/[A-Z]+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex negation works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to not match/), + }, + { + status: "pass", + message: expect.stringMatching(/to not match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should support .matches() alias", () => { + return expect( + runTest(` + hopp.test("matches alias works", () => { + hopp.expect('abc123').to.matches(/[a-z]+\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "matches alias works", + expectResults: [ + { + status: "pass", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail on non-matching regex", () => { + return expect( + runTest(` + hopp.test("regex assertion fails correctly", () => { + hopp.expect('hello').to.match(/\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex assertion fails correctly", + expectResults: [ + { + status: "fail", + message: expect.stringMatching(/to match/), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle regex with flags", () => { + return expect( + runTest(` + hopp.test("regex flags work", () => { + hopp.expect('HELLO').to.match(/hello/i) + hopp.expect('hello\\nworld').to.match(/hello.world/s) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "regex flags work", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should handle complex regex patterns", () => { + return expect( + runTest(` + hopp.test("complex regex patterns", () => { + hopp.expect('user@example.com').to.match(/^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$/i) + hopp.expect('192.168.1.1').to.match(/^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/) + hopp.expect('+1-555-123-4567').to.match(/^\\+?\\d{1,3}-?\\d{3}-\\d{3}-\\d{4}$/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "complex regex patterns", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) + + describe("Combined String and Regex Tests", () => { + test("should work with both string and regex in same test", () => { + return expect( + runTest(` + hopp.test("combined assertions", () => { + const email = 'test@example.com' + hopp.expect(email).to.have.string('@') + hopp.expect(email).to.match(/^[^@]+@[^@]+$/) + hopp.expect(email).to.have.string('example') + hopp.expect(email).to.match(/\\.com$/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "combined assertions", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should chain with other assertions", () => { + return expect( + runTest(` + hopp.test("chained assertions", () => { + hopp.expect('hello world').to.be.a('string').and.have.string('world') + hopp.expect('test123').to.be.a('string').and.match(/\\d+/) + }) + `)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "chained assertions", + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts index d22f1371..ca0f4cc5 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/delete.spec.ts @@ -1,41 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.envs) - ) - -const funcTest = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" describe("hopp.env.delete", () => { test("removes variable from selected environment", () => expect( - func(`hopp.env.delete("baseUrl")`, { + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { global: [], selected: [ { @@ -50,7 +19,7 @@ describe("hopp.env.delete", () => { test("removes variable from global environment", () => expect( - func(`hopp.env.delete("baseUrl")`, { + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { global: [ { key: "baseUrl", @@ -65,7 +34,7 @@ describe("hopp.env.delete", () => { test("removes only from selected if present in both", () => expect( - func(`hopp.env.delete("baseUrl")`, { + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { global: [ { key: "baseUrl", @@ -99,7 +68,7 @@ describe("hopp.env.delete", () => { test("removes only first matching entry if duplicates exist in selected", () => expect( - func(`hopp.env.delete("baseUrl")`, { + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { global: [ { key: "baseUrl", @@ -146,7 +115,7 @@ describe("hopp.env.delete", () => { test("removes only first matching entry if duplicates exist in global", () => expect( - func(`hopp.env.delete("baseUrl")`, { + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { global: [ { key: "baseUrl", @@ -179,19 +148,22 @@ describe("hopp.env.delete", () => { test("no change if attempting to delete non-existent key", () => expect( - func(`hopp.env.delete("baseUrl")`, { global: [], selected: [] })() + runTestAndGetEnvs(`hopp.env.delete("baseUrl")`, { + global: [], + selected: [], + })() ).resolves.toEqualRight( expect.objectContaining({ global: [], selected: [] }) )) test("key must be a string", () => expect( - func(`hopp.env.delete(5)`, { global: [], selected: [] })() + runTestAndGetEnvs(`hopp.env.delete(5)`, { global: [], selected: [] })() ).resolves.toBeLeft()) test("reflected in script execution", () => expect( - funcTest( + runTest( ` hopp.env.delete("baseUrl") hopp.expect(hopp.env.get("baseUrl")).toBe(null) @@ -220,7 +192,7 @@ describe("hopp.env.delete", () => { describe("hopp.env.active.delete", () => { test("removes variable from selected environment", () => expect( - func(`hopp.env.active.delete("foo")`, { + runTestAndGetEnvs(`hopp.env.active.delete("foo")`, { selected: [ { key: "foo", @@ -254,7 +226,7 @@ describe("hopp.env.active.delete", () => { test("no effect if not present in selected", () => expect( - func(`hopp.env.active.delete("nope")`, { + runTestAndGetEnvs(`hopp.env.active.delete("nope")`, { selected: [], global: [ { @@ -281,14 +253,17 @@ describe("hopp.env.active.delete", () => { test("key must be a string", () => expect( - func(`hopp.env.active.delete({})`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.active.delete({})`, { + selected: [], + global: [], + })() ).resolves.toBeLeft()) }) describe("hopp.env.global.delete", () => { test("removes variable from global environment", () => expect( - func(`hopp.env.global.delete("foo")`, { + runTestAndGetEnvs(`hopp.env.global.delete("foo")`, { selected: [ { key: "foo", @@ -322,7 +297,7 @@ describe("hopp.env.global.delete", () => { test("no effect if not present in global", () => expect( - func(`hopp.env.global.delete("missing")`, { + runTestAndGetEnvs(`hopp.env.global.delete("missing")`, { selected: [ { key: "missing", @@ -349,6 +324,9 @@ describe("hopp.env.global.delete", () => { test("key must be a string", () => expect( - func(`hopp.env.global.delete([])`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.global.delete([])`, { + selected: [], + global: [], + })() ).resolves.toBeLeft()) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts index 940a1f76..26607cea 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/get.spec.ts @@ -1,31 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("hopp.env.get", () => { test("returns the correct value for an existing selected environment value", () => { return expect( - func( + runTest( ` const data = hopp.env.get("a") hopp.expect(data).toBe("b") @@ -51,7 +30,7 @@ describe("hopp.env.get", () => { test("returns the correct value for an existing global environment value", () => { return expect( - func( + runTest( ` const data = hopp.env.get("a") hopp.expect(data).toBe("b") @@ -77,7 +56,7 @@ describe("hopp.env.get", () => { test("returns null for a key that is not present in both selected or global", () => { return expect( - func( + runTest( ` const data = hopp.env.get("a") hopp.expect(data).toBe(null) @@ -98,7 +77,7 @@ describe("hopp.env.get", () => { test("returns the value defined in selected environment if also present in global", () => { return expect( - func( + runTest( ` const data = hopp.env.get("a") hopp.expect(data).toBe("selected val") @@ -136,7 +115,7 @@ describe("hopp.env.get", () => { test("resolves environment values recursively by default", () => { return expect( - func( + runTest( ` const data = hopp.env.get("a") hopp.expect(data).toBe("hello") @@ -170,7 +149,7 @@ describe("hopp.env.get", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` const data = hopp.env.get(5) `, @@ -186,7 +165,7 @@ describe("hopp.env.get", () => { describe("hopp.env.active.get", () => { test("returns the value from selected environment if present", () => { return expect( - func( + runTest( ` const data = hopp.env.active.get("a") hopp.expect(data).toBe("selectedVal") @@ -224,7 +203,7 @@ describe("hopp.env.active.get", () => { test("returns null if key does not exist in selected", () => { return expect( - func( + runTest( ` const data = hopp.env.active.get("absent") hopp.expect(data).toBe(null) @@ -252,7 +231,7 @@ describe("hopp.env.active.get", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` hopp.env.active.get({}) `, @@ -265,7 +244,7 @@ describe("hopp.env.active.get", () => { describe("hopp.env.global.get", () => { test("returns the value from global environment if present", () => { return expect( - func( + runTest( ` const data = hopp.env.global.get("foo") hopp.expect(data).toBe("globalVal") @@ -300,7 +279,7 @@ describe("hopp.env.global.get", () => { test("returns null if key does not exist in global", () => { return expect( - func( + runTest( ` const data = hopp.env.global.get("not_here") hopp.expect(data).toBe(null) @@ -328,7 +307,7 @@ describe("hopp.env.global.get", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` hopp.env.global.get([]) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts index 76d64257..0db90551 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getInitialRaw.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("hopp.env.getInitialRaw", () => { test("returns initial value for existing selected env variable", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("foo") hopp.expect(val).toBe("bar") @@ -54,7 +32,7 @@ describe("hopp.env.getInitialRaw", () => { test("returns initial value from global if not in selected", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("foo") hopp.expect(val).toBe("bar") @@ -82,7 +60,7 @@ describe("hopp.env.getInitialRaw", () => { test("selected shadows global when both present", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("foo") hopp.expect(val).toBe("selVal") @@ -117,7 +95,7 @@ describe("hopp.env.getInitialRaw", () => { test("returns null for missing key", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("notFound") hopp.expect(val).toBe(null) @@ -135,7 +113,7 @@ describe("hopp.env.getInitialRaw", () => { test("returns empty string if initial value was empty", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("empty") hopp.expect(val).toBe("") @@ -156,7 +134,7 @@ describe("hopp.env.getInitialRaw", () => { test("returns literal template syntax, no resolution", () => { return expect( - func( + runTest( ` const val = hopp.env.getInitialRaw("templ") hopp.expect(val).toBe("<>") @@ -190,7 +168,7 @@ describe("hopp.env.getInitialRaw", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.getInitialRaw(5) `, @@ -203,7 +181,7 @@ describe("hopp.env.getInitialRaw", () => { describe("hopp.env.active.getInitialRaw", () => { test("returns initial value if present in selected env", () => { return expect( - func( + runTest( ` const val = hopp.env.active.getInitialRaw("alpha") hopp.expect(val).toBe("a_value") @@ -238,7 +216,7 @@ describe("hopp.env.active.getInitialRaw", () => { test("returns null if not present in selected env", () => { return expect( - func( + runTest( ` const val = hopp.env.active.getInitialRaw("missing") hopp.expect(val).toBe(null) @@ -266,7 +244,7 @@ describe("hopp.env.active.getInitialRaw", () => { test("returns '' if initial value was empty string", () => { return expect( - func( + runTest( ` const val = hopp.env.active.getInitialRaw("blank") hopp.expect(val).toBe("") @@ -287,7 +265,7 @@ describe("hopp.env.active.getInitialRaw", () => { test("returns literal template if present", () => { return expect( - func( + runTest( ` const val = hopp.env.active.getInitialRaw("tmpl") hopp.expect(val).toBe("<>") @@ -321,7 +299,7 @@ describe("hopp.env.active.getInitialRaw", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.active.getInitialRaw({}) `, @@ -334,7 +312,7 @@ describe("hopp.env.active.getInitialRaw", () => { describe("hopp.env.global.getInitialRaw", () => { test("returns initial value if present in global env", () => { return expect( - func( + runTest( ` const val = hopp.env.global.getInitialRaw("gamma") hopp.expect(val).toBe("g_val") @@ -369,7 +347,7 @@ describe("hopp.env.global.getInitialRaw", () => { test("returns null if not present in global env", () => { return expect( - func( + runTest( ` const val = hopp.env.global.getInitialRaw("none") hopp.expect(val).toBe(null) @@ -397,7 +375,7 @@ describe("hopp.env.global.getInitialRaw", () => { test("returns '' if initial value was empty string", () => { return expect( - func( + runTest( ` const val = hopp.env.global.getInitialRaw("empty") hopp.expect(val).toBe("") @@ -418,7 +396,7 @@ describe("hopp.env.global.getInitialRaw", () => { test("returns literal template value if present", () => { return expect( - func( + runTest( ` const val = hopp.env.global.getInitialRaw("tmpl") hopp.expect(val).toBe("<>") @@ -452,7 +430,7 @@ describe("hopp.env.global.getInitialRaw", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.global.getInitialRaw([]) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts index 5c98a5f7..687578be 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/getRaw.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("hopp.env.getRaw", () => { test("returns the correct value for an existing selected environment value", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe("b") @@ -47,7 +25,7 @@ describe("hopp.env.getRaw", () => { test("returns the correct value for an existing global environment value", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe("b") @@ -68,7 +46,7 @@ describe("hopp.env.getRaw", () => { test("returns null for a key that is not present in both selected and global", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe(null) @@ -89,7 +67,7 @@ describe("hopp.env.getRaw", () => { test("returns the value defined in selected if also present in global", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe("selected val") @@ -127,7 +105,7 @@ describe("hopp.env.getRaw", () => { test("does not resolve values recursively", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe("<>") @@ -161,7 +139,7 @@ describe("hopp.env.getRaw", () => { test("returns the value as is even if there is a potential recursion", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw("a") hopp.expect(data).toBe("<>") @@ -195,7 +173,7 @@ describe("hopp.env.getRaw", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` const data = hopp.env.getRaw(5) `, @@ -208,7 +186,7 @@ describe("hopp.env.getRaw", () => { describe("hopp.env.active.getRaw", () => { test("returns only from selected", () => { return expect( - func( + runTest( ` hopp.expect(hopp.env.active.getRaw("a")).toBe("a-selected") hopp.expect(hopp.env.active.getRaw("b")).toBe(null) @@ -247,7 +225,7 @@ describe("hopp.env.active.getRaw", () => { test("returns null if key absent in selected", () => { return expect( - func( + runTest( ` hopp.expect(hopp.env.active.getRaw("missing")).toBe(null) `, @@ -274,7 +252,7 @@ describe("hopp.env.active.getRaw", () => { test("errors if key is not a string", () => { return expect( - func( + runTest( ` hopp.env.active.getRaw({}) `, @@ -287,7 +265,7 @@ describe("hopp.env.active.getRaw", () => { describe("hopp.env.global.getRaw", () => { test("returns only from global", () => { return expect( - func( + runTest( ` hopp.expect(hopp.env.global.getRaw("b")).toBe("b-global") hopp.expect(hopp.env.global.getRaw("a")).toBe(null) @@ -323,7 +301,7 @@ describe("hopp.env.global.getRaw", () => { test("returns null if key absent in global", () => { return expect( - func( + runTest( ` hopp.expect(hopp.env.global.getRaw("missing")).toBe(null) `, @@ -350,7 +328,7 @@ describe("hopp.env.global.getRaw", () => { test("errors if key is not a string", () => { return expect( - func( + runTest( ` hopp.env.global.getRaw([]) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts index 46036ad3..22cfe6ba 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/reset.spec.ts @@ -1,42 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.envs) - ) - -const funcTest = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" describe("hopp.env.reset", () => { test("resets selected variable to its initial value", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.set("foo", "changed") hopp.env.reset("foo") @@ -68,7 +36,7 @@ describe("hopp.env.reset", () => { test("resets global variable to its initial value if not in selected", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.set("bar", "override") hopp.env.reset("bar") @@ -100,7 +68,7 @@ describe("hopp.env.reset", () => { test("if variable exists in both, only selected variable is reset", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.set("a", "S") hopp.env.global.set("a", "G") @@ -148,7 +116,7 @@ describe("hopp.env.reset", () => { test("resets only the first occurrence if duplicates exist in selected", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.set("dup", "X") hopp.env.reset("dup") @@ -172,7 +140,7 @@ describe("hopp.env.reset", () => { test("resets only the first occurrence if duplicates exist in global", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.global.set("gdup", "Y") hopp.env.reset("gdup") @@ -216,19 +184,22 @@ describe("hopp.env.reset", () => { test("no change if attempting to reset a non-existent key", () => expect( - func(`hopp.env.reset("none")`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.reset("none")`, { + selected: [], + global: [], + })() ).resolves.toEqualRight( expect.objectContaining({ selected: [], global: [] }) )) test("keys should be a string", () => expect( - func(`hopp.env.reset(123)`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.reset(123)`, { selected: [], global: [] })() ).resolves.toBeLeft()) test("reset reflected in subsequent get in the same script (selected)", () => expect( - funcTest( + runTest( ` hopp.env.set("foo", "override") hopp.env.reset("foo") @@ -256,7 +227,7 @@ describe("hopp.env.reset", () => { test("reset works for secret variables", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.set("secret", "newVal") hopp.env.reset("secret") @@ -290,7 +261,7 @@ describe("hopp.env.reset", () => { describe("hopp.env.active.reset", () => { test("resets variable only in selected environment", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.active.set("xxx", "MUT") hopp.env.active.reset("xxx") @@ -317,7 +288,7 @@ describe("hopp.env.active.reset", () => { test("no effect if key not in selected", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.active.reset("nonexistent") `, @@ -349,14 +320,17 @@ describe("hopp.env.active.reset", () => { test("key must be a string", () => expect( - func(`hopp.env.active.reset(123)`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.active.reset(123)`, { + selected: [], + global: [], + })() ).resolves.toBeLeft()) }) describe("hopp.env.global.reset", () => { test("resets variable only in global environment", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.global.set("yyy", "GGG") hopp.env.global.reset("yyy") @@ -388,7 +362,7 @@ describe("hopp.env.global.reset", () => { test("no effect if key not in global", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.global.reset("nonexistent") `, @@ -422,7 +396,7 @@ describe("hopp.env.global.reset", () => { describe("hopp.env.reset - regression cases", () => { test("create via setInitial then set, and reset restores to initial (selected)", () => expect( - func( + runTestAndGetEnvs( ` // Variable does not exist initially hopp.env.setInitial("AUTH_TOKEN", "seeded-v1") @@ -450,7 +424,7 @@ describe("hopp.env.global.reset", () => { test("scope flip: remove from global, create in active, reset only affects active and not deleted global", () => expect( - func( + runTestAndGetEnvs( ` // Start by ensuring global is cleared hopp.env.global.delete("API_KEY") @@ -490,7 +464,7 @@ describe("hopp.env.global.reset", () => { test("delete then reset within same script should be a no-op (selected)", () => expect( - func( + runTestAndGetEnvs( ` hopp.env.active.delete("SESSION_ID") // Reset after unset should not reintroduce or change anything @@ -517,6 +491,9 @@ describe("hopp.env.global.reset", () => { }) test("key must be a string", () => expect( - func(`hopp.env.global.reset([])`, { selected: [], global: [] })() + runTestAndGetEnvs(`hopp.env.global.reset([])`, { + selected: [], + global: [], + })() ).resolves.toBeLeft()) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts index 83e8af56..84bbbf8d 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/setInitial.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("hopp.env.setInitial", () => { test("sets initial value in selected env when key doesn't exist", () => { return expect( - func( + runTest( ` hopp.env.setInitial("newKey", "newValue") const val = hopp.env.getInitialRaw("newKey") @@ -48,7 +26,7 @@ describe("hopp.env.setInitial", () => { test("updates initial value in selected env when key exists", () => { return expect( - func( + runTest( ` hopp.env.setInitial("existing", "updated") const val = hopp.env.getInitialRaw("existing") @@ -77,7 +55,7 @@ describe("hopp.env.setInitial", () => { test("updates selected env when key exists in both selected and global", () => { return expect( - func( + runTest( ` hopp.env.setInitial("shared", "selectedUpdate") const val = hopp.env.getInitialRaw("shared") @@ -116,7 +94,7 @@ describe("hopp.env.setInitial", () => { test("sets initial value in global env when only exists in global", () => { return expect( - func( + runTest( ` hopp.env.setInitial("globalOnly", "globalUpdate") const val = hopp.env.getInitialRaw("globalOnly") @@ -148,7 +126,7 @@ describe("hopp.env.setInitial", () => { test("allows setting empty string as initial value", () => { return expect( - func( + runTest( ` hopp.env.setInitial("empty", "") const val = hopp.env.getInitialRaw("empty") @@ -168,7 +146,7 @@ describe("hopp.env.setInitial", () => { test("allows setting template syntax as initial value", () => { return expect( - func( + runTest( ` hopp.env.setInitial("template", "<>") const val = hopp.env.getInitialRaw("template") @@ -190,7 +168,7 @@ describe("hopp.env.setInitial", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.setInitial(123, "value") `, @@ -201,7 +179,7 @@ describe("hopp.env.setInitial", () => { test("errors for non-string value", () => { return expect( - func( + runTest( ` hopp.env.setInitial("key", 456) `, @@ -214,7 +192,7 @@ describe("hopp.env.setInitial", () => { describe("hopp.env.active.setInitial", () => { test("sets initial value in selected env only", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial("activeKey", "activeValue") const activeVal = hopp.env.active.getInitialRaw("activeKey") @@ -242,7 +220,7 @@ describe("hopp.env.active.setInitial", () => { test("updates existing selected env variable", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial("existing", "updated") const val = hopp.env.active.getInitialRaw("existing") @@ -271,7 +249,7 @@ describe("hopp.env.active.setInitial", () => { test("does not affect global env even if key exists there", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial("shared", "activeUpdate") const activeVal = hopp.env.active.getInitialRaw("shared") @@ -316,7 +294,7 @@ describe("hopp.env.active.setInitial", () => { test("allows setting empty string", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial("blank", "") const val = hopp.env.active.getInitialRaw("blank") @@ -336,7 +314,7 @@ describe("hopp.env.active.setInitial", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial(null, "value") `, @@ -347,7 +325,7 @@ describe("hopp.env.active.setInitial", () => { test("errors for non-string value", () => { return expect( - func( + runTest( ` hopp.env.active.setInitial("key", {}) `, @@ -360,7 +338,7 @@ describe("hopp.env.active.setInitial", () => { describe("hopp.env.global.setInitial", () => { test("sets initial value in global env only", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("globalKey", "globalValue") const globalVal = hopp.env.global.getInitialRaw("globalKey") @@ -388,7 +366,7 @@ describe("hopp.env.global.setInitial", () => { test("updates existing global env variable", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("existing", "updated") const val = hopp.env.global.getInitialRaw("existing") @@ -417,7 +395,7 @@ describe("hopp.env.global.setInitial", () => { test("does not affect selected env even if key exists there", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("shared", "globalUpdate") const globalVal = hopp.env.global.getInitialRaw("shared") @@ -462,7 +440,7 @@ describe("hopp.env.global.setInitial", () => { test("allows setting empty string", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("empty", "") const val = hopp.env.global.getInitialRaw("empty") @@ -482,7 +460,7 @@ describe("hopp.env.global.setInitial", () => { test("allows setting template syntax", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("template", "<>") const val = hopp.env.global.getInitialRaw("template") @@ -504,7 +482,7 @@ describe("hopp.env.global.setInitial", () => { test("errors for non-string key", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial([], "value") `, @@ -515,7 +493,7 @@ describe("hopp.env.global.setInitial", () => { test("errors for non-string value", () => { return expect( - func( + runTest( ` hopp.env.global.setInitial("key", true) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts new file mode 100644 index 00000000..48ba7422 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/env/undefined-rejection.spec.ts @@ -0,0 +1,120 @@ +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { describe, expect, test } from "vitest" + +import { runTestScript } from "~/node" +import { TestResponse, TestResult } from "~/types" + +// Hopp namespace enforces strict string typing (unlike PM namespace) + +const defaultRequest = getDefaultRESTRequest() +const fakeResponse: TestResponse = { + status: 200, + body: "test", + headers: [], +} + +const execEnv = (script: string, envs: TestResult["envs"]) => + pipe( + runTestScript(script, { + envs, + request: defaultRequest, + response: fakeResponse, + }), + TE.map((x) => x.envs) + ) + +describe("hopp.env.set undefined rejection", () => { + test("hopp.env.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.set("key", undefined)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.active.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.active.set("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.global.set rejects undefined value", () => { + return expect( + execEnv(`hopp.env.global.set("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.active.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.active.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.global.setInitial rejects undefined value", () => { + return expect( + execEnv(`hopp.env.global.setInitial("key", undefined)`, { + selected: [], + global: [], + })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects null value", () => { + return expect( + execEnv(`hopp.env.set("key", null)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects boolean value", () => { + return expect( + execEnv(`hopp.env.set("key", true)`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects object value", () => { + return expect( + execEnv(`hopp.env.set("key", {})`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set rejects array value", () => { + return expect( + execEnv(`hopp.env.set("key", [])`, { selected: [], global: [] })() + ).resolves.toBeLeft() + }) + + test("hopp.env.set accepts string value (baseline)", () => { + return expect( + execEnv(`hopp.env.set("key", "value")`, { selected: [], global: [] })() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "key", + currentValue: "value", + initialValue: "value", + secret: false, + }, + ], + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts index ccf73107..a3de0b47 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts @@ -9,7 +9,7 @@ import { runPreRequestScript, runTestScript } from "~/web" import { TestResponse } from "~/types" const baseRequest: HoppRESTRequest = { - v: "15", + v: "16", name: "Test Request", endpoint: "https://example.com/api", method: "GET", @@ -143,7 +143,7 @@ describe("hopp.request", () => { ) }) - test("hopp.request.setMethod should update and uppercase the method", () => { + test("hopp.request.setMethod should update the method (case preserved)", () => { return expect( runPreRequestScript(`hopp.request.setMethod("post")`, { envs: { global: [], selected: [] }, @@ -152,7 +152,7 @@ describe("hopp.request", () => { ).resolves.toEqualRight( expect.objectContaining({ updatedRequest: expect.objectContaining({ - method: "POST", + method: "post", }), }) ) @@ -722,4 +722,295 @@ describe("hopp.request", () => { ).resolves.toEqualLeft(expect.stringContaining("read-only")) }) }) + + describe("setter methods immediately reflect in console.log", () => { + test("setUrl should reflect immediately in hopp.request.url", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.url) + hopp.request.setUrl("https://updated.com/api") + console.log("After:", hopp.request.url) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "https://example.com/api"], + }), + expect.objectContaining({ + args: ["After:", "https://updated.com/api"], + }), + ], + updatedRequest: expect.objectContaining({ + endpoint: "https://updated.com/api", + }), + }) + ) + }) + + test("setMethod should reflect immediately in hopp.request.method", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.method) + hopp.request.setMethod("POST") + console.log("After:", hopp.request.method) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "GET"], + }), + expect.objectContaining({ + args: ["After:", "POST"], + }), + ], + updatedRequest: expect.objectContaining({ + method: "POST", + }), + }) + ) + }) + + test("setHeader should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + const before = hopp.request.headers.find(h => h.key === "X-Test") + console.log("Before value:", before.value) + hopp.request.setHeader("X-Test", "modified") + const after = hopp.request.headers.find(h => h.key === "X-Test") + console.log("After value:", after.value) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before value:", "val1"], + }), + expect.objectContaining({ + args: ["After value:", "modified"], + }), + ], + }) + ) + }) + + test("setHeaders should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Before length:", hopp.request.headers.length) + hopp.request.setHeaders([ + { key: "X-New-1", value: "val1", active: true, description: "" }, + { key: "X-New-2", value: "val2", active: true, description: "" } + ]) + console.log("After length:", hopp.request.headers.length) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before length:", 1], + }), + expect.objectContaining({ + args: ["After length:", 2], + }), + ], + }) + ) + }) + + test("removeHeader should reflect immediately in hopp.request.headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.headers.map(h => h.key)) + hopp.request.removeHeader("X-Test") + console.log("After:", hopp.request.headers.map(h => h.key)) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", ["X-Test"]], + }), + expect.objectContaining({ + args: ["After:", []], + }), + ], + }) + ) + }) + + test("setParam should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + const before = hopp.request.params.find(p => p.key === "q") + console.log("Before value:", before.value) + hopp.request.setParam("q", "updated") + const after = hopp.request.params.find(p => p.key === "q") + console.log("After value:", after.value) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before value:", "search"], + }), + expect.objectContaining({ + args: ["After value:", "updated"], + }), + ], + }) + ) + }) + + test("setParams should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + console.log("Before length:", hopp.request.params.length) + hopp.request.setParams([ + { key: "page", value: "1", active: true, description: "" }, + { key: "limit", value: "10", active: true, description: "" } + ]) + console.log("After length:", hopp.request.params.length) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before length:", 1], + }), + expect.objectContaining({ + args: ["After length:", 2], + }), + ], + }) + ) + }) + + test("removeParam should reflect immediately in hopp.request.params", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.params.map(p => p.key)) + hopp.request.removeParam("q") + console.log("After:", hopp.request.params.map(p => p.key)) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", ["q"]], + }), + expect.objectContaining({ + args: ["After:", []], + }), + ], + }) + ) + }) + + test("setBody should reflect immediately in hopp.request.body", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.body.contentType) + hopp.request.setBody({ + contentType: "application/json", + body: '{"test": true}' + }) + console.log("After:", hopp.request.body.contentType) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", null], + }), + expect.objectContaining({ + args: ["After:", "application/json"], + }), + ], + }) + ) + }) + + test("setAuth should reflect immediately in hopp.request.auth", () => { + return expect( + runPreRequestScript( + ` + console.log("Before:", hopp.request.auth.authType) + hopp.request.setAuth({ authType: "bearer", token: "test-token" }) + console.log("After:", hopp.request.auth.authType) + `, + { + envs: { global: [], selected: [] }, + request: baseRequest, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: [ + expect.objectContaining({ + args: ["Before:", "none"], + }), + expect.objectContaining({ + args: ["After:", "bearer"], + }), + ], + }) + ) + }) + }) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts index cb57ee77..4afca3fa 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/response.spec.ts @@ -462,4 +462,148 @@ describe("hopp.response", () => { ).resolves.toEqualLeft(expect.stringContaining("read-only")) }) }) + + describe("hopp.response utility methods", () => { + test("hopp.response.text() should return response as text", async () => { + await expect( + runTestScript( + `hopp.expect(hopp.response.text()).toBe("Plaintext response")`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + "Expected 'Plaintext response' to be 'Plaintext response'", + }, + ], + }), + }) + ) + }) + + test("hopp.response.json() should parse JSON response", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.json().ok).toBe(true)`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + }) + ) + }) + + test("hopp.response.reason() should return HTTP reason phrase", async () => { + await expect( + runTestScript(`hopp.expect(hopp.response.reason()).toBe("OK")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleTextResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'OK' to be 'OK'" }, + ], + }), + }) + ) + }) + + test("hopp.response.dataURI() should convert response to data URI", async () => { + await expect( + runTestScript( + ` + const dataURI = hopp.response.dataURI() + hopp.expect(dataURI).toBeType("string") + hopp.expect(dataURI.startsWith("data:")).toBe(true) + `, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: sampleJSONResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + }) + ) + }) + + test("hopp.response.jsonp() should parse JSONP response", async () => { + const jsonpResponse: TestResponse = { + status: 200, + body: 'callback({"data": "test"})', + headers: [{ key: "Content-Type", value: "application/javascript" }], + statusText: "OK", + responseTime: 100, + } + + await expect( + runTestScript( + `hopp.expect(hopp.response.jsonp("callback").data).toBe("test")`, + { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: jsonpResponse, + } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'test' to be 'test'" }, + ], + }), + }) + ) + }) + + test("hopp.response.jsonp() should handle plain JSON without callback", async () => { + const plainJSONResponse: TestResponse = { + status: 200, + body: '{"plain": "json"}', + headers: [{ key: "Content-Type", value: "application/json" }], + statusText: "OK", + responseTime: 100, + } + + await expect( + runTestScript(`hopp.expect(hopp.response.jsonp().plain).toBe("json")`, { + envs: { global: [], selected: [] }, + request: defaultRequest, + response: plainJSONResponse, + }) + ).resolves.toEqualRight( + expect.objectContaining({ + tests: expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'json' to be 'json'" }, + ], + }), + }) + ) + }) + }) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts new file mode 100644 index 00000000..c6986b25 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/advanced-assertions.spec.ts @@ -0,0 +1,806 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.to.have.jsonSchema` - JSON Schema Validation", () => { + test("should validate simple type schema", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Response matches schema", function() { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string" }, + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response matches schema", + // Note: jsonSchema assertion currently doesn't populate expectResults + // TODO: Enhance implementation to track individual schema validation results + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate nested object schema", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + user: { + id: 123, + profile: { + name: "John", + email: "john@example.com", + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Nested schema validation", function() { + const schema = { + type: "object", + required: ["user"], + properties: { + user: { + type: "object", + required: ["id", "profile"], + properties: { + id: { type: "number" }, + profile: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" } + } + } + } + } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested schema validation", + // Note: jsonSchema assertion currently doesn't populate expectResults + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate array schema with items", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify([ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ]), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Array schema validation", function() { + const schema = { + type: "array", + items: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "number" }, + name: { type: "string" } + } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Array schema validation", + // Note: jsonSchema assertion currently doesn't populate expectResults + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate enum constraints", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ status: "active", role: "admin" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Enum validation", function() { + const schema = { + type: "object", + properties: { + status: { enum: ["active", "inactive", "pending"] }, + role: { enum: ["admin", "user", "guest"] } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Enum validation", + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate number constraints (min/max)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ age: 25, score: 85 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Number constraints", function() { + const schema = { + type: "object", + properties: { + age: { type: "number", minimum: 0, maximum: 120 }, + score: { type: "number", minimum: 0, maximum: 100 } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Number constraints", + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate string constraints (length, pattern)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + username: "john123", + email: "john@example.com", + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("String constraints", function() { + const schema = { + type: "object", + properties: { + username: { type: "string", minLength: 3, maxLength: 20 }, + email: { type: "string", pattern: "^[^@]+@[^@]+\\\\.[^@]+$" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "String constraints", + expectResults: [], + }), + ], + }), + ]) + }) + + test("should validate array length constraints", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + items: [1, 2, 3], + tags: ["tag1", "tag2"], + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Array length constraints", function() { + const schema = { + type: "object", + properties: { + items: { type: "array", minItems: 1, maxItems: 10 }, + tags: { type: "array", minItems: 1 } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Array length constraints", + expectResults: [], + }), + ], + }), + ]) + }) + + test("should fail when required property is missing", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Missing required property", function() { + const schema = { + type: "object", + required: ["name", "age"], + properties: { + name: { type: "string" }, + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualLeft( + expect.stringContaining("Required property 'age' is missing") + ) + }) + + test("should fail when type doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ age: "thirty" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Wrong type", function() { + const schema = { + type: "object", + properties: { + age: { type: "number" } + } + } + pm.response.to.have.jsonSchema(schema) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualLeft( + expect.stringContaining("Expected type number, got string") + ) + }) +}) + +describe("`pm.response.to.have.charset` - Charset Assertions", () => { + test("should assert UTF-8 charset", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello World", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Response has UTF-8 charset", function() { + pm.response.to.have.charset("utf-8") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has UTF-8 charset", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'utf-8'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should assert ISO-8859-1 charset", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello World", + headers: [ + { key: "Content-Type", value: "text/plain; charset=ISO-8859-1" }, + ], + } + + return expect( + runTest( + ` + pm.test("Response has ISO-8859-1 charset", function() { + pm.response.to.have.charset("iso-8859-1") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has ISO-8859-1 charset", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'iso-8859-1' to equal 'iso-8859-1'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should handle charset case-insensitively", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json; charset=UTF-8" }, + ], + } + + return expect( + runTest( + ` + pm.test("Charset is case-insensitive", function() { + pm.response.to.have.charset("utf-8") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Charset is case-insensitive", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'utf-8'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when charset doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Wrong charset fails", function() { + pm.response.to.have.charset("iso-8859-1") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong charset fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'utf-8' to equal 'iso-8859-1'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.jsonPath` - JSONPath Queries", () => { + test("should query simple property", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query simple property", function() { + pm.response.to.have.jsonPath("$.name", "John") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query simple property", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'John' to deep equal 'John'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should query nested property", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + user: { + profile: { + name: "John Doe", + age: 30, + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query nested property", function() { + pm.response.to.have.jsonPath("$.user.profile.name", "John Doe") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query nested property", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'John Doe' to deep equal 'John Doe'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should query array element by index", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + items: [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ], + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query array element", function() { + pm.response.to.have.jsonPath("$.items[0].name", "Item 1") + pm.response.to.have.jsonPath("$.items[1].id", 2) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query array element", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should query without expected value (existence check)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ user: { id: 123, name: "John" } }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Check property exists", function() { + pm.response.to.have.jsonPath("$.user.id") + pm.response.to.have.jsonPath("$.user.name") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Check property exists", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle root path", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John", age: 30 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Query root", function() { + const root = pm.response.json() + pm.response.to.have.jsonPath("$", root) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Query root", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("deep equal"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when path doesn't exist", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Non-existent path fails", function() { + pm.response.to.have.jsonPath("$.nonexistent") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualLeft( + expect.stringContaining("Property 'nonexistent' not found") + ) + }) + + test("should fail when array index is out of bounds", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ items: [1, 2, 3] }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Out of bounds index fails", function() { + pm.response.to.have.jsonPath("$.items[10]") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualLeft( + expect.stringContaining("Array index '10' out of bounds") + ) + }) + + test("should fail when value doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ name: "John" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Wrong value fails", function() { + pm.response.to.have.jsonPath("$.name", "Jane") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong value fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'John' to deep equal 'Jane'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts new file mode 100644 index 00000000..37cc0369 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-advanced-features.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.expect - Advanced Chai Features", () => { + describe(".nested property assertions", () => { + test("should access nested properties using dot notation", () => { + return expect( + runTest( + ` + pm.test("Nested property access", function() { + const obj = { a: { b: { c: "value" } } } + pm.expect(obj).to.have.nested.property("a.b.c", "value") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested property access", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should access nested properties without value check", () => { + return expect( + runTest( + ` + pm.test("Nested property existence", function() { + const obj = { x: { y: { z: 123 } } } + pm.expect(obj).to.have.nested.property("x.y.z") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested property existence", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle nested array indices", () => { + return expect( + runTest( + ` + pm.test("Nested array access", function() { + const obj = { items: [{ name: "first" }, { name: "second" }] } + pm.expect(obj).to.have.nested.property("items[1].name", "second") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Nested array access", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should work with .not negation", () => { + return expect( + runTest( + ` + pm.test("Negated nested property", function() { + const obj = { a: { b: "value" } } + pm.expect(obj).to.not.have.nested.property("a.c") + }) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Negated nested property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + }) + + // Side-effect assertions with .by() chaining are comprehensively tested in + // change-increase-decrease-getter.spec.ts which includes both getter and object+property patterns, + // positive/negative deltas, and all assertion combinations +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts new file mode 100644 index 00000000..3f2c2436 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/chai-modifier-combinations.spec.ts @@ -0,0 +1,689 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "OK", + headers: [], +} + +describe("Chai Edge Cases - .include.members() / .contain.members() Pattern", () => { + test("should support .include.members() for subset matching", async () => { + const testScript = ` + pm.test("include.members subset", () => { + pm.expect([1, 2, 3, 4]).to.include.members([1, 2]); + pm.expect([1, 2, 3, 4]).to.contain.members([3, 4]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "include.members subset", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .have.members() for exact matching", async () => { + const testScript = ` + pm.test("exact members", () => { + pm.expect([1, 2, 3]).to.have.members([1, 2, 3]); + pm.expect([1, 2, 3]).to.have.members([3, 2, 1]); // Order doesn't matter + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "exact members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("hopp namespace should support include.members", async () => { + const testScript = ` + hopp.test("hopp include.members", () => { + hopp.expect([1, 2, 3, 4]).to.include.members([2, 3]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "hopp include.members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .any.keys() / .all.keys() Patterns", () => { + test("should support .any.keys() - at least one key matches", async () => { + const testScript = ` + pm.test("any.keys pattern", () => { + const obj = { a: 1, b: 2, c: 3 }; + pm.expect(obj).to.have.any.keys('a', 'b'); // Has both + pm.expect(obj).to.have.any.keys('a', 'z'); // Has at least one (a) + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "any.keys pattern", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .all.keys() - must have exactly these keys", async () => { + const testScript = ` + pm.test("all.keys pattern", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.all.keys('a', 'b'); // Exact match + pm.expect(obj).to.have.keys('a', 'b'); // Default is .all + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "all.keys pattern", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail when .any.keys() has no matching keys", async () => { + const testScript = ` + pm.test("any.keys failure", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.any.keys('x', 'y', 'z'); // None match + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "any.keys failure", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .ordered.members() Pattern", () => { + test("should support .ordered.members() - order matters", async () => { + const testScript = ` + pm.test("ordered.members", () => { + pm.expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ordered.members", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail .ordered.members() when order is wrong", async () => { + const testScript = ` + pm.test("ordered.members wrong order", () => { + pm.expect([1, 2, 3]).to.have.ordered.members([3, 2, 1]); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ordered.members wrong order", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .own.include() Pattern", () => { + test("should support .own.include() - own properties only", async () => { + const testScript = ` + pm.test("own.include", () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'ownValue'; + + pm.expect(obj).to.have.own.include({ own: 'ownValue' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail .own.include() for inherited properties", async () => { + const testScript = ` + pm.test("own.include excludes inherited", () => { + const obj = Object.create({ inherited: 'value' }); + obj.own = 'ownValue'; + + // This should fail because 'inherited' is not an own property + pm.expect(obj).to.have.own.include({ inherited: 'value' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own.include excludes inherited", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .nested.include() Pattern", () => { + test("should support .nested.include() - dot notation for nested properties", async () => { + const testScript = ` + pm.test("nested.include", () => { + const obj = { + user: { + name: 'John', + address: { + city: 'NYC' + } + } + }; + + pm.expect(obj).to.nested.include({ 'user.name': 'John' }); + pm.expect(obj).to.nested.include({ 'user.address.city': 'NYC' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .nested.include() with bracket notation", async () => { + const testScript = ` + pm.test("nested.include bracket notation", () => { + const obj = { + 'user.name': 'Alice', // Literal key with dot + user: { name: 'Bob' } + }; + + // Bracket notation for literal key with dot + pm.expect(obj).to.nested.include({ '["user.name"]': 'Alice' }); + // Dot notation for nested property + pm.expect(obj).to.nested.include({ 'user.name': 'Bob' }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested.include bracket notation", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - Modifier Combinations", () => { + test("should support .deep.own.include() - stacked modifiers", async () => { + const testScript = ` + pm.test("deep.own.include", () => { + const obj = Object.create({ inherited: { a: 1 } }); + obj.own = { b: 2 }; + + pm.expect(obj).to.have.deep.own.include({ own: { b: 2 } }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.own.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .deep.equal() - deep equality check", async () => { + const testScript = ` + pm.test("deep.equal", () => { + const obj1 = { a: { b: { c: 1 } } }; + const obj2 = { a: { b: { c: 1 } } }; + + pm.expect(obj1).to.deep.equal(obj2); + pm.expect(obj1).to.eql(obj2); // eql is alias for deep.equal + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.equal", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .deep.nested.include() - multiple modifiers", async () => { + const testScript = ` + pm.test("deep.nested.include", () => { + const obj = { + user: { + profile: { + settings: { theme: 'dark', notifications: true } + } + } + }; + + pm.expect(obj).to.deep.nested.include({ + 'user.profile.settings': { theme: 'dark', notifications: true } + }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "deep.nested.include", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .ownPropertyDescriptor() with Chaining", () => { + test("should support chaining after .ownPropertyDescriptor()", async () => { + const testScript = ` + pm.test("ownPropertyDescriptor chaining", () => { + const obj = {}; + Object.defineProperty(obj, 'foo', { + value: 42, + writable: false, + enumerable: true, + configurable: false + }); + + pm.expect(obj).to.have.ownPropertyDescriptor('foo') + .that.has.property('enumerable', true); + + pm.expect(obj).to.have.ownPropertyDescriptor('foo') + .that.has.property('writable', false); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "ownPropertyDescriptor chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - .respondTo() with .itself", () => { + test("should support .itself.respondTo() for function methods", async () => { + const testScript = ` + pm.test("itself.respondTo", () => { + function MyFunc() {} + MyFunc.staticMethod = function() {}; + + // Check that the function itself has the method + pm.expect(MyFunc).itself.to.respondTo('staticMethod'); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "itself.respondTo", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Chai Edge Cases - Real-World Patterns", () => { + test("should handle complex nested assertions from API responses", async () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: JSON.stringify({ + data: { + users: [ + { id: 1, name: "Alice", roles: ["admin", "user"] }, + { id: 2, name: "Bob", roles: ["user"] }, + ], + meta: { + total: 2, + page: 1, + }, + }, + }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("complex API response validation", () => { + const response = pm.response.json(); + + // Deep nested property checks + pm.expect(response).to.nested.include({ 'data.meta.total': 2 }); + pm.expect(response).to.nested.include({ 'data.meta.page': 1 }); + + // Array member checks + const userIds = response.data.users.map(u => u.id); + pm.expect(userIds).to.include.members([1, 2]); + + // Deep property on array element + pm.expect(response.data.users[0]).to.deep.include({ + roles: ["admin", "user"] + }); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + jsonResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex API response validation", + expectResults: [ + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("nested include"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("nested include"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("include members"), + }), + expect.objectContaining({ + status: "pass", + message: expect.stringContaining("deep include"), + }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts new file mode 100644 index 00000000..2f891595 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/cross-namespace-undefined.spec.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "vitest" +import { runTest, runTestAndGetEnvs } from "~/utils/test-helpers" +import { runPreRequestScript } from "~/node" +import { getDefaultRESTRequest } from "@hoppscotch/data" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +// Undefined marker pattern ensures values survive serialization + +describe("Cross-namespace undefined preservation", () => { + test("hopp.env.get can read undefined set by pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("undef_var", undefined) + const value = hopp.env.get("undef_var") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("pw.env.get can read undefined set by pm.environment.set in pre-request", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set("env_undef_pre", undefined) + // Verify pw.env.get can read the undefined value + const value = pw.env.get("env_undef_pre") + // Store the result to verify it was read correctly + pw.env.set("read_result", value === undefined ? "success" : "failed") + `, + { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + cookies: null, + experimentalScriptingSandbox: true, + } + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "env_undef_pre", + currentValue: "undefined", // Converted from UNDEFINED_MARKER + initialValue: "undefined", + secret: false, + }, + { + key: "read_result", + currentValue: "success", // Confirms pw.env.get returned undefined + initialValue: "success", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("pm.variables.get can read undefined from environment", () => { + return expect( + runTest( + ` + pm.environment.set("env_undef", undefined) + const value = pm.variables.get("env_undef") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("hopp.env.active.get can read undefined set by pm.variables.set", () => { + return expect( + runTest( + ` + pm.variables.set("var_undef", undefined) + const value = hopp.env.active.get("var_undef") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("hopp.env.global.get can read undefined set by pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("global_test", undefined) + const value = hopp.env.global.get("global_test") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("undefined value appears correctly in environment array", () => { + return expect( + runTestAndGetEnvs( + ` + pm.environment.set("stored_undef", undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight( + expect.objectContaining({ + selected: [ + { + key: "stored_undef", + // The value should be stored as the marker internally but exposed as "undefined" string for UI + currentValue: "undefined", + initialValue: "undefined", + secret: false, + }, + ], + }) + ) + }) + + test("undefined is preserved across multiple namespace reads", () => { + return expect( + runTest( + ` + pm.environment.set("multi_read", undefined) + + // Read from PM namespace + const pmValue = pm.environment.get("multi_read") + pm.expect(pmValue).toBe(undefined) + + // Read from hopp namespace + const hoppValue = hopp.env.get("multi_read") + pm.expect(hoppValue).toBe(undefined) + + // Read from pm.variables (which resolves from environment) + const varValue = pm.variables.get("multi_read") + pm.expect(varValue).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("overwriting undefined with string works across namespaces", () => { + return expect( + runTest( + ` + // Set undefined via PM + pm.environment.set("changeable", undefined) + pm.expect(hopp.env.get("changeable")).toBe(undefined) + + // Overwrite with string via PM + pm.environment.set("changeable", "new_value") + pm.expect(hopp.env.get("changeable")).toBe("new_value") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'new_value' to be 'new_value'", + }, + ], + }), + ]) + }) + + test("undefined precedence in pm.variables.get (environment over global)", () => { + return expect( + runTest( + ` + // Set undefined in both global and environment + pm.globals.set("precedence_test", undefined) + pm.environment.set("precedence_test", undefined) + + // pm.variables should return environment's undefined + const value = pm.variables.get("precedence_test") + pm.expect(value).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts index 050d6146..64af9b0c 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/environment.spec.ts @@ -1,35 +1,14 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pm.environment additional coverage", () => { test("pm.environment.set creates and retrieves environment variable", () => { return expect( - func( + runTest( ` pm.environment.set("test_set", "set_value") const retrieved = pm.environment.get("test_set") - pw.expect(retrieved).toBe("set_value") + pm.expect(retrieved).toBe("set_value") `, { global: [], @@ -50,12 +29,12 @@ describe("pm.environment additional coverage", () => { test("pm.environment.has correctly identifies existing and non-existing variables", () => { return expect( - func( + runTest( ` const hasExisting = pm.environment.has("existing_var") const hasNonExisting = pm.environment.has("non_existing_var") - pw.expect(hasExisting.toString()).toBe("true") - pw.expect(hasNonExisting.toString()).toBe("false") + pm.expect(hasExisting.toString()).toBe("true") + pm.expect(hasNonExisting.toString()).toBe("false") `, { global: [], @@ -84,16 +63,180 @@ describe("pm.environment additional coverage", () => { }), ]) }) + + test("pm.environment.toObject returns all environment variables set via pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + pm.environment.set("key3", "value3") + + const envObj = pm.environment.toObject() + + pm.expect(envObj.key1).toBe("value1") + pm.expect(envObj.key2).toBe("value2") + pm.expect(envObj.key3).toBe("value3") + pm.expect(Object.keys(envObj).length.toString()).toBe("3") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'value1' to be 'value1'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected 'value3' to be 'value3'", + }, + { + status: "pass", + message: "Expected '3' to be '3'", + }, + ], + }), + ]) + }) + + test("pm.environment.toObject returns empty object when no variables are set", () => { + return expect( + runTest( + ` + const envObj = pm.environment.toObject() + pm.expect(Object.keys(envObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.environment.clear removes all environment variables set via pm.environment.set", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + + // Verify variables are set + pm.expect(pm.environment.get("key1")).toBe("value1") + pm.expect(pm.environment.get("key2")).toBe("value2") + + // Clear all + pm.environment.clear() + + // Verify variables are cleared + pm.expect(pm.environment.get("key1")).toBe(undefined) + pm.expect(pm.environment.get("key2")).toBe(undefined) + + // Verify toObject returns empty + const envObj = pm.environment.toObject() + pm.expect(Object.keys(envObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'value1' to be 'value1'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.environment.unset removes key from tracking", () => { + return expect( + runTest( + ` + pm.environment.set("key1", "value1") + pm.environment.set("key2", "value2") + + // Unset one key + pm.environment.unset("key1") + + // Verify key1 is removed but key2 remains + const envObj = pm.environment.toObject() + pm.expect(envObj.key1).toBe(undefined) + pm.expect(envObj.key2).toBe("value2") + pm.expect(Object.keys(envObj).length.toString()).toBe("1") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'value2' to be 'value2'", + }, + { + status: "pass", + message: "Expected '1' to be '1'", + }, + ], + }), + ]) + }) }) describe("pm.globals additional coverage", () => { test("pm.globals.set creates and retrieves global variable", () => { return expect( - func( + runTest( ` pm.globals.set("test_global", "global_value") const retrieved = pm.globals.get("test_global") - pw.expect(retrieved).toBe("global_value") + pm.expect(retrieved).toBe("global_value") `, { global: [], @@ -111,16 +254,259 @@ describe("pm.globals additional coverage", () => { }), ]) }) + + test("pm.globals.toObject returns all global variables set via pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + pm.globals.set("globalKey3", "globalValue3") + + const globalObj = pm.globals.toObject() + + pm.expect(globalObj.globalKey1).toBe("globalValue1") + pm.expect(globalObj.globalKey2).toBe("globalValue2") + pm.expect(globalObj.globalKey3).toBe("globalValue3") + pm.expect(Object.keys(globalObj).length.toString()).toBe("3") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue1' to be 'globalValue1'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected 'globalValue3' to be 'globalValue3'", + }, + { + status: "pass", + message: "Expected '3' to be '3'", + }, + ], + }), + ]) + }) + + test("pm.globals.toObject returns empty object when no globals are set", () => { + return expect( + runTest( + ` + const globalObj = pm.globals.toObject() + pm.expect(Object.keys(globalObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear removes all global variables set via pm.globals.set", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + + // Verify variables are set + pm.expect(pm.globals.get("globalKey1")).toBe("globalValue1") + pm.expect(pm.globals.get("globalKey2")).toBe("globalValue2") + + // Clear all + pm.globals.clear() + + // Verify variables are cleared + pm.expect(pm.globals.get("globalKey1")).toBe(undefined) + pm.expect(pm.globals.get("globalKey2")).toBe(undefined) + + // Verify toObject returns empty + const globalObj = pm.globals.toObject() + pm.expect(Object.keys(globalObj).length.toString()).toBe("0") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'globalValue1' to be 'globalValue1'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected '0' to be '0'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear also removes initial global variables from environment", () => { + return expect( + runTest( + ` + // Verify initial globals exist + pm.expect(pm.globals.get("initial_global1")).toBe("initial_value1") + pm.expect(pm.globals.get("initial_global2")).toBe("initial_value2") + + // Add tracked globals + pm.globals.set("tracked_global", "tracked_value") + pm.expect(pm.globals.get("tracked_global")).toBe("tracked_value") + + // Verify toObject includes both initial and tracked + const before = pm.globals.toObject() + pm.expect(before.initial_global1).toBe("initial_value1") + pm.expect(before.tracked_global).toBe("tracked_value") + + // Clear all (both initial and tracked) + pm.globals.clear() + + // Verify ALL globals are cleared + pm.expect(pm.globals.get("initial_global1")).toBe(undefined) + pm.expect(pm.globals.get("initial_global2")).toBe(undefined) + pm.expect(pm.globals.get("tracked_global")).toBe(undefined) + + // Verify toObject returns empty + const after = pm.globals.toObject() + pm.expect(Object.keys(after).length.toString()).toBe("0") + `, + { + global: [ + { + key: "initial_global1", + currentValue: "initial_value1", + initialValue: "initial_value1", + secret: false, + }, + { + key: "initial_global2", + currentValue: "initial_value2", + initialValue: "initial_value2", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'initial_value1' to be 'initial_value1'", + }, + { + status: "pass", + message: "Expected 'initial_value2' to be 'initial_value2'", + }, + { + status: "pass", + message: "Expected 'tracked_value' to be 'tracked_value'", + }, + { + status: "pass", + message: "Expected 'initial_value1' to be 'initial_value1'", + }, + { + status: "pass", + message: "Expected 'tracked_value' to be 'tracked_value'", + }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ], + }), + ]) + }) + + test("pm.globals.unset removes key from tracking", () => { + return expect( + runTest( + ` + pm.globals.set("globalKey1", "globalValue1") + pm.globals.set("globalKey2", "globalValue2") + + // Unset one key + pm.globals.unset("globalKey1") + + // Verify key1 is removed but key2 remains + const globalObj = pm.globals.toObject() + pm.expect(globalObj.globalKey1).toBe(undefined) + pm.expect(globalObj.globalKey2).toBe("globalValue2") + pm.expect(Object.keys(globalObj).length.toString()).toBe("1") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'globalValue2' to be 'globalValue2'", + }, + { + status: "pass", + message: "Expected '1' to be '1'", + }, + ], + }), + ]) + }) }) describe("pm.variables additional coverage", () => { test("pm.variables.set creates and retrieves variable in active environment", () => { return expect( - func( + runTest( ` pm.variables.set("test_var", "test_value") const retrieved = pm.variables.get("test_var") - pw.expect(retrieved).toBe("test_value") + pm.expect(retrieved).toBe("test_value") `, { global: [], @@ -141,12 +527,12 @@ describe("pm.variables additional coverage", () => { test("pm.variables.has correctly identifies existing and non-existing variables", () => { return expect( - func( + runTest( ` const hasExisting = pm.variables.has("existing_var") const hasNonExisting = pm.variables.has("non_existing_var") - pw.expect(hasExisting.toString()).toBe("true") - pw.expect(hasNonExisting.toString()).toBe("false") + pm.expect(hasExisting.toString()).toBe("true") + pm.expect(hasNonExisting.toString()).toBe("false") `, { global: [], @@ -178,10 +564,10 @@ describe("pm.variables additional coverage", () => { test("pm.variables.get returns the correct value from any scope", () => { return expect( - func( + runTest( ` const data = pm.variables.get("scopedVar") - pw.expect(data).toBe("scopedValue") + pm.expect(data).toBe("scopedValue") `, { global: [ @@ -209,11 +595,11 @@ describe("pm.variables additional coverage", () => { test("pm.variables.replaceIn handles multiple variables", () => { return expect( - func( + runTest( ` const template = "User {{name}} has {{count}} items in {{location}}" const result = pm.variables.replaceIn(template) - pw.expect(result).toBe("User Alice has 10 items in Cart") + pm.expect(result).toBe("User Alice has 10 items in Cart") `, { global: [ diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts new file mode 100644 index 00000000..9286a126 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/expect-fail.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, test } from "vitest" +import { TestResponse } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const mockResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "OK", + headers: [], +} + +describe("pm.expect.fail() - Explicit test failures", () => { + test("pm.expect.fail() with no arguments fails the test", async () => { + const testScript = ` + pm.test("explicit failure", () => { + pm.expect.fail(); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "explicit failure", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringContaining("expect.fail()"), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() with custom message", async () => { + const testScript = ` + pm.test("custom failure message", () => { + pm.expect.fail("This test intentionally fails"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom failure message", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "This test intentionally fails", + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() with actual and expected values", async () => { + const testScript = ` + pm.test("failure with values", () => { + pm.expect.fail(5, 10); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "failure with values", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringMatching(/expected.*5.*equal.*10/i), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("pm.expect.fail() practical use case - conditional validation", async () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: JSON.stringify({ id: 1, name: "Test" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("validate response", () => { + const data = pm.response.json(); + + if (!data.email) { + pm.expect.fail("Missing required email field"); + } + + pm.expect(data).to.be.an("object"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + jsonResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "validate response", + expectResults: expect.arrayContaining([ + expect.objectContaining({ + status: "fail", + message: "Missing required email field", + }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("hopp.expect.fail() - Explicit test failures", () => { + test("hopp.expect.fail() with no arguments fails the test", async () => { + const testScript = ` + hopp.test("explicit failure", () => { + hopp.expect.fail(); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "explicit failure", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringContaining("expect.fail()"), + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("hopp.expect.fail() with custom message", async () => { + const testScript = ` + hopp.test("custom failure message", () => { + hopp.expect.fail("This test intentionally fails"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "custom failure message", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "This test intentionally fails", + }), + ], + }), + ]), + }), + ]) + ) + }) + + test("hopp.expect.fail() with actual and expected values", async () => { + const testScript = ` + hopp.test("failure with values", () => { + hopp.expect.fail("hello", "world"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "failure with values", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: expect.stringMatching( + /expected.*hello.*equal.*world/i + ), + }), + ], + }), + ]), + }), + ]) + ) + }) +}) + +describe("expect.fail() - Cross-namespace compatibility", () => { + test("both pm and hopp namespaces support fail() with same behavior", async () => { + const testScript = ` + pm.test("pm namespace fail", () => { + pm.expect.fail("PM failure"); + }); + + hopp.test("hopp namespace fail", () => { + hopp.expect.fail("Hopp failure"); + }); + ` + + const result = await runTest( + testScript, + { global: [], selected: [] }, + mockResponse + )() + + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "pm namespace fail", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "PM failure", + }), + ], + }), + expect.objectContaining({ + descriptor: "hopp namespace fail", + expectResults: [ + expect.objectContaining({ + status: "fail", + message: "Hopp failure", + }), + ], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts index f987ca43..768a28b3 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/info.spec.ts @@ -2,28 +2,10 @@ import { getDefaultRESTRequest } from "@hoppscotch/data" import * as TE from "fp-ts/TaskEither" import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" +import { runTest, defaultRequest, fakeResponse } from "~/utils/test-helpers" import { runPreRequestScript } from "~/web" -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "test response", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: { ...defaultRequest, name: "default-request", id: "test-id" }, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) - describe("pm.info context", () => { test("pm.info.eventName returns 'pre-request' in pre-request context", () => { const defaultRequest = getDefaultRESTRequest() @@ -50,12 +32,12 @@ describe("pm.info context", () => { ) }) - test("pm.info.eventName returns 'post-request' in post-request context", () => { + test("pm.info.eventName returns 'test' in test context", () => { return expect( - func( + runTest( ` pm.test("Event name is correct", () => { - pm.expect(pm.info.eventName).toBe("post-request") + pm.expect(pm.info.eventName).toBe("test") }) `, { @@ -71,7 +53,7 @@ describe("pm.info context", () => { expectResults: [ { status: "pass", - message: "Expected 'post-request' to be 'post-request'", + message: "Expected 'test' to be 'test'", }, ], }), @@ -81,8 +63,14 @@ describe("pm.info context", () => { }) test("pm.info provides requestName and requestId", () => { + const customRequest = { + ...defaultRequest, + name: "default-request", + id: "test-id", + } + return expect( - func( + runTest( ` pm.test("Request info is available", () => { pm.expect(pm.info.requestName).toBe("default-request") @@ -92,7 +80,9 @@ describe("pm.info context", () => { { global: [], selected: [], - } + }, + fakeResponse, + customRequest )() ).resolves.toEqualRight([ expect.objectContaining({ @@ -111,4 +101,36 @@ describe("pm.info context", () => { }), ]) }) + + test("pm.info.requestId falls back to requestName when id is undefined", () => { + return expect( + pipe( + runTestScript( + ` + pm.test("Request ID fallback works", () => { + pm.expect(pm.info.requestId).to.exist + pm.expect(pm.info.requestId).toBe("fallback-request-name") + }) + `, + { + envs: { global: [], selected: [] }, + request: { ...defaultRequest, name: "fallback-request-name" }, + response: fakeResponse, + } + ), + TE.map((x) => x.tests) + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Request ID fallback works", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts new file mode 100644 index 00000000..d5ba8566 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/map-set-size.spec.ts @@ -0,0 +1,204 @@ +// Map/Set serialize as {} across sandbox boundary, so we extract .size before serialization + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("Map.size property assertions", () => { + test("should support .property('size') for Map", async () => { + const testScript = ` + pm.test("Map - size property", () => { + const myMap = new Map([['key1', 'value1'], ['key2', 'value2']]); + pm.expect(myMap).to.have.property('size', 2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size property", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .property('size') with chaining for Map", async () => { + const testScript = ` + pm.test("Map - size with chaining", () => { + const myMap = new Map([['a', 1], ['b', 2], ['c', 3]]); + pm.expect(myMap).to.have.property('size').that.equals(3); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size with chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support negation for Map size", async () => { + const testScript = ` + pm.test("Map - size negation", () => { + const myMap = new Map([['key1', 'value1']]); + pm.expect(myMap).to.not.have.property('size', 5); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - size negation", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) + +describe("Set.size property assertions", () => { + test("should support .property('size') for Set", async () => { + const testScript = ` + pm.test("Set - size property", () => { + const mySet = new Set([1, 2, 3]); + pm.expect(mySet).to.have.property('size', 3); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size property", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should support .property('size') with chaining for Set", async () => { + const testScript = ` + pm.test("Set - size with chaining", () => { + const mySet = new Set(['a', 'b', 'c', 'd']); + pm.expect(mySet).to.have.property('size').that.is.above(2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size with chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support negation for Set size", async () => { + const testScript = ` + pm.test("Set - size negation", () => { + const mySet = new Set([1, 2]); + pm.expect(mySet).to.not.have.property('size', 10); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - size negation", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should handle empty Set", async () => { + const testScript = ` + pm.test("Set - empty size", () => { + const mySet = new Set(); + pm.expect(mySet).to.have.property('size', 0); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Set - empty size", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should handle empty Map", async () => { + const testScript = ` + pm.test("Map - empty size", () => { + const myMap = new Map(); + pm.expect(myMap).to.have.property('size', 0); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Map - empty size", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts new file mode 100644 index 00000000..66a8ed84 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/pre-request-type-preservation.spec.ts @@ -0,0 +1,513 @@ +import { describe, expect, test } from "vitest" +import { getDefaultRESTRequest } from "@hoppscotch/data" +import { runPreRequestScript } from "~/node" + +const DEFAULT_REQUEST = getDefaultRESTRequest() + +// Pre-request scripts use markers to preserve null/undefined across serialization + +describe("PM namespace type preservation in pre-request context", () => { + const emptyEnvs = { + envs: { + global: [], + selected: [], + }, + request: DEFAULT_REQUEST, + } + + describe("pm.environment.set() type preservation", () => { + test("preserves arrays (not String() coercion to '1,2,3')", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('testArray', [1, 2, 3]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "testArray", + currentValue: "[1,2,3]", // JSON stringified for UI display + initialValue: "[1,2,3]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects (not String() coercion to '[object Object]')", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('testObj', { foo: 'bar', num: 42 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "testObj", + currentValue: '{"foo":"bar","num":42}', // JSON stringified for UI display + secret: false, + initialValue: '{"foo":"bar","num":42}', + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null with NULL_MARKER", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('nullValue', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "nullValue", + currentValue: "null", // Converted from NULL_MARKER by getUpdatedEnvs + initialValue: "null", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves undefined with UNDEFINED_MARKER", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('undefinedValue', undefined) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "undefinedValue", + currentValue: "undefined", // Converted from UNDEFINED_MARKER by getUpdatedEnvs + initialValue: "undefined", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves nested structures", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('nested', { + users: [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" } + ], + meta: { count: 2, filters: ["active", "verified"] } + }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "nested", + currentValue: + '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"meta":{"count":2,"filters":["active","verified"]}}', + initialValue: + '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"meta":{"count":2,"filters":["active","verified"]}}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves primitives correctly", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('str', 'hello') + pm.environment.set('num', 42) + pm.environment.set('bool', true) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "str", + currentValue: "hello", + initialValue: "hello", + secret: false, + }, + { + key: "num", + currentValue: "42", // Converted to string for UI compatibility + initialValue: "42", + secret: false, + }, + { + key: "bool", + currentValue: "true", // Converted to string for UI compatibility + initialValue: "true", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("pm.globals.set() type preservation", () => { + test("preserves arrays in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalArray', [10, 20, 30]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalArray", + currentValue: "[10,20,30]", + initialValue: "[10,20,30]", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalObj', { env: 'prod', port: 8080 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalObj", + currentValue: '{"env":"prod","port":8080}', + initialValue: '{"env":"prod","port":8080}', + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalNull', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalNull", + currentValue: "null", // Converted from NULL_MARKER + initialValue: "null", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves undefined in globals", () => { + return expect( + runPreRequestScript( + ` + pm.globals.set('globalUndefined', undefined) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [ + { + key: "globalUndefined", + currentValue: "undefined", // Converted from UNDEFINED_MARKER + initialValue: "undefined", + secret: false, + }, + ], + selected: [], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("pm.variables.set() type preservation", () => { + test("preserves arrays (uses active scope)", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varArray', [5, 10, 15]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varArray", + currentValue: "[5,10,15]", + initialValue: "[5,10,15]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves objects", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varObj', { status: 'active', count: 100 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varObj", + currentValue: '{"status":"active","count":100}', + initialValue: '{"status":"active","count":100}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("preserves null", () => { + return expect( + runPreRequestScript( + ` + pm.variables.set('varNull', null) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "varNull", + currentValue: "null", + initialValue: "null", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("Regression tests for String() coercion bug", () => { + test("CRITICAL: does NOT convert [1,2,3] to '1,2,3' string", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('arr', [1, 2, 3]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "arr", + currentValue: "[1,2,3]", // JSON stringified for UI display + initialValue: "[1,2,3]", + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("CRITICAL: does NOT convert object to '[object Object]'", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('obj', { foo: 'bar' }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "obj", + currentValue: '{"foo":"bar"}', // Object (JSON string for UI), not "[object Object]" string + initialValue: '{"foo":"bar"}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) + + describe("Complex scenarios", () => { + test("mixed array of primitives, null, undefined, objects", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('mixed', [ + 'string', + 42, + true, + null, + undefined, + [1, 2], + { key: 'value' } + ]) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + global: [], + selected: [ + { + key: "mixed", + currentValue: + '["string",42,true,null,null,[1,2],{"key":"value"}]', + initialValue: + '["string",42,true,null,null,[1,2],{"key":"value"}]', // JSON stringified for UI display + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + + test("multiple PM namespace calls in same pre-request", () => { + return expect( + runPreRequestScript( + ` + pm.environment.set('arr1', [1, 2]) + pm.globals.set('arr2', [3, 4]) + pm.variables.set('arr3', [5, 6]) + pm.environment.set('obj1', { a: 1 }) + pm.globals.set('obj2', { b: 2 }) + `, + emptyEnvs + )() + ).resolves.toEqualRight({ + updatedEnvs: { + selected: [ + { + key: "arr1", + currentValue: "[1,2]", + initialValue: "[1,2]", + secret: false, + }, + { + key: "arr3", + currentValue: "[5,6]", + initialValue: "[5,6]", + secret: false, + }, + { + key: "obj1", + currentValue: '{"a":1}', + initialValue: '{"a":1}', + secret: false, + }, + ], + global: [ + { + key: "arr2", + currentValue: "[3,4]", + initialValue: "[3,4]", + secret: false, + }, + { + key: "obj2", + currentValue: '{"b":2}', + initialValue: '{"b":2}', + secret: false, + }, + ], + }, + updatedRequest: DEFAULT_REQUEST, + updatedCookies: null, + }) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts new file mode 100644 index 00000000..e19d8215 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/property-chaining.spec.ts @@ -0,0 +1,216 @@ +// .property('key') returns a NEW expectation wrapping the property value + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("property() with .that chaining", () => { + test("should chain property value with .that.equals()", async () => { + const testScript = ` + pm.test("property chaining with that", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a').that.equals(1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining with that", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should chain nested property with .that.is.an()", async () => { + const testScript = ` + pm.test("nested property chaining", () => { + pm.expect({ nested: { value: 42 } }) + .to.have.property('nested') + .that.is.an('object'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "nested property chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support .which as alias for .that", async () => { + const testScript = ` + pm.test("property chaining with which", () => { + pm.expect({ x: 1 }).to.have.property('x').which.equals(1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining with which", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should support complex chaining", async () => { + const testScript = ` + pm.test("complex property chaining", () => { + pm.expect({ name: 'John', age: 30 }) + .to.be.an('object') + .and.have.property('name') + .that.is.a('string') + .and.equals('John'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex property chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail when chained value doesn't match", async () => { + const testScript = ` + pm.test("property chaining fails correctly", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a').that.equals(2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property chaining fails correctly", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "fail" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("property() with value parameter", () => { + test("should assert property value directly", async () => { + const testScript = ` + pm.test("property with value", () => { + pm.expect({ a: 1, b: 2 }).to.have.property('a', 1); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property with value", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail when property value doesn't match", async () => { + const testScript = ` + pm.test("property value mismatch", () => { + pm.expect({ a: 1, b: 2 }).to.not.have.property('a', 2); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "property value mismatch", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) +}) + +describe("own.property() assertions", () => { + test("should check own properties vs inherited", async () => { + const testScript = ` + pm.test("own property check", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + pm.expect(obj).to.not.have.own.property('inherited'); + pm.expect(obj).to.have.property('inherited'); + }); + ` + + const result = await runTest(testScript)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property check", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts similarity index 81% rename from packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request.spec.ts rename to packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts index 3967fb19..bf84315e 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/basic.spec.ts @@ -1,31 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pm.request coverage", () => { test("pm.request object provides access to request data", () => { return expect( - func( + runTest( ` pw.expect(pm.request.url.toString()).toBe("https://echo.hoppscotch.io") pw.expect(pm.request.method).toBe("GET") @@ -59,7 +38,7 @@ describe("pm.request coverage", () => { test("pm.request.url provides correct URL value", () => { return expect( - func( + runTest( ` pw.expect(pm.request.url.toString()).toBe("https://echo.hoppscotch.io") pw.expect(pm.request.url.toString().length).toBe(26) @@ -93,7 +72,7 @@ describe("pm.request coverage", () => { test("pm.request.headers functionality", () => { return expect( - func( + runTest( ` pw.expect(pm.request.headers.get("Content-Type")).toBe(null) pw.expect(pm.request.headers.has("Content-Type")).toBe(false) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts new file mode 100644 index 00000000..3b41798b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist-advanced.spec.ts @@ -0,0 +1,509 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users", + method: "POST", + headers: [ + { key: "Content-Type", value: "application/json", active: true }, + { key: "Authorization", value: "Bearer token123", active: true }, + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.headers.find()", () => { + test("finds header by predicate function", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find((header) => header.key === 'Authorization') + + console.log("Found header:", result) + console.log("Header key:", result.key) + console.log("Header value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found header:", + expect.objectContaining({ + key: "Authorization", + value: "Bearer token123", + }), + ], + }), + expect.objectContaining({ args: ["Header key:", "Authorization"] }), + expect.objectContaining({ + args: ["Header value:", "Bearer token123"], + }), + ]), + }) + ) + }) + + test("finds header by key string (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find('content-type') + + console.log("Found by string:", result) + console.log("Value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found by string:", + expect.objectContaining({ + key: "Content-Type", + value: "application/json", + }), + ], + }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + ]), + }) + ) + }) + + test("returns null when header not found", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.headers.find('Nonexistent-Header') + + console.log("Result:", result) + console.log("Is null:", result === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Result:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.indexOf()", () => { + test("returns index of header by key (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const idx1 = pm.request.headers.indexOf('content-type') + const idx2 = pm.request.headers.indexOf('AUTHORIZATION') + const idx3 = pm.request.headers.indexOf('X-Custom-Header') + + console.log("Index of content-type:", idx1) + console.log("Index of AUTHORIZATION:", idx2) + console.log("Index of X-Custom-Header:", idx3) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index of content-type:", 0] }), + expect.objectContaining({ args: ["Index of AUTHORIZATION:", 1] }), + expect.objectContaining({ args: ["Index of X-Custom-Header:", 2] }), + ]), + }) + ) + }) + + test("returns index of header by object (case-insensitive)", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.headers.indexOf({ key: 'authorization' }) + + console.log("Index:", idx) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", 1] }), + ]), + }) + ) + }) + + test("returns -1 when header not found", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.headers.indexOf('Nonexistent') + + console.log("Index:", idx) + console.log("Is -1:", idx === -1) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", -1] }), + expect.objectContaining({ args: ["Is -1:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.insert()", () => { + test("inserts header before specified key", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-API-Key', value: 'secret123' }, 'Authorization') + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + ["Content-Type", "X-API-Key", "Authorization", "X-Custom-Header"], + ], + }), + ]), + }) + ) + }) + + test("appends header if 'before' key not found", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-New-Header', value: 'new' }, 'NonExistent') + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-New-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("appends header when no 'before' specified", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.insert({ key: 'X-Added-Header', value: 'added' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-Added-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.headers.insert({ value: 'test' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Header must have a 'key' property"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.append()", () => { + test("moves existing header to end", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.append({ key: 'Content-Type', value: 'application/xml' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + const contentType = pm.request.headers.get('Content-Type') + + console.log("All headers:", allHeaders) + console.log("Content-Type value:", contentType) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + ["Authorization", "X-Custom-Header", "Content-Type"], + ], + }), + expect.objectContaining({ + args: ["Content-Type value:", "application/xml"], + }), + ]), + }) + ) + }) + + test("adds new header at end", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.append({ key: 'X-New-Header', value: 'new-value' }) + + const allHeaders = pm.request.headers.map((h) => h.key) + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + [ + "Content-Type", + "Authorization", + "X-Custom-Header", + "X-New-Header", + ], + ], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.headers.append({ value: 'test' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Header must have a 'key' property"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.assimilate()", () => { + test("updates existing headers and adds new ones from array", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate([ + { key: 'Content-Type', value: 'text/plain' }, + { key: 'X-API-Key', value: 'key123' } + ]) + + const allHeaders = pm.request.headers.all() + console.log("Content-Type:", allHeaders['Content-Type']) + console.log("Authorization:", allHeaders['Authorization']) + console.log("X-API-Key:", allHeaders['X-API-Key']) + console.log("Count:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Content-Type:", "text/plain"] }), + expect.objectContaining({ + args: ["Authorization:", "Bearer token123"], + }), + expect.objectContaining({ args: ["X-API-Key:", "key123"] }), + expect.objectContaining({ args: ["Count:", 4] }), + ]), + }) + ) + }) + + test("updates existing headers and adds new ones from object", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate({ + 'Content-Type': 'application/xml', + 'X-New-Header': 'new-value' + }) + + const allHeaders = pm.request.headers.all() + console.log("All headers:", allHeaders) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + expect.objectContaining({ + "Content-Type": "application/xml", + Authorization: "Bearer token123", + "X-Custom-Header": "custom-value", + "X-New-Header": "new-value", + }), + ], + }), + ]), + }) + ) + }) + + test("prunes headers not in source when prune=true", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.assimilate( + { + 'Content-Type': 'application/json', + 'X-API-Key': 'key123' + }, + true + ) + + const allHeaders = pm.request.headers.all() + const count = pm.request.headers.count() + + console.log("All headers:", allHeaders) + console.log("Count:", count) + console.log("Has Authorization:", pm.request.headers.has('Authorization')) + console.log("Has X-Custom-Header:", pm.request.headers.has('X-Custom-Header')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All headers:", + expect.objectContaining({ + "Content-Type": "application/json", + "X-API-Key": "key123", + }), + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Has Authorization:", false] }), + expect.objectContaining({ + args: ["Has X-Custom-Header:", false], + }), + ]), + }) + ) + }) + + test("throws error for invalid source", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.headers.assimilate("invalid") + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Source must be an array or object"], + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts new file mode 100644 index 00000000..67506fcc --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/headers/propertylist.spec.ts @@ -0,0 +1,950 @@ +import { HoppRESTRequest, getDefaultRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + { + key: "X-API-Version", + value: "v1", + active: true, + description: "", + }, + ], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.headers.each()", () => { + test("iterates over all headers", () => { + return expect( + runPreRequestScript( + ` + const keys = [] + const values = [] + + pm.request.headers.each((header) => { + keys.push(header.key) + values.push(header.value) + }) + + console.log("Keys:", keys) + console.log("Values:", values) + console.log("Count:", keys.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Keys:", ["Content-Type", "Authorization", "X-API-Version"]], + }), + expect.objectContaining({ + args: ["Values:", ["application/json", "Bearer token123", "v1"]], + }), + expect.objectContaining({ args: ["Count:", 3] }), + ]), + }) + ) + }) + + test("callback receives complete header object", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.each((header) => { + console.log("Key:", header.key) + console.log("Value:", header.value) + console.log("Has active:", 'active' in header) + return // Check only first header + }) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "Content-Type"] }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + expect.objectContaining({ args: ["Has active:", true] }), + ]), + }) + ) + }) + + test("updates after headers are modified", () => { + return expect( + runPreRequestScript( + ` + let count1 = 0 + pm.request.headers.each(() => { count1++ }) + console.log("Initial count:", count1) + + pm.request.headers.add({ key: 'X-Custom', value: 'custom-value' }) + + let count2 = 0 + pm.request.headers.each(() => { count2++ }) + console.log("Count after add:", count2) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["Count after add:", 4] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.map()", () => { + test("transforms headers and returns new array", () => { + return expect( + runPreRequestScript( + ` + const mapped = pm.request.headers.map((header) => { + return header.key.toLowerCase() + ': ' + header.value + }) + + console.log("Mapped:", mapped) + console.log("Array length:", mapped.length) + console.log("First item:", mapped[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Mapped:", + [ + "content-type: application/json", + "authorization: Bearer token123", + "x-api-version: v1", + ], + ], + }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ + args: ["First item:", "content-type: application/json"], + }), + ]), + }) + ) + }) + + test("does not modify original headers", () => { + return expect( + runPreRequestScript( + ` + const originalCount = pm.request.headers.count() + + const mapped = pm.request.headers.map((header) => { + return { modified: header.key } + }) + + const afterCount = pm.request.headers.count() + + console.log("Original count:", originalCount) + console.log("Mapped length:", mapped.length) + console.log("After count:", afterCount) + console.log("Headers unchanged:", originalCount === afterCount) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Original count:", 3] }), + expect.objectContaining({ args: ["Mapped length:", 3] }), + expect.objectContaining({ args: ["After count:", 3] }), + expect.objectContaining({ args: ["Headers unchanged:", true] }), + ]), + }) + ) + }) + + test("returns array with custom objects", () => { + return expect( + runPreRequestScript( + ` + const customHeaders = pm.request.headers.map((header) => ({ + name: header.key, + val: header.value, + length: header.value.length + })) + + console.log("First custom:", customHeaders[0]) + console.log("Has name:", 'name' in customHeaders[0]) + console.log("Has val:", 'val' in customHeaders[0]) + console.log("Has length:", 'length' in customHeaders[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "First custom:", + { name: "Content-Type", val: "application/json", length: 16 }, + ], + }), + expect.objectContaining({ args: ["Has name:", true] }), + expect.objectContaining({ args: ["Has val:", true] }), + expect.objectContaining({ args: ["Has length:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.filter()", () => { + test("filters headers based on condition", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.key.startsWith('X-') + }) + + console.log("Filtered:", filtered) + console.log("Count:", filtered.length) + console.log("Keys:", filtered.map(h => h.key)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 1] }), + expect.objectContaining({ args: ["Keys:", ["X-API-Version"]] }), + ]), + }) + ) + }) + + test("filters by value content", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.value.includes('Bearer') + }) + + console.log("Filtered count:", filtered.length) + console.log("Header key:", filtered[0].key) + console.log("Header value:", filtered[0].value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered count:", 1] }), + expect.objectContaining({ args: ["Header key:", "Authorization"] }), + expect.objectContaining({ + args: ["Header value:", "Bearer token123"], + }), + ]), + }) + ) + }) + + test("returns empty array when no matches", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return header.key === 'NonExistent' + }) + + console.log("Filtered:", filtered) + console.log("Is array:", Array.isArray(filtered)) + console.log("Length:", filtered.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered:", []] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) + + test("returns all headers when condition always true", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.headers.filter((header) => { + return true + }) + + console.log("Count:", filtered.length) + console.log("Equals total:", filtered.length === pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Equals total:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.count()", () => { + test("returns correct count of headers", () => { + return expect( + runPreRequestScript( + ` + const count = pm.request.headers.count() + + console.log("Count:", count) + console.log("Type:", typeof count) + + let manualCount = 0 + pm.request.headers.each(() => { manualCount++ }) + console.log("Manual count:", manualCount) + console.log("Counts match:", count === manualCount) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Type:", "number"] }), + expect.objectContaining({ args: ["Manual count:", 3] }), + expect.objectContaining({ args: ["Counts match:", true] }), + ]), + }) + ) + }) + + test("updates after headers are added or removed", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial count:", pm.request.headers.count()) + + pm.request.headers.add({ key: 'X-New-Header', value: 'value' }) + console.log("After add:", pm.request.headers.count()) + + pm.request.headers.remove('Content-Type') + console.log("After remove:", pm.request.headers.count()) + + pm.request.headers.clear() + console.log("After clear:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["After add:", 4] }), + expect.objectContaining({ args: ["After remove:", 3] }), + expect.objectContaining({ args: ["After clear:", 0] }), + ]), + }) + ) + }) + + test("returns 0 for empty headers", () => { + const requestWithoutHeaders: HoppRESTRequest = { + ...baseRequest, + headers: [], + } + + return expect( + runPreRequestScript( + ` + const count = pm.request.headers.count() + + console.log("Count:", count) + console.log("Is zero:", count === 0) + `, + { envs, request: requestWithoutHeaders } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 0] }), + expect.objectContaining({ args: ["Is zero:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.idx()", () => { + test("returns header at specific index", () => { + return expect( + runPreRequestScript( + ` + const first = pm.request.headers.idx(0) + const second = pm.request.headers.idx(1) + const third = pm.request.headers.idx(2) + + console.log("First:", first) + console.log("Second:", second) + console.log("Third:", third) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "First:", + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + }), + expect.objectContaining({ + args: [ + "Second:", + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + ], + }), + expect.objectContaining({ + args: [ + "Third:", + { + key: "X-API-Version", + value: "v1", + active: true, + description: "", + }, + ], + }), + ]), + }) + ) + }) + + test("returns null for out-of-bounds index", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(999) + + console.log("Out of bounds:", header) + console.log("Is null:", header === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Out of bounds:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("returns null for negative index", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(-1) + + console.log("Negative index:", header) + console.log("Is null:", header === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Negative index:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("can access header properties via idx", () => { + return expect( + runPreRequestScript( + ` + const header = pm.request.headers.idx(0) + + console.log("Key:", header.key) + console.log("Value:", header.value) + console.log("Active:", header.active) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "Content-Type"] }), + expect.objectContaining({ args: ["Value:", "application/json"] }), + expect.objectContaining({ args: ["Active:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.clear()", () => { + test("removes all headers", () => { + return expect( + runPreRequestScript( + ` + console.log("Count before:", pm.request.headers.count()) + console.log("Headers before:", pm.request.headers.toObject()) + + pm.request.headers.clear() + + console.log("Count after:", pm.request.headers.count()) + console.log("Headers after:", pm.request.headers.toObject()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [], + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count before:", 3] }), + expect.objectContaining({ args: ["Count after:", 0] }), + expect.objectContaining({ args: ["Headers after:", {}] }), + ]), + }) + ) + }) + + test("allows adding headers after clearing", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.clear() + console.log("After clear:", pm.request.headers.count()) + + pm.request.headers.add({ key: 'X-New', value: 'value' }) + console.log("After add:", pm.request.headers.count()) + console.log("Headers:", pm.request.headers.toObject()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["After clear:", 0] }), + expect.objectContaining({ args: ["After add:", 1] }), + expect.objectContaining({ args: ["Headers:", { "X-New": "value" }] }), + ]), + }) + ) + }) + + test("clear followed by get returns null", () => { + return expect( + runPreRequestScript( + ` + console.log("Before clear - Content-Type:", pm.request.headers.get('Content-Type')) + + pm.request.headers.clear() + + console.log("After clear - Content-Type:", pm.request.headers.get('Content-Type')) + console.log("After clear - has Content-Type:", pm.request.headers.has('Content-Type')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Before clear - Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["After clear - Content-Type:", null], + }), + expect.objectContaining({ + args: ["After clear - has Content-Type:", false], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.toObject()", () => { + test("returns headers as key-value object", () => { + return expect( + runPreRequestScript( + ` + const obj = pm.request.headers.toObject() + + console.log("Headers object:", obj) + console.log("Type:", typeof obj) + console.log("Content-Type:", obj['Content-Type']) + console.log("Authorization:", obj['Authorization']) + console.log("X-API-Version:", obj['X-API-Version']) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Headers object:", + { + "Content-Type": "application/json", + Authorization: "Bearer token123", + "X-API-Version": "v1", + }, + ], + }), + expect.objectContaining({ args: ["Type:", "object"] }), + expect.objectContaining({ + args: ["Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["Authorization:", "Bearer token123"], + }), + expect.objectContaining({ args: ["X-API-Version:", "v1"] }), + ]), + }) + ) + }) + + test("matches all() method", () => { + return expect( + runPreRequestScript( + ` + const toObjectResult = pm.request.headers.toObject() + const allResult = pm.request.headers.all() + + console.log("toObject:", toObjectResult) + console.log("all:", allResult) + console.log("Are equal:", JSON.stringify(toObjectResult) === JSON.stringify(allResult)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Are equal:", true] }), + ]), + }) + ) + }) + + test("returns empty object for empty headers", () => { + const requestWithoutHeaders: HoppRESTRequest = { + ...baseRequest, + headers: [], + } + + return expect( + runPreRequestScript( + ` + const obj = pm.request.headers.toObject() + + console.log("Headers object:", obj) + console.log("Keys count:", Object.keys(obj).length) + console.log("Is empty:", Object.keys(obj).length === 0) + `, + { envs, request: requestWithoutHeaders } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Headers object:", {}] }), + expect.objectContaining({ args: ["Keys count:", 0] }), + expect.objectContaining({ args: ["Is empty:", true] }), + ]), + }) + ) + }) + + test("updates dynamically after mutations", () => { + return expect( + runPreRequestScript( + ` + const before = pm.request.headers.toObject() + console.log("Before:", before) + + pm.request.headers.add({ key: 'X-Custom', value: 'test' }) + + const after = pm.request.headers.toObject() + console.log("After:", after) + console.log("Has X-Custom:", 'X-Custom' in after) + console.log("X-Custom value:", after['X-Custom']) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has X-Custom:", true] }), + expect.objectContaining({ args: ["X-Custom value:", "test"] }), + ]), + }) + ) + }) +}) + +describe("combined headers PropertyList operations", () => { + test("chaining multiple PropertyList methods", () => { + return expect( + runPreRequestScript( + ` + const filteredAndMapped = pm.request.headers + .filter(h => h.key.startsWith('X-') || h.key === 'Content-Type') + .map(h => ({ name: h.key, length: h.value.length })) + + console.log("Result:", filteredAndMapped) + console.log("Count:", filteredAndMapped.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Result:", + [ + { name: "Content-Type", length: 16 }, + { name: "X-API-Version", length: 2 }, + ], + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + ]), + }) + ) + }) + + test("using each to build custom structure", () => { + return expect( + runPreRequestScript( + ` + const headerMap = new Map() + + pm.request.headers.each((header) => { + headerMap.set(header.key.toLowerCase(), header.value.toUpperCase()) + }) + + console.log("Map size:", headerMap.size) + console.log("content-type:", headerMap.get('content-type')) + console.log("authorization:", headerMap.get('authorization')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Map size:", 3] }), + expect.objectContaining({ + args: ["content-type:", "APPLICATION/JSON"], + }), + expect.objectContaining({ + args: ["authorization:", "BEARER TOKEN123"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers.remove() - case insensitive", () => { + const envs: TestResult["envs"] = { + global: [], + selected: [], + } + + const baseRequest: HoppRESTRequest = { + ...getDefaultRESTRequest(), + name: "Test", + method: "GET", + endpoint: "https://example.com/api", + headers: [ + { key: "Content-Type", value: "application/json", active: true }, + { key: "Authorization", value: "Bearer token123", active: true }, + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + } + + test("removes header with exact case match", () => { + return expect( + runPreRequestScript( + ` + const hasContentType = pm.request.headers.has("Content-Type") + pm.request.headers.remove("Content-Type") + const afterRemove = pm.request.headers.has("Content-Type") + + console.log("Has before:", hasContentType) + console.log("Has after:", afterRemove) + console.log("Count after:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ key: "Content-Type" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + expect.objectContaining({ args: ["Count after:", 2] }), + ]), + }) + ) + }) + + test("removes header with different case (lowercase)", () => { + return expect( + runPreRequestScript( + ` + const hasAuth = pm.request.headers.has("Authorization") + // Remove with lowercase - should be case-insensitive + pm.request.headers.remove("authorization") + const afterRemove = pm.request.headers.has("Authorization") + + console.log("Has before:", hasAuth) + console.log("Has after:", afterRemove) + console.log("Count after:", pm.request.headers.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ + key: expect.stringMatching(/^authorization$/i), + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + expect.objectContaining({ args: ["Count after:", 2] }), + ]), + }) + ) + }) + + test("removes header with different case (UPPERCASE)", () => { + return expect( + runPreRequestScript( + ` + const hasCustom = pm.request.headers.has("X-Custom-Header") + // Remove with uppercase - should be case-insensitive + pm.request.headers.remove("X-CUSTOM-HEADER") + const afterRemove = pm.request.headers.has("X-Custom-Header") + + console.log("Has before:", hasCustom) + console.log("Has after:", afterRemove) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ + key: expect.stringMatching(/^x-custom-header$/i), + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has before:", true] }), + expect.objectContaining({ args: ["Has after:", false] }), + ]), + }) + ) + }) + + test("multiple remove operations with mixed case", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.remove("Content-Type") + pm.request.headers.remove("authorization") // lowercase + + const hasContentType = pm.request.headers.has("Content-Type") + const hasAuth = pm.request.headers.has("Authorization") + const hasCustom = pm.request.headers.has("X-Custom-Header") + + console.log("Final count:", pm.request.headers.count()) + console.log("Has Content-Type:", hasContentType) + console.log("Has Authorization:", hasAuth) + console.log("Has Custom:", hasCustom) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: [ + { key: "X-Custom-Header", value: "custom-value", active: true }, + ], + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Final count:", 1] }), + expect.objectContaining({ args: ["Has Content-Type:", false] }), + expect.objectContaining({ args: ["Has Authorization:", false] }), + expect.objectContaining({ args: ["Has Custom:", true] }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts new file mode 100644 index 00000000..e089514c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/mutations.spec.ts @@ -0,0 +1,1039 @@ +import { HoppRESTAuth, HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://example.com/api/users?filter=active&sort=name", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + { + key: "Authorization", + value: "Bearer token123", + active: true, + description: "", + }, + ], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request URL property mutations", () => { + test("pm.request.url.protocol mutation changes protocol from https to http", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial protocol:", pm.request.url.protocol) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.protocol = 'http' + + console.log("Updated protocol:", pm.request.url.protocol) + console.log("Updated URL:", pm.request.url.toString()) + console.log("hopp.request.url:", hopp.request.url) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("http://example.com"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial protocol:", "https"] }), + expect.objectContaining({ args: ["Updated protocol:", "http"] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated URL:", + expect.stringContaining("http://"), + ]), + }), + ]), + }) + ) + }) + + test("pm.request.url.host mutation changes hostname", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial host:", pm.request.url.host) + console.log("Initial hostname:", pm.request.url.host.join('.')) + + pm.request.url.host = ['api', 'newdomain', 'com'] + + console.log("Updated host:", pm.request.url.host) + console.log("Updated hostname:", pm.request.url.host.join('.')) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("api.newdomain.com"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", ["example", "com"]], + }), + expect.objectContaining({ + args: ["Updated host:", ["api", "newdomain", "com"]], + }), + expect.objectContaining({ + args: ["Updated hostname:", "api.newdomain.com"], + }), + ]), + }) + ) + }) + + test("pm.request.url.path mutation changes URL path", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial path:", pm.request.url.path) + + pm.request.url.path = ['v2', 'posts', '123'] + + console.log("Updated path:", pm.request.url.path) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("/v2/posts/123"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial path:", ["api", "users"]], + }), + expect.objectContaining({ + args: ["Updated path:", ["v2", "posts", "123"]], + }), + ]), + }) + ) + }) + + test("pm.request.url.port mutation changes port", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial port:", pm.request.url.port) + + pm.request.url.port = '8080' + + console.log("Updated port:", pm.request.url.port) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining(":8080"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial port:", "443"] }), + expect.objectContaining({ args: ["Updated port:", "8080"] }), + ]), + }) + ) + }) + + test("multiple URL property mutations are applied sequentially", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.protocol = 'http' + console.log("After protocol:", pm.request.url.toString()) + + pm.request.url.host = ['api', 'test', 'com'] + console.log("After host:", pm.request.url.toString()) + + pm.request.url.path = ['v3', 'data'] + console.log("After path:", pm.request.url.toString()) + + pm.request.url.port = '3000' + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://api.test.com:3000/v3/data?filter=active&sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Final URL:", + "http://api.test.com:3000/v3/data?filter=active&sort=name", + ]), + }), + ]), + }) + ) + }) + + test("pm.request.url.toString() returns dynamically updated URL", () => { + return expect( + runPreRequestScript( + ` + const url1 = pm.request.url.toString() + console.log("URL before mutation:", url1) + + pm.request.url.protocol = 'http' + pm.request.url.path = ['updated'] + + const url2 = pm.request.url.toString() + console.log("URL after mutation:", url2) + + console.log("URLs are different:", url1 !== url2) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "URL before mutation:", + "https://example.com/api/users?filter=active&sort=name", + ], + }), + expect.objectContaining({ + args: [ + "URL after mutation:", + "http://example.com/updated?filter=active&sort=name", + ], + }), + expect.objectContaining({ + args: ["URLs are different:", true], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query methods", () => { + test("pm.request.url.query.add() adds new query parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'limit', value: '10' }) + + console.log("Updated query params:", pm.request.url.query.all()) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("limit=10"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial query params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: [ + "Updated query params:", + { filter: "active", sort: "name", limit: "10" }, + ], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.add() with empty value", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.add({ key: 'flag' }) + + console.log("Query params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("flag="), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Query params:", + { filter: "active", sort: "name", flag: "" }, + ], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.remove() removes existing query parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.remove('filter') + + console.log("Updated query params:", pm.request.url.query.all()) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://example.com/api/users?sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated query params:", { sort: "name" }], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.remove() handles non-existent parameter gracefully", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial query params:", pm.request.url.query.all()) + + pm.request.url.query.remove('nonexistent') + + console.log("Updated query params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: baseRequest.endpoint, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated query params:", { filter: "active", sort: "name" }], + }), + ]), + }) + ) + }) + + test("pm.request.url.query.all() returns all query parameters as object", () => { + return expect( + runPreRequestScript( + ` + const allParams = pm.request.url.query.all() + console.log("All params:", allParams) + console.log("Filter param:", allParams.filter) + console.log("Sort param:", allParams.sort) + console.log("Param count:", Object.keys(allParams).length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: ["Filter param:", "active"], + }), + expect.objectContaining({ + args: ["Sort param:", "name"], + }), + expect.objectContaining({ + args: ["Param count:", 2], + }), + ]), + }) + ) + }) + + test("multiple query mutations work correctly", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'page', value: '1' }) + console.log("After add:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + console.log("After second add:", pm.request.url.query.all()) + + pm.request.url.query.remove('sort') + console.log("After remove:", pm.request.url.query.all()) + + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: + "https://example.com/api/users?filter=active&page=1&limit=20", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "After remove:", + { filter: "active", page: "1", limit: "20" }, + ], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url string assignment", () => { + test("pm.request.url string assignment replaces entire URL", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url = "http://newapi.example.com:8080/v2/posts?id=5" + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Updated protocol:", pm.request.url.protocol) + console.log("Updated host:", pm.request.url.host) + console.log("Updated path:", pm.request.url.path) + console.log("Updated query:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://newapi.example.com:8080/v2/posts?id=5", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Updated protocol:", "http"] }), + expect.objectContaining({ + args: ["Updated host:", ["newapi", "example", "com"]], + }), + expect.objectContaining({ args: ["Updated path:", ["v2", "posts"]] }), + expect.objectContaining({ args: ["Updated query:", { id: "5" }] }), + ]), + }) + ) + }) + + test("pm.request.url string assignment followed by property mutations", () => { + return expect( + runPreRequestScript( + ` + pm.request.url = "https://api.example.com/users" + console.log("After string assignment:", pm.request.url.toString()) + + pm.request.url.path = ['posts', '42'] + console.log("After path mutation:", pm.request.url.toString()) + + pm.request.url.query.add({ key: 'include', value: 'comments' }) + console.log("Final URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/posts/42?include=comments", + }), + }) + ) + }) +}) + +describe("pm.request method mutations", () => { + test("pm.request.method mutation changes HTTP method", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial method:", pm.request.method) + + pm.request.method = 'POST' + + console.log("Updated method:", pm.request.method) + console.log("hopp.request.method:", hopp.request.method) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + method: "POST", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial method:", "GET"] }), + expect.objectContaining({ args: ["Updated method:", "POST"] }), + expect.objectContaining({ args: ["hopp.request.method:", "POST"] }), + ]), + }) + ) + }) + + test("pm.request.method preserves case (matches Postman)", () => { + return expect( + runPreRequestScript( + ` + pm.request.method = 'put' + console.log("Method (lowercase input):", pm.request.method) + + pm.request.method = 'DeLeTe' + console.log("Method (mixed case input):", pm.request.method) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + method: "DeLeTe", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Method (lowercase input):", "put"], + }), + expect.objectContaining({ + args: ["Method (mixed case input):", "DeLeTe"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.headers mutations", () => { + test("pm.request.headers.add() adds new header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial X-Custom-Header:", pm.request.headers.get("X-Custom-Header")) + + pm.request.headers.add({ key: 'X-Custom-Header', value: 'custom-value' }) + + console.log("Updated X-Custom-Header:", pm.request.headers.get("X-Custom-Header")) + console.log("Has X-Custom-Header:", pm.request.headers.has("X-Custom-Header")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-Custom-Header", + value: "custom-value", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial X-Custom-Header:", null] }), + expect.objectContaining({ + args: ["Updated X-Custom-Header:", "custom-value"], + }), + expect.objectContaining({ args: ["Has X-Custom-Header:", true] }), + ]), + }) + ) + }) + + test("pm.request.headers.upsert() updates existing header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial Content-Type:", pm.request.headers.get("Content-Type")) + + pm.request.headers.upsert({ key: 'Content-Type', value: 'application/xml' }) + + console.log("Updated Content-Type:", pm.request.headers.get("Content-Type")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "Content-Type", + value: "application/xml", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial Content-Type:", "application/json"], + }), + expect.objectContaining({ + args: ["Updated Content-Type:", "application/xml"], + }), + ]), + }) + ) + }) + + test("pm.request.headers.upsert() adds header if not exists", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial X-New-Header:", pm.request.headers.get("X-New-Header")) + + pm.request.headers.upsert({ key: 'X-New-Header', value: 'new-value' }) + + console.log("Updated X-New-Header:", pm.request.headers.get("X-New-Header")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-New-Header", + value: "new-value", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial X-New-Header:", null] }), + expect.objectContaining({ + args: ["Updated X-New-Header:", "new-value"], + }), + ]), + }) + ) + }) + + test("pm.request.headers.remove() removes header", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial Authorization:", pm.request.headers.get("Authorization")) + console.log("Initial has Authorization:", pm.request.headers.has("Authorization")) + + pm.request.headers.remove("Authorization") + + console.log("Updated Authorization:", pm.request.headers.get("Authorization")) + console.log("Updated has Authorization:", pm.request.headers.has("Authorization")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.not.arrayContaining([ + expect.objectContaining({ key: "Authorization" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial Authorization:", "Bearer token123"], + }), + expect.objectContaining({ + args: ["Initial has Authorization:", true], + }), + expect.objectContaining({ args: ["Updated Authorization:", null] }), + expect.objectContaining({ + args: ["Updated has Authorization:", false], + }), + ]), + }) + ) + }) + + test("multiple header mutations work correctly", () => { + return expect( + runPreRequestScript( + ` + pm.request.headers.add({ key: 'X-Header-1', value: 'value1' }) + pm.request.headers.add({ key: 'X-Header-2', value: 'value2' }) + console.log("After adds - X-Header-1:", pm.request.headers.get("X-Header-1")) + console.log("After adds - X-Header-2:", pm.request.headers.get("X-Header-2")) + + pm.request.headers.upsert({ key: 'X-Header-1', value: 'updated-value1' }) + console.log("After upsert - X-Header-1:", pm.request.headers.get("X-Header-1")) + + pm.request.headers.remove('X-Header-2') + console.log("After remove - X-Header-2:", pm.request.headers.get("X-Header-2")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "X-Header-1", + value: "updated-value1", + }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["After upsert - X-Header-1:", "updated-value1"], + }), + expect.objectContaining({ + args: ["After remove - X-Header-2:", null], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.body mutations", () => { + test("pm.request.body.update() changes request body with Postman format", () => { + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: '{"name": "John"}', + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial body:", pm.request.body) + + pm.request.body.update({ + mode: 'raw', + raw: '{"name": "Jane", "age": 30}', + options: { raw: { language: 'json' } } + }) + + console.log("Updated body:", pm.request.body) + console.log("hopp.request.body:", hopp.request.body) + `, + { envs, request: requestWithBody } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: { + contentType: "application/json", + body: '{"name": "Jane", "age": 30}', + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Initial body:", + expect.objectContaining({ contentType: "application/json" }), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated body:", + expect.objectContaining({ + contentType: "application/json", + body: '{"name": "Jane", "age": 30}', + }), + ]), + }), + ]), + }) + ) + }) + + test("pm.request.body.update() with string directly", () => { + const requestWithBody: HoppRESTRequest = { + ...baseRequest, + body: { + contentType: "application/json", + body: '{"data": "original"}', + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial body:", pm.request.body) + + pm.request.body.update('plain text body') + + console.log("Updated body:", pm.request.body) + console.log("Content-Type:", pm.request.body.contentType) + `, + { envs, request: requestWithBody } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + body: { + contentType: "text/plain", + body: "plain text body", + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Content-Type:", "text/plain"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.auth mutations", () => { + test("pm.request.auth setter changes authentication", () => { + const newAuth: HoppRESTAuth = { + authType: "bearer", + token: "new-bearer-token-xyz", + authActive: true, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth type:", pm.request.auth.authType) + + pm.request.auth = ${JSON.stringify(newAuth)} + + console.log("Updated auth type:", pm.request.auth.authType) + console.log("Updated auth token:", pm.request.auth.token) + console.log("hopp.request.auth:", hopp.request.auth) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: newAuth, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial auth type:", "none"] }), + expect.objectContaining({ args: ["Updated auth type:", "bearer"] }), + expect.objectContaining({ + args: ["Updated auth token:", "new-bearer-token-xyz"], + }), + ]), + }) + ) + }) + + test("pm.request.auth setter replaces entire auth object", () => { + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "bearer", + token: "original-token", + authActive: true, + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth:", pm.request.auth) + + pm.request.auth = { + authType: 'basic', + username: 'user', + password: 'pass', + authActive: true + } + + console.log("Updated auth:", pm.request.auth) + console.log("Auth type changed:", pm.request.auth.authType) + `, + { envs, request: requestWithAuth } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: expect.objectContaining({ + authType: "basic", + username: "user", + password: "pass", + }), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Auth type changed:", "basic"] }), + ]), + }) + ) + }) + + test("pm.request.auth can be set to null to disable auth", () => { + const requestWithAuth: HoppRESTRequest = { + ...baseRequest, + auth: { + authType: "bearer", + token: "some-token", + authActive: true, + }, + } + + return expect( + runPreRequestScript( + ` + console.log("Initial auth active:", pm.request.auth.authActive) + console.log("Initial auth type:", pm.request.auth.authType) + + pm.request.auth = null + + console.log("Updated auth active:", pm.request.auth.authActive) + console.log("Updated auth type:", pm.request.auth.authType) + `, + { envs, request: requestWithAuth } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + auth: { + authType: "none", + authActive: false, + }, + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial auth active:", true] }), + expect.objectContaining({ args: ["Updated auth active:", false] }), + expect.objectContaining({ args: ["Updated auth type:", "none"] }), + ]), + }) + ) + }) +}) + +describe("pm.request combined mutations", () => { + test("multiple different mutation types work together", () => { + return expect( + runPreRequestScript( + ` + console.log("=== Initial State ===") + console.log("URL:", pm.request.url.toString()) + console.log("Method:", pm.request.method) + console.log("Content-Type:", pm.request.headers.get("Content-Type")) + + // Change URL + pm.request.url = "https://api.example.com/v2/data" + pm.request.url.protocol = 'http' + pm.request.url.query.add({ key: 'page', value: '1' }) + + // Change method + pm.request.method = 'POST' + + // Change headers + pm.request.headers.upsert({ key: 'Content-Type', value: 'application/xml' }) + pm.request.headers.add({ key: 'X-API-Key', value: 'secret123' }) + + console.log("=== Final State ===") + console.log("URL:", pm.request.url.toString()) + console.log("Method:", pm.request.method) + console.log("Content-Type:", pm.request.headers.get("Content-Type")) + console.log("X-API-Key:", pm.request.headers.get("X-API-Key")) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://api.example.com/v2/data?page=1", + method: "POST", + headers: expect.arrayContaining([ + expect.objectContaining({ + key: "Content-Type", + value: "application/xml", + }), + expect.objectContaining({ key: "X-API-Key", value: "secret123" }), + ]), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["URL:", "http://api.example.com/v2/data?page=1"], + }), + expect.objectContaining({ args: ["Method:", "POST"] }), + expect.objectContaining({ + args: ["Content-Type:", "application/xml"], + }), + expect.objectContaining({ args: ["X-API-Key:", "secret123"] }), + ]), + }) + ) + }) + + test("mutations persist across multiple reads", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.protocol = 'http' + const url1 = pm.request.url.toString() + + pm.request.url.path = ['updated', 'path'] + const url2 = pm.request.url.toString() + + pm.request.url.query.add({ key: 'test', value: 'value' }) + const url3 = pm.request.url.toString() + + console.log("URL after 1st mutation:", url1) + console.log("URL after 2nd mutation:", url2) + console.log("URL after 3rd mutation:", url3) + console.log("Current URL:", pm.request.url.toString()) + console.log("hopp.request.url:", hopp.request.url) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 1st mutation:", + expect.stringContaining("http://"), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 2nd mutation:", + expect.stringContaining("/updated/path"), + ]), + }), + expect.objectContaining({ + args: expect.arrayContaining([ + "URL after 3rd mutation:", + expect.stringContaining("test=value"), + ]), + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts new file mode 100644 index 00000000..225e41c5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/query/propertylist.spec.ts @@ -0,0 +1,1226 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users?filter=active&sort=name&page=1", + method: "GET", + headers: [], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + { key: "page", value: "1", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.query.get()", () => { + test("returns value for existing parameter", () => { + return expect( + runPreRequestScript( + ` + const filterValue = pm.request.url.query.get('filter') + const sortValue = pm.request.url.query.get('sort') + + console.log("Filter value:", filterValue) + console.log("Sort value:", sortValue) + console.log("Filter type:", typeof filterValue) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filter value:", "active"] }), + expect.objectContaining({ args: ["Sort value:", "name"] }), + expect.objectContaining({ args: ["Filter type:", "string"] }), + ]), + }) + ) + }) + + test("returns null for non-existent parameter", () => { + return expect( + runPreRequestScript( + ` + const value = pm.request.url.query.get('nonexistent') + + console.log("Non-existent value:", value) + console.log("Is null:", value === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Non-existent value:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("updates after parameter is added", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial limit:", pm.request.url.query.get('limit')) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + + console.log("Updated limit:", pm.request.url.query.get('limit')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial limit:", null] }), + expect.objectContaining({ args: ["Updated limit:", "20"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.has()", () => { + test("returns true for existing parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Has filter:", pm.request.url.query.has('filter')) + console.log("Has sort:", pm.request.url.query.has('sort')) + console.log("Has page:", pm.request.url.query.has('page')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has filter:", true] }), + expect.objectContaining({ args: ["Has sort:", true] }), + expect.objectContaining({ args: ["Has page:", true] }), + ]), + }) + ) + }) + + test("returns false for non-existent parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Has limit:", pm.request.url.query.has('limit')) + console.log("Has offset:", pm.request.url.query.has('offset')) + console.log("Has nonexistent:", pm.request.url.query.has('nonexistent')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has limit:", false] }), + expect.objectContaining({ args: ["Has offset:", false] }), + expect.objectContaining({ args: ["Has nonexistent:", false] }), + ]), + }) + ) + }) + + test("updates after parameters are modified", () => { + return expect( + runPreRequestScript( + ` + console.log("Has status (before):", pm.request.url.query.has('status')) + + pm.request.url.query.add({ key: 'status', value: 'published' }) + + console.log("Has status (after add):", pm.request.url.query.has('status')) + + pm.request.url.query.remove('status') + + console.log("Has status (after remove):", pm.request.url.query.has('status')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has status (before):", false] }), + expect.objectContaining({ args: ["Has status (after add):", true] }), + expect.objectContaining({ + args: ["Has status (after remove):", false], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.upsert()", () => { + test("adds new parameter when it doesn't exist", () => { + return expect( + runPreRequestScript( + ` + console.log("Has limit (before):", pm.request.url.query.has('limit')) + + pm.request.url.query.upsert({ key: 'limit', value: '50' }) + + console.log("Has limit (after):", pm.request.url.query.has('limit')) + console.log("Limit value:", pm.request.url.query.get('limit')) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("limit=50"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Has limit (before):", false] }), + expect.objectContaining({ args: ["Has limit (after):", true] }), + expect.objectContaining({ args: ["Limit value:", "50"] }), + ]), + }) + ) + }) + + test("updates existing parameter", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial filter:", pm.request.url.query.get('filter')) + + pm.request.url.query.upsert({ key: 'filter', value: 'inactive' }) + + console.log("Updated filter:", pm.request.url.query.get('filter')) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("filter=inactive"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial filter:", "active"] }), + expect.objectContaining({ args: ["Updated filter:", "inactive"] }), + ]), + }) + ) + }) + + test("handles empty value", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.upsert({ key: 'flag' }) + + console.log("Flag value:", pm.request.url.query.get('flag')) + console.log("Flag exists:", pm.request.url.query.has('flag')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Flag value:", ""] }), + expect.objectContaining({ args: ["Flag exists:", true] }), + ]), + }) + ) + }) + + test("throws error for missing key", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.query.upsert({ value: 'test' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Error caught:", + expect.stringContaining("must have a 'key' property"), + ]), + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.clear()", () => { + test("removes all query parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Params before:", pm.request.url.query.all()) + console.log("Count before:", pm.request.url.query.count()) + + pm.request.url.query.clear() + + console.log("Params after:", pm.request.url.query.all()) + console.log("Count after:", pm.request.url.query.count()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Params before:", + { filter: "active", sort: "name", page: "1" }, + ], + }), + expect.objectContaining({ args: ["Count before:", 3] }), + expect.objectContaining({ args: ["Params after:", {}] }), + expect.objectContaining({ args: ["Count after:", 0] }), + ]), + }) + ) + }) + + test("allows adding parameters after clearing", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.clear() + console.log("After clear:", pm.request.url.query.all()) + + pm.request.url.query.add({ key: 'new', value: 'param' }) + console.log("After add:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["After clear:", {}] }), + expect.objectContaining({ args: ["After add:", { new: "param" }] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.each()", () => { + test("iterates over all parameters", () => { + return expect( + runPreRequestScript( + ` + const keys = [] + const values = [] + + pm.request.url.query.each((param) => { + keys.push(param.key) + values.push(param.value) + }) + + console.log("Keys:", keys) + console.log("Values:", values) + console.log("Count:", keys.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Keys:", ["filter", "sort", "page"]], + }), + expect.objectContaining({ + args: ["Values:", ["active", "name", "1"]], + }), + expect.objectContaining({ args: ["Count:", 3] }), + ]), + }) + ) + }) + + test("callback receives correct parameter object", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.each((param) => { + console.log("Key:", param.key) + console.log("Value:", param.value) + return // Check only first param + }) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Key:", "filter"] }), + expect.objectContaining({ args: ["Value:", "active"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.map()", () => { + test("transforms parameters and returns new array", () => { + return expect( + runPreRequestScript( + ` + const mapped = pm.request.url.query.map((param) => { + return param.key.toUpperCase() + '=' + param.value + }) + + console.log("Mapped:", mapped) + console.log("Array length:", mapped.length) + console.log("First item:", mapped[0]) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Mapped:", ["FILTER=active", "SORT=name", "PAGE=1"]], + }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ args: ["First item:", "FILTER=active"] }), + ]), + }) + ) + }) + + test("does not modify original parameters", () => { + return expect( + runPreRequestScript( + ` + const originalParams = pm.request.url.query.all() + + const mapped = pm.request.url.query.map((param) => { + return { modified: param.key } + }) + + const afterParams = pm.request.url.query.all() + + console.log("Original:", originalParams) + console.log("Mapped:", mapped) + console.log("After:", afterParams) + console.log("Params unchanged:", JSON.stringify(originalParams) === JSON.stringify(afterParams)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Original:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ + args: ["After:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ args: ["Params unchanged:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.filter()", () => { + test("filters parameters based on condition", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.url.query.filter((param) => { + return param.key.includes('sort') || param.key.includes('page') + }) + + console.log("Filtered:", filtered) + console.log("Count:", filtered.length) + console.log("Keys:", filtered.map(p => p.key)) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Keys:", ["sort", "page"]] }), + ]), + }) + ) + }) + + test("returns empty array when no matches", () => { + return expect( + runPreRequestScript( + ` + const filtered = pm.request.url.query.filter((param) => { + return param.key === 'nonexistent' + }) + + console.log("Filtered:", filtered) + console.log("Is array:", Array.isArray(filtered)) + console.log("Length:", filtered.length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filtered:", []] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.count()", () => { + test("returns correct count of parameters", () => { + return expect( + runPreRequestScript( + ` + const count = pm.request.url.query.count() + + console.log("Count:", count) + console.log("Type:", typeof count) + console.log("Matches all():", count === Object.keys(pm.request.url.query.all()).length) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Count:", 3] }), + expect.objectContaining({ args: ["Type:", "number"] }), + expect.objectContaining({ args: ["Matches all():", true] }), + ]), + }) + ) + }) + + test("updates after parameters are added or removed", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial count:", pm.request.url.query.count()) + + pm.request.url.query.add({ key: 'limit', value: '20' }) + console.log("After add:", pm.request.url.query.count()) + + pm.request.url.query.remove('filter') + console.log("After remove:", pm.request.url.query.count()) + + pm.request.url.query.clear() + console.log("After clear:", pm.request.url.query.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial count:", 3] }), + expect.objectContaining({ args: ["After add:", 4] }), + expect.objectContaining({ args: ["After remove:", 3] }), + expect.objectContaining({ args: ["After clear:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.idx()", () => { + test("returns parameter at specific index", () => { + return expect( + runPreRequestScript( + ` + const first = pm.request.url.query.idx(0) + const second = pm.request.url.query.idx(1) + const third = pm.request.url.query.idx(2) + + console.log("First:", first) + console.log("Second:", second) + console.log("Third:", third) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["First:", { key: "filter", value: "active" }], + }), + expect.objectContaining({ + args: ["Second:", { key: "sort", value: "name" }], + }), + expect.objectContaining({ + args: ["Third:", { key: "page", value: "1" }], + }), + ]), + }) + ) + }) + + test("returns null for out-of-bounds index", () => { + return expect( + runPreRequestScript( + ` + const param = pm.request.url.query.idx(999) + + console.log("Out of bounds:", param) + console.log("Is null:", param === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Out of bounds:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) + + test("returns null for negative index", () => { + return expect( + runPreRequestScript( + ` + const param = pm.request.url.query.idx(-1) + + console.log("Negative index:", param) + console.log("Is null:", param === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Negative index:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.toObject()", () => { + test("returns parameters as object (alias for all())", () => { + return expect( + runPreRequestScript( + ` + const obj = pm.request.url.query.toObject() + const all = pm.request.url.query.all() + + console.log("toObject:", obj) + console.log("all:", all) + console.log("Are equal:", JSON.stringify(obj) === JSON.stringify(all)) + console.log("Type:", typeof obj) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["toObject:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ + args: ["all:", { filter: "active", sort: "name", page: "1" }], + }), + expect.objectContaining({ args: ["Are equal:", true] }), + expect.objectContaining({ args: ["Type:", "object"] }), + ]), + }) + ) + }) +}) + +describe("duplicate query parameter handling", () => { + test("handles duplicate parameter keys by converting to array", () => { + const requestWithDuplicates: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?tag=js&tag=ts&tag=go", + params: [ + { key: "tag", value: "js", active: true, description: "" }, + { key: "tag", value: "ts", active: true, description: "" }, + { key: "tag", value: "go", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const params = pm.request.url.query.all() + + console.log("Params:", params) + console.log("Tag value:", params.tag) + console.log("Is array:", Array.isArray(params.tag)) + console.log("Array length:", params.tag.length) + console.log("All values:", params.tag) + `, + { envs, request: requestWithDuplicates } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Tag value:", ["js", "ts", "go"]] }), + expect.objectContaining({ args: ["Is array:", true] }), + expect.objectContaining({ args: ["Array length:", 3] }), + expect.objectContaining({ + args: ["All values:", ["js", "ts", "go"]], + }), + ]), + }) + ) + }) + + test("handles mixed duplicate and unique parameters", () => { + const requestWithMixed: HoppRESTRequest = { + ...baseRequest, + endpoint: + "https://api.example.com/users?filter=active&tag=js&tag=ts&sort=name", + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "tag", value: "js", active: true, description: "" }, + { key: "tag", value: "ts", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const params = pm.request.url.query.all() + + console.log("All params:", params) + console.log("filter is string:", typeof params.filter === 'string') + console.log("tag is array:", Array.isArray(params.tag)) + console.log("sort is string:", typeof params.sort === 'string') + `, + { envs, request: requestWithMixed } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + { filter: "active", tag: ["js", "ts"], sort: "name" }, + ], + }), + expect.objectContaining({ args: ["filter is string:", true] }), + expect.objectContaining({ args: ["tag is array:", true] }), + expect.objectContaining({ args: ["sort is string:", true] }), + ]), + }) + ) + }) + + test("get() returns first value for duplicate keys", () => { + const requestWithDuplicates: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?tag=first&tag=second", + params: [ + { key: "tag", value: "first", active: true, description: "" }, + { key: "tag", value: "second", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + const value = pm.request.url.query.get('tag') + + console.log("get() value:", value) + console.log("Is first value:", value === 'first') + console.log("all() value:", pm.request.url.query.all().tag) + `, + { envs, request: requestWithDuplicates } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["get() value:", "first"] }), + expect.objectContaining({ args: ["Is first value:", true] }), + expect.objectContaining({ + args: ["all() value:", ["first", "second"]], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.find()", () => { + test("finds parameter by predicate function", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find((param) => param.key === 'sort') + + console.log("Found param:", result) + console.log("Param key:", result.key) + console.log("Param value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found param:", + expect.objectContaining({ key: "sort", value: "name" }), + ], + }), + expect.objectContaining({ args: ["Param key:", "sort"] }), + expect.objectContaining({ args: ["Param value:", "name"] }), + ]), + }) + ) + }) + + test("finds parameter by key string", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find('filter') + + console.log("Found by string:", result) + console.log("Value:", result.value) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Found by string:", + expect.objectContaining({ key: "filter", value: "active" }), + ], + }), + expect.objectContaining({ args: ["Value:", "active"] }), + ]), + }) + ) + }) + + test("returns null when parameter not found", () => { + return expect( + runPreRequestScript( + ` + const result = pm.request.url.query.find('nonexistent') + + console.log("Result:", result) + console.log("Is null:", result === null) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Result:", null] }), + expect.objectContaining({ args: ["Is null:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.indexOf()", () => { + test("returns index of parameter by key", () => { + return expect( + runPreRequestScript( + ` + const idx1 = pm.request.url.query.indexOf('filter') + const idx2 = pm.request.url.query.indexOf('sort') + const idx3 = pm.request.url.query.indexOf('page') + + console.log("Index of filter:", idx1) + console.log("Index of sort:", idx2) + console.log("Index of page:", idx3) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index of filter:", 0] }), + expect.objectContaining({ args: ["Index of sort:", 1] }), + expect.objectContaining({ args: ["Index of page:", 2] }), + ]), + }) + ) + }) + + test("returns index of parameter by object", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.url.query.indexOf({ key: 'sort' }) + + console.log("Index:", idx) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", 1] }), + ]), + }) + ) + }) + + test("returns -1 when parameter not found", () => { + return expect( + runPreRequestScript( + ` + const idx = pm.request.url.query.indexOf('nonexistent') + + console.log("Index:", idx) + console.log("Is -1:", idx === -1) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Index:", -1] }), + expect.objectContaining({ args: ["Is -1:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.insert()", () => { + test("inserts parameter before specified key", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }, 'page') + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "limit", "page"]], + }), + expect.objectContaining({ + args: [ + "URL:", + "https://api.example.com/users?filter=active&sort=name&limit=10&page=1", + ], + }), + ]), + }) + ) + }) + + test("appends parameter if 'before' key not found", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }, 'nonexistent') + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("appends parameter when no 'before' specified", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.insert({ key: 'limit', value: '10' }) + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.query.insert({ value: '10' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Query param must have a 'key' property"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.append()", () => { + test("moves existing parameter to end", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.append({ key: 'filter', value: 'updated' }) + + const allParams = pm.request.url.query.map((p) => p.key) + const filterValue = pm.request.url.query.get('filter') + + console.log("All params:", allParams) + console.log("Filter value:", filterValue) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["sort", "page", "filter"]], + }), + expect.objectContaining({ args: ["Filter value:", "updated"] }), + expect.objectContaining({ + args: [ + "URL:", + "https://api.example.com/users?sort=name&page=1&filter=updated", + ], + }), + ]), + }) + ) + }) + + test("adds new parameter at end", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.append({ key: 'limit', value: '10' }) + + const allParams = pm.request.url.query.map((p) => p.key) + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["All params:", ["filter", "sort", "page", "limit"]], + }), + ]), + }) + ) + }) + + test("throws error when item has no key", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.query.append({ value: '10' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Query param must have a 'key' property"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.query.assimilate()", () => { + test("updates existing parameters and adds new ones from array", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate([ + { key: 'filter', value: 'updated' }, + { key: 'limit', value: '20' } + ]) + + const allParams = pm.request.url.query.all() + console.log("Filter:", allParams.filter) + console.log("Sort:", allParams.sort) + console.log("Limit:", allParams.limit) + console.log("Count:", pm.request.url.query.count()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Filter:", "updated"] }), + expect.objectContaining({ args: ["Sort:", "name"] }), + expect.objectContaining({ args: ["Limit:", "20"] }), + expect.objectContaining({ args: ["Count:", 4] }), + ]), + }) + ) + }) + + test("updates existing parameters and adds new ones from object", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate({ + filter: 'inactive', + limit: '50' + }) + + const allParams = pm.request.url.query.all() + console.log("All params:", allParams) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + expect.objectContaining({ + filter: "inactive", + sort: "name", + page: "1", + limit: "50", + }), + ], + }), + ]), + }) + ) + }) + + test("prunes parameters not in source when prune=true", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.query.assimilate( + { filter: 'active', limit: '10' }, + true + ) + + const allParams = pm.request.url.query.all() + const count = pm.request.url.query.count() + + console.log("All params:", allParams) + console.log("Count:", count) + console.log("Has sort:", pm.request.url.query.has('sort')) + console.log("Has page:", pm.request.url.query.has('page')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "All params:", + expect.objectContaining({ filter: "active", limit: "10" }), + ], + }), + expect.objectContaining({ args: ["Count:", 2] }), + expect.objectContaining({ args: ["Has sort:", false] }), + expect.objectContaining({ args: ["Has page:", false] }), + ]), + }) + ) + }) + + test("throws error for invalid source", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.query.assimilate("invalid") + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Error caught:", "Source must be an array or object"], + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts new file mode 100644 index 00000000..f01646af --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/helper-methods.spec.ts @@ -0,0 +1,633 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: + "https://api.example.com:8080/v1/users/profile?filter=active&sort=name", + method: "GET", + headers: [ + { + key: "Content-Type", + value: "application/json", + active: true, + description: "", + }, + ], + params: [ + { key: "filter", value: "active", active: true, description: "" }, + { key: "sort", value: "name", active: true, description: "" }, + ], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.getHost()", () => { + test("returns hostname as a string", () => { + return expect( + runPreRequestScript( + ` + const host = pm.request.url.getHost() + console.log("Host:", host) + console.log("Host type:", typeof host) + console.log("Is string:", typeof host === 'string') + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Host:", "api.example.com"] }), + expect.objectContaining({ args: ["Host type:", "string"] }), + expect.objectContaining({ args: ["Is string:", true] }), + ]), + }) + ) + }) + + test("updates after host mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial host:", pm.request.url.getHost()) + + pm.request.url.host = ['newapi', 'test', 'com'] + + console.log("Updated host:", pm.request.url.getHost()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", "api.example.com"], + }), + expect.objectContaining({ + args: ["Updated host:", "newapi.test.com"], + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getPath()", () => { + test("returns path as string with leading slash", () => { + return expect( + runPreRequestScript( + ` + const path = pm.request.url.getPath() + console.log("Path:", path) + console.log("Starts with slash:", path.startsWith('/')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path:", "/v1/users/profile"] }), + expect.objectContaining({ args: ["Starts with slash:", true] }), + ]), + }) + ) + }) + + test("returns '/' for empty path", () => { + const requestWithoutPath: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com", + } + + return expect( + runPreRequestScript( + ` + const path = pm.request.url.getPath() + console.log("Path:", path) + console.log("Is root:", path === '/') + `, + { envs, request: requestWithoutPath } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path:", "/"] }), + expect.objectContaining({ args: ["Is root:", true] }), + ]), + }) + ) + }) + + test("updates after path mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial path:", pm.request.url.getPath()) + + pm.request.url.path = ['api', 'v2', 'posts'] + + console.log("Updated path:", pm.request.url.getPath()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial path:", "/v1/users/profile"], + }), + expect.objectContaining({ args: ["Updated path:", "/api/v2/posts"] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getPathWithQuery()", () => { + test("returns path with query string", () => { + return expect( + runPreRequestScript( + ` + const pathWithQuery = pm.request.url.getPathWithQuery() + console.log("Path with query:", pathWithQuery) + console.log("Includes path:", pathWithQuery.includes('/v1/users/profile')) + console.log("Includes query:", pathWithQuery.includes('filter=active')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Path with query:", + "/v1/users/profile?filter=active&sort=name", + ], + }), + expect.objectContaining({ args: ["Includes path:", true] }), + expect.objectContaining({ args: ["Includes query:", true] }), + ]), + }) + ) + }) + + test("returns only path when no query parameters", () => { + const requestWithoutQuery: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + params: [], + } + + return expect( + runPreRequestScript( + ` + const pathWithQuery = pm.request.url.getPathWithQuery() + console.log("Path with query:", pathWithQuery) + console.log("Has question mark:", pathWithQuery.includes('?')) + `, + { envs, request: requestWithoutQuery } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Path with query:", "/users"] }), + expect.objectContaining({ args: ["Has question mark:", false] }), + ]), + }) + ) + }) + + test("updates after query mutation", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial:", pm.request.url.getPathWithQuery()) + + pm.request.url.query.add({ key: 'page', value: '5' }) + + console.log("Updated:", pm.request.url.getPathWithQuery()) + console.log("Includes new param:", pm.request.url.getPathWithQuery().includes('page=5')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial:", "/v1/users/profile?filter=active&sort=name"], + }), + expect.objectContaining({ + args: [ + "Updated:", + "/v1/users/profile?filter=active&sort=name&page=5", + ], + }), + expect.objectContaining({ args: ["Includes new param:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getQueryString()", () => { + test("returns query string without leading question mark", () => { + return expect( + runPreRequestScript( + ` + const queryString = pm.request.url.getQueryString() + console.log("Query string:", queryString) + console.log("Starts with question mark:", queryString.startsWith('?')) + console.log("Contains filter:", queryString.includes('filter=active')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Query string:", "filter=active&sort=name"], + }), + expect.objectContaining({ + args: ["Starts with question mark:", false], + }), + expect.objectContaining({ args: ["Contains filter:", true] }), + ]), + }) + ) + }) + + test("returns empty string when no query parameters", () => { + const requestWithoutQuery: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + params: [], + } + + return expect( + runPreRequestScript( + ` + const queryString = pm.request.url.getQueryString() + console.log("Query string:", queryString) + console.log("Is empty:", queryString === '') + console.log("Length:", queryString.length) + `, + { envs, request: requestWithoutQuery } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Query string:", ""] }), + expect.objectContaining({ args: ["Is empty:", true] }), + expect.objectContaining({ args: ["Length:", 0] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.getRemote()", () => { + test("includes port when not standard (443/80)", () => { + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote() + console.log("Remote:", remote) + console.log("Includes port:", remote.includes(':8080')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Remote:", "api.example.com:8080"], + }), + expect.objectContaining({ args: ["Includes port:", true] }), + ]), + }) + ) + }) + + test("excludes standard HTTPS port (443) by default", () => { + const requestWithStandardPort: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote() + console.log("Remote:", remote) + console.log("Has port in string:", remote.includes(':')) + `, + { envs, request: requestWithStandardPort } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Remote:", "api.example.com"] }), + expect.objectContaining({ args: ["Has port in string:", false] }), + ]), + }) + ) + }) + + test("forces port display when forcePort is true", () => { + const requestWithStandardPort: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const remote = pm.request.url.getRemote(true) + console.log("Remote with forced port:", remote) + console.log("Has port in string:", remote.includes(':443')) + `, + { envs, request: requestWithStandardPort } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Remote with forced port:", "api.example.com:443"], + }), + expect.objectContaining({ args: ["Has port in string:", true] }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.update()", () => { + test("updates entire URL from string", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.update('http://newapi.test.com:3000/v2/posts?id=123') + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Protocol:", pm.request.url.protocol) + console.log("Host:", pm.request.url.getHost()) + console.log("Port:", pm.request.url.port) + console.log("Path:", pm.request.url.getPath()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "http://newapi.test.com:3000/v2/posts?id=123", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Protocol:", "http"] }), + expect.objectContaining({ args: ["Host:", "newapi.test.com"] }), + expect.objectContaining({ args: ["Port:", "3000"] }), + expect.objectContaining({ args: ["Path:", "/v2/posts"] }), + ]), + }) + ) + }) + + test("accepts object with toString() method", () => { + return expect( + runPreRequestScript( + ` + const urlObj = { + toString: () => 'https://custom.api.com/endpoint' + } + + pm.request.url.update(urlObj) + + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://custom.api.com/endpoint", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated URL:", "https://custom.api.com/endpoint"], + }), + ]), + }) + ) + }) + + test("throws error for invalid input", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.update(12345) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Error caught:", + expect.stringContaining("URL update requires"), + ]), + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.addQueryParams()", () => { + test("adds multiple query parameters", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.addQueryParams([ + { key: 'page', value: '1' }, + { key: 'limit', value: '20' } + ]) + + console.log("Updated params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: expect.stringContaining("page=1&limit=20"), + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Updated params:", + { filter: "active", sort: "name", page: "1", limit: "20" }, + ], + }), + ]), + }) + ) + }) + + test("handles empty value parameters", () => { + return expect( + runPreRequestScript( + ` + pm.request.url.addQueryParams([ + { key: 'flag' }, + { key: 'emptyValue', value: '' } + ]) + + console.log("Params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: [ + "Params:", + { filter: "active", sort: "name", flag: "", emptyValue: "" }, + ], + }), + ]), + }) + ) + }) + + test("throws error for non-array input", () => { + return expect( + runPreRequestScript( + ` + try { + pm.request.url.addQueryParams({ key: 'test', value: '123' }) + console.log("Should not reach here") + } catch (error) { + console.log("Error caught:", error.message) + } + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: expect.arrayContaining([ + "Error caught:", + expect.stringContaining("requires an array"), + ]), + }), + ]), + }) + ) + }) +}) + +describe("pm.request.url.removeQueryParams()", () => { + test("removes single query parameter by name", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams('filter') + + console.log("Updated params:", pm.request.url.query.all()) + console.log("URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com:8080/v1/users/profile?sort=name", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated params:", { sort: "name" }], + }), + ]), + }) + ) + }) + + test("removes multiple query parameters by array", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams(['filter', 'sort']) + + console.log("Updated params:", pm.request.url.query.all()) + console.log("Params object is empty:", Object.keys(pm.request.url.query.all()).length === 0) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com:8080/v1/users/profile", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Updated params:", {}], + }), + expect.objectContaining({ + args: ["Params object is empty:", true], + }), + ]), + }) + ) + }) + + test("handles non-existent parameter names gracefully", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial params:", pm.request.url.query.all()) + + pm.request.url.removeQueryParams(['nonexistent', 'alsoNotThere']) + + console.log("Updated params:", pm.request.url.query.all()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial params:", { filter: "active", sort: "name" }], + }), + expect.objectContaining({ + args: ["Updated params:", { filter: "active", sort: "name" }], + }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts new file mode 100644 index 00000000..3cde7217 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/request/url/properties.spec.ts @@ -0,0 +1,285 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { describe, expect, test } from "vitest" + +import { runPreRequestScript } from "~/web" + +const baseRequest: HoppRESTRequest = { + v: "16", + name: "Test Request", + endpoint: "https://api.example.com/users#section1", + method: "GET", + headers: [], + params: [], + body: { contentType: null, body: null }, + auth: { authType: "none", authActive: false }, + preRequestScript: "", + testScript: "", + requestVariables: [], + responses: {}, +} + +const envs = { global: [], selected: [] } + +describe("pm.request.url.hash property", () => { + test("hash getter returns fragment without leading #", () => { + return expect( + runPreRequestScript( + ` + const hash = pm.request.url.hash + console.log("Hash:", hash) + console.log("Hash type:", typeof hash) + console.log("Starts with #:", hash.startsWith('#')) + console.log("Full URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", "section1"] }), + expect.objectContaining({ args: ["Hash type:", "string"] }), + expect.objectContaining({ args: ["Starts with #:", false] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Full URL:", + expect.stringContaining("#section1"), + ]), + }), + ]), + }) + ) + }) + + test("hash getter returns empty string when no fragment", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + const hash = pm.request.url.hash + console.log("Hash:", hash) + console.log("Hash is empty:", hash === '') + console.log("Hash length:", hash.length) + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", ""] }), + expect.objectContaining({ args: ["Hash is empty:", true] }), + expect.objectContaining({ args: ["Hash length:", 0] }), + ]), + }) + ) + }) + + test("hash setter adds fragment to URL", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = 'overview' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + console.log("URL includes #:", pm.request.url.toString().includes('#overview')) + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#overview", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", ""] }), + expect.objectContaining({ args: ["Updated hash:", "overview"] }), + expect.objectContaining({ args: ["URL includes #:", true] }), + ]), + }) + ) + }) + + test("hash setter updates existing fragment", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = 'newsection' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#newsection", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ args: ["Updated hash:", "newsection"] }), + expect.objectContaining({ + args: expect.arrayContaining([ + "Updated URL:", + expect.stringContaining("#newsection"), + ]), + }), + ]), + }) + ) + }) + + test("hash setter accepts value with leading #", () => { + const requestWithoutHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users", + } + + return expect( + runPreRequestScript( + ` + pm.request.url.hash = '#details' + + console.log("Hash:", pm.request.url.hash) + console.log("URL:", pm.request.url.toString()) + console.log("Hash value:", pm.request.url.hash === 'details') + `, + { envs, request: requestWithoutHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users#details", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Hash:", "details"] }), + expect.objectContaining({ args: ["Hash value:", true] }), + ]), + }) + ) + }) + + test("hash setter removes fragment when set to empty string", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial hash:", pm.request.url.hash) + console.log("Initial URL:", pm.request.url.toString()) + + pm.request.url.hash = '' + + console.log("Updated hash:", pm.request.url.hash) + console.log("Updated URL:", pm.request.url.toString()) + console.log("URL has #:", pm.request.url.toString().includes('#')) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ args: ["Updated hash:", ""] }), + expect.objectContaining({ args: ["URL has #:", false] }), + ]), + }) + ) + }) + + test("hash works with query parameters", () => { + const requestWithQueryAndHash: HoppRESTRequest = { + ...baseRequest, + endpoint: "https://api.example.com/users?filter=active#top", + params: [ + { key: "filter", value: "active", active: true, description: "" }, + ], + } + + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + console.log("Initial hash:", pm.request.url.hash) + console.log("Query params:", pm.request.url.query.all()) + + pm.request.url.hash = 'bottom' + + console.log("Updated URL:", pm.request.url.toString()) + console.log("Updated hash:", pm.request.url.hash) + console.log("Query params unchanged:", JSON.stringify(pm.request.url.query.all())) + `, + { envs, request: requestWithQueryAndHash } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://api.example.com/users?filter=active#bottom", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ args: ["Initial hash:", "top"] }), + expect.objectContaining({ args: ["Updated hash:", "bottom"] }), + expect.objectContaining({ + args: ["Query params:", { filter: "active" }], + }), + ]), + }) + ) + }) +}) + +describe("combined host and hash mutations", () => { + test("host and hash can be changed independently", () => { + return expect( + runPreRequestScript( + ` + console.log("Initial URL:", pm.request.url.toString()) + console.log("Initial host:", pm.request.url.getHost()) + console.log("Initial hash:", pm.request.url.hash) + + pm.request.url.host = ['newdomain', 'com'] + console.log("After host change:", pm.request.url.toString()) + + pm.request.url.hash = 'newhash' + console.log("After hash change:", pm.request.url.toString()) + + console.log("Final host:", pm.request.url.getHost()) + console.log("Final hash:", pm.request.url.hash) + `, + { envs, request: baseRequest } + ) + ).resolves.toEqualRight( + expect.objectContaining({ + updatedRequest: expect.objectContaining({ + endpoint: "https://newdomain.com/users#newhash", + }), + consoleEntries: expect.arrayContaining([ + expect.objectContaining({ + args: ["Initial host:", "api.example.com"], + }), + expect.objectContaining({ args: ["Initial hash:", "section1"] }), + expect.objectContaining({ + args: ["Final host:", "newdomain.com"], + }), + expect.objectContaining({ args: ["Final hash:", "newhash"] }), + ]), + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response.spec.ts deleted file mode 100644 index bcd8f6d1..00000000 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" -import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - statusText: "OK", - body: JSON.stringify({ message: "Hello, World!" }), - headers: [ - { key: "Content-Type", value: "application/json" }, - { key: "Authorization", value: "Bearer token123" }, - ], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) - -describe("pm.response", () => { - test("pm.response.code provides access to status code", () => { - return expect( - func( - ` - const code = pm.response.code - pw.expect(code.toString()).toBe("200") - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: "Expected '200' to be '200'", - }, - ], - }), - ]) - }) - - test("pm.response.status provides access to status text", () => { - return expect( - func( - ` - const status = pm.response.status - pw.expect(status).toBe("OK") - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: "Expected 'OK' to be 'OK'", - }, - ], - }), - ]) - }) - - test("pm.response.text() provides response body as text", () => { - return expect( - func( - ` - const text = pm.response.text() - pw.expect(text).toBe('{"message":"Hello, World!"}') - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: - 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', - }, - ], - }), - ]) - }) - - test("pm.response.json() provides parsed JSON response", () => { - return expect( - func( - ` - const json = pm.response.json() - pw.expect(json.message).toBe("Hello, World!") - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: "Expected 'Hello, World!' to be 'Hello, World!'", - }, - ], - }), - ]) - }) - - test("pm.response.headers provides access to response headers", () => { - return expect( - func( - ` - const headers = pm.response.headers - pw.expect(headers.get("Content-Type")).toBe("application/json") - pw.expect(headers.get("Authorization")).toBe("Bearer token123") - pw.expect(headers.get("nonexistent")).toBe(null) - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: "Expected 'application/json' to be 'application/json'", - }, - { - status: "pass", - message: "Expected 'Bearer token123' to be 'Bearer token123'", - }, - { - status: "pass", - message: "Expected 'null' to be 'null'", - }, - ], - }), - ]) - }) - - test("pm.response object has correct structure and values", () => { - return expect( - func( - ` - pw.expect(pm.response.code).toBe(200) - pw.expect(pm.response.status).toBe("OK") - pw.expect(pm.response.text()).toBe('{"message":"Hello, World!"}') - pw.expect(pm.response.json().message).toBe("Hello, World!") - `, - { - global: [], - selected: [], - } - )() - ).resolves.toEqualRight([ - expect.objectContaining({ - expectResults: [ - { - status: "pass", - message: "Expected '200' to be '200'", - }, - { - status: "pass", - message: "Expected 'OK' to be 'OK'", - }, - { - status: "pass", - message: - 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', - }, - { - status: "pass", - message: "Expected 'Hello, World!' to be 'Hello, World!'", - }, - ], - }), - ]) - }) -}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts new file mode 100644 index 00000000..4750f4c4 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/basic.spec.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "vitest" +import { TestResponse, TestResult } from "~/types" +import { runTest } from "~/utils/test-helpers" + +const customResponse: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello, World!" }), + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "Authorization", value: "Bearer token123" }, + ], +} + +const func = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = customResponse +) => runTest(script, envs, response) + +describe("pm.response", () => { + test("pm.response.code provides access to status code", () => { + return expect( + func( + ` + const code = pm.response.code + pw.expect(code.toString()).toBe("200") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + ], + }), + ]) + }) + + test("pm.response.status provides access to status text", () => { + return expect( + func( + ` + const status = pm.response.status + pw.expect(status).toBe("OK") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + ], + }), + ]) + }) + + test("pm.response.text() provides response body as text", () => { + return expect( + func( + ` + const text = pm.response.text() + pw.expect(text).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.json() provides parsed JSON response", () => { + return expect( + func( + ` + const json = pm.response.json() + pw.expect(json.message).toBe("Hello, World!") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'Hello, World!' to be 'Hello, World!'", + }, + ], + }), + ]) + }) + + test("pm.response.headers provides access to response headers", () => { + return expect( + func( + ` + const headers = pm.response.headers + pw.expect(headers.get("Content-Type")).toBe("application/json") + pw.expect(headers.get("Authorization")).toBe("Bearer token123") + // Postman returns undefined for non-existent headers, not null + pw.expect(headers.get("nonexistent")).toBe(undefined) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'application/json' to be 'application/json'", + }, + { + status: "pass", + message: "Expected 'Bearer token123' to be 'Bearer token123'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("pm.response object has correct structure and values", () => { + return expect( + func( + ` + pw.expect(pm.response.code).toBe(200) + pw.expect(pm.response.status).toBe("OK") + pw.expect(pm.response.text()).toBe('{"message":"Hello, World!"}') + pw.expect(pm.response.json().message).toBe("Hello, World!") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected '200' to be '200'", + }, + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + { + status: "pass", + message: "Expected 'Hello, World!' to be 'Hello, World!'", + }, + ], + }), + ]) + }) + + test("pm.response.stream provides response body as Uint8Array", () => { + return expect( + func( + ` + const stream = pm.response.stream + // Verify it's a Uint8Array by checking it can be decoded + const decoder = new TextDecoder() + const decoded = decoder.decode(stream) + pw.expect(decoded).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.stream contains correct byte data", () => { + return expect( + func( + ` + const stream = pm.response.stream + const decoder = new TextDecoder() + const text = decoder.decode(stream) + pw.expect(text).toBe('{"message":"Hello, World!"}') + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: + 'Expected \'{"message":"Hello, World!"}\' to be \'{"message":"Hello, World!"}\'', + }, + ], + }), + ]) + }) + + test("pm.response.reason() returns HTTP reason phrase", () => { + return expect( + func( + ` + const reason = pm.response.reason() + pw.expect(reason).toBe("OK") + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'OK' to be 'OK'", + }, + ], + }), + ]) + }) + + test("pm.response.dataURI() converts response to data URI", () => { + return expect( + func( + ` + const dataURI = pm.response.dataURI() + pw.expect(dataURI).toBeType("string") + // Check it starts with data: and contains base64 + const startsWithData = dataURI.startsWith("data:") + pw.expect(startsWithData).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: expect.stringContaining("to be type 'string'"), + }, + { + status: "pass", + message: "Expected 'true' to be 'true'", + }, + ], + }), + ]) + }) + + test("pm.response.jsonp() parses JSONP response", () => { + const jsonpResponse: TestResponse = { + status: 200, + statusText: "OK", + body: 'callback({"data": "test value"})', + headers: [{ key: "Content-Type", value: "application/javascript" }], + } + + return expect( + func( + ` + const data = pm.response.jsonp("callback") + pw.expect(data.data).toBe("test value") + `, + { global: [], selected: [] }, + jsonpResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'test value' to be 'test value'", + }, + ], + }), + ]) + }) + + test("pm.response.jsonp() handles response without callback wrapper", () => { + const jsonResponse: TestResponse = { + status: 200, + statusText: "OK", + body: '{"plain": "json"}', + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + func( + ` + const data = pm.response.jsonp() + pw.expect(data.plain).toBe("json") + `, + { global: [], selected: [] }, + jsonResponse + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'json' to be 'json'", + }, + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts new file mode 100644 index 00000000..fd22d48d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/bdd-assertions.spec.ts @@ -0,0 +1,897 @@ +/** + * @file Tests for Postman BDD-style response assertions (pm.response.to.*) + * + * These tests verify the Postman compatibility layer's BDD-style assertion helpers + * which are commonly used in Postman collections. They provide syntactic sugar over + * the standard Chai assertions for common response validation patterns. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.to.have.*` - Status Code Assertions", () => { + test("should support `.status()` for exact status code matching", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Status code is 200", function() { + pm.response.to.have.status(200) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Status code is 200", + expectResults: [ + { + status: "pass", + message: "Expected 200 to equal 200", + }, + ], + }), + ], + }), + ]) + }) + + test("should fail `.status()` when status code doesn't match", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Status code is 200", function() { + pm.response.to.have.status(200) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Status code is 200", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected 404 to equal 200"), + }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.be.*` - Status Code Convenience Methods", () => { + test("should support `.ok()` for 2xx status codes", () => { + const responses = [ + { status: 200, statusText: "OK" }, + { status: 201, statusText: "Created" }, + { status: 204, statusText: "No Content" }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Response is OK", function() { + pm.response.to.be.ok() + }) + `, + { global: [], selected: [] }, + { ...r, body: "{}", headers: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) + + test("should fail `.ok()` for non-2xx status codes", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Response is OK", function() { + pm.response.to.be.ok() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { + status: "fail", + message: expect.any(String), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.accepted()` for 202 status code", () => { + const response: TestResponse = { + status: 202, + statusText: "Accepted", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Request accepted", function() { + pm.response.to.be.accepted() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.badRequest()` for 400 status code", () => { + const response: TestResponse = { + status: 400, + statusText: "Bad Request", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Bad request error", function() { + pm.response.to.be.badRequest() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.unauthorized()` for 401 status code", () => { + const response: TestResponse = { + status: 401, + statusText: "Unauthorized", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Unauthorized error", function() { + pm.response.to.be.unauthorized() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.forbidden()` for 403 status code", () => { + const response: TestResponse = { + status: 403, + statusText: "Forbidden", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Forbidden error", function() { + pm.response.to.be.forbidden() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.notFound()` for 404 status code", () => { + const response: TestResponse = { + status: 404, + statusText: "Not Found", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Not found error", function() { + pm.response.to.be.notFound() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.rateLimited()` for 429 status code", () => { + const response: TestResponse = { + status: 429, + statusText: "Too Many Requests", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Rate limited", function() { + pm.response.to.be.rateLimited() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.serverError()` for 5xx status codes", () => { + const responses = [ + { status: 500, statusText: "Internal Server Error" }, + { status: 502, statusText: "Bad Gateway" }, + { status: 503, statusText: "Service Unavailable" }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Server error", function() { + pm.response.to.be.serverError() + }) + `, + { global: [], selected: [] }, + { ...r, body: "{}", headers: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) +}) + +describe("`pm.response.to.have.header()` - Header Assertions", () => { + test("should check header existence", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "X-Custom-Header", value: "custom-value" }, + ], + } + + return expect( + runTest( + ` + pm.test("Headers exist", function() { + pm.response.to.have.header("Content-Type") + pm.response.to.have.header("X-Custom-Header") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should check header value when specified", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Header has correct value", function() { + pm.response.to.have.header("Content-Type", "application/json") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should be case-insensitive for header names", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Case insensitive header check", function() { + pm.response.to.have.header("content-type") + pm.response.to.have.header("CONTENT-TYPE") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.body()` - Body Assertions", () => { + test("should match exact body content", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello, World!", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Body matches", function() { + pm.response.to.have.body("Hello, World!") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.jsonBody()` - JSON Body Assertions", () => { + test("should assert response is JSON object when called without arguments", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + return expect( + runTest( + ` + pm.test("Response is JSON", function() { + pm.response.to.have.jsonBody() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should check for property existence when key provided", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("JSON has properties", function() { + pm.response.to.have.jsonBody("message") + pm.response.to.have.jsonBody("count") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should check property value when key and value provided", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello", count: 42 }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("JSON property values match", function() { + pm.response.to.have.jsonBody("message", "Hello") + pm.response.to.have.jsonBody("count", 42) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) + + test("should support nested property checks", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ user: { name: "Alice", age: 30 } }), + headers: [], + } + + return expect( + runTest( + ` + pm.test("Nested properties", function() { + const data = pm.response.json() + pm.expect(data.user).to.have.property("name") + pm.expect(data.user.name).to.equal("Alice") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.be.*` - Content Type Convenience Methods", () => { + test("should support `.json()` for JSON content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + return expect( + runTest( + ` + pm.test("Response is JSON", function() { + pm.response.to.be.json() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.html()` for HTML content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + return expect( + runTest( + ` + pm.test("Response is HTML", function() { + pm.response.to.be.html() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.xml()` for XML content types", () => { + const responses = [ + { headers: [{ key: "Content-Type", value: "application/xml" }] }, + { headers: [{ key: "Content-Type", value: "text/xml" }] }, + ] + + return Promise.all( + responses.map((r) => + expect( + runTest( + ` + pm.test("Response is XML", function() { + pm.response.to.be.xml() + }) + `, + { global: [], selected: [] }, + { status: 200, statusText: "OK", body: "", ...r } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: expect.any(String) }, + ], + }), + ], + }), + ]) + ) + ) + }) + + test("should support `.text()` for plain text content type", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Plain text response", + headers: [{ key: "Content-Type", value: "text/plain" }], + } + + return expect( + runTest( + ` + pm.test("Response is text", function() { + pm.response.to.be.text() + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.responseTime.*` - Response Time Assertions", () => { + test("should support `.below()` for response time upper bound", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + responseTime: 250, + } + + return expect( + runTest( + ` + pm.test("Response time is acceptable", function() { + pm.response.to.have.responseTime.below(500) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("should support `.above()` for response time lower bound", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + responseTime: 250, + } + + return expect( + runTest( + ` + pm.test("Response time is above threshold", function() { + pm.response.to.have.responseTime.above(100) + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) +}) + +describe("Real-world Postman script patterns", () => { + test("should handle complex assertion combinations", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ + success: true, + data: { id: 123, name: "Test" }, + timestamp: Date.now(), + }), + headers: [ + { key: "Content-Type", value: "application/json" }, + { key: "X-Request-ID", value: "abc-123" }, + ], + responseTime: 150, + } + + return expect( + runTest( + ` + pm.test("API response validation", function() { + // Status code checks + pm.response.to.have.status(200) + pm.response.to.be.ok() + + // Header checks + pm.response.to.have.header("Content-Type") + pm.response.to.have.header("X-Request-ID", "abc-123") + + // Content type + pm.response.to.be.json() + + // JSON body checks + pm.response.to.have.jsonBody("success", true) + pm.response.to.have.jsonBody("data") + + // Response time + pm.response.to.have.responseTime.below(500) + + // Detailed JSON assertions + const json = pm.response.json() + pm.expect(json.data.id).to.equal(123) + pm.expect(json.data.name).to.equal("Test") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "API response validation", + expectResults: expect.arrayContaining([ + { status: "pass", message: expect.any(String) }, + ]), + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts new file mode 100644 index 00000000..b06de863 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/cookies.spec.ts @@ -0,0 +1,392 @@ +/** + * @file Tests for Postman cookie handling (pm.response.cookies.*, pm.response.to.have.cookie) + * + * These tests verify cookie parsing from Set-Cookie headers and cookie assertions. + * Cookies in responses are extracted from Set-Cookie headers and made available + * through the pm.response.cookies API. + */ + +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("`pm.response.cookies` - Cookie Access Methods", () => { + test("should support `.get()` to retrieve a cookie value by name", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "session=abc123; Path=/; HttpOnly" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can retrieve cookie value by name", function() { + const cookieValue = pm.response.cookies.get("session") + pm.expect(cookieValue).to.not.be.null + pm.expect(cookieValue).to.equal("abc123") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can retrieve cookie value by name", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should return null for non-existent cookies", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "session=abc123; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Returns null for non-existent cookie", function() { + const cookie = pm.response.cookies.get("nonexistent") + pm.expect(cookie).to.be.null + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Returns null for non-existent cookie", + expectResults: [ + { + status: "pass", + message: expect.stringContaining("Expected null to be null"), + }, + ], + }), + ], + }), + ]) + }) + + test("should support `.has()` to check cookie existence", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "auth_token=xyz789; Secure" }, + { key: "Set-Cookie", value: "user_id=42; SameSite=Strict" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can check cookie existence", function() { + pm.expect(pm.response.cookies.has("auth_token")).to.be.true + pm.expect(pm.response.cookies.has("user_id")).to.be.true + pm.expect(pm.response.cookies.has("nonexistent")).to.be.false + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can check cookie existence", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should support `.toObject()` to get all cookies", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { key: "Set-Cookie", value: "cookie1=value1; Path=/" }, + { key: "Set-Cookie", value: "cookie2=value2; Domain=example.com" }, + ], + } + + return expect( + runTest( + ` + pm.test("Can get all cookies as object", function() { + const cookies = pm.response.cookies.toObject() + pm.expect(cookies).to.be.an("object") + pm.expect(cookies.cookie1).to.equal("value1") + pm.expect(cookies.cookie2).to.equal("value2") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Can get all cookies as object", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should return just the cookie value (matching Postman behavior)", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { + key: "Set-Cookie", + value: + "full_cookie=test_value; Domain=.example.com; Path=/api; Max-Age=3600; Secure; HttpOnly; SameSite=Lax", + }, + ], + } + + return expect( + runTest( + ` + pm.test("Returns only cookie value, not attributes", function() { + const cookieValue = pm.response.cookies.get("full_cookie") + pm.expect(cookieValue).to.equal("test_value") + pm.expect(cookieValue).to.be.a("string") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Returns only cookie value, not attributes", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should handle cookies with equals signs in value", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [ + { + key: "Set-Cookie", + value: + "jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=; Path=/", + }, + ], + } + + return expect( + runTest( + ` + pm.test("Handles equals signs in cookie value", function() { + const cookieValue = pm.response.cookies.get("jwt") + pm.expect(cookieValue).to.include("=") + pm.expect(cookieValue).to.equal("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Handles equals signs in cookie value", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) +}) + +describe("`pm.response.to.have.cookie` - Cookie Assertions", () => { + test("should assert cookie exists by name", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "session=abc123; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Response has session cookie", function() { + pm.response.to.have.cookie("session") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Response has session cookie", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ], + }), + ]) + }) + + test("should assert cookie value matches", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "user=john_doe; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Cookie has correct value", function() { + pm.response.to.have.cookie("user", "john_doe") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Cookie has correct value", + expectResults: [ + { + status: "pass", + message: expect.stringContaining( + "Expected 'john_doe' to equal 'john_doe'" + ), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when cookie doesn't exist", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [], + } + + return expect( + runTest( + ` + pm.test("Missing cookie fails", function() { + pm.response.to.have.cookie("nonexistent") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Missing cookie fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining("Expected false to be true"), + }, + ], + }), + ], + }), + ]) + }) + + test("should fail when cookie value doesn't match", () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "{}", + headers: [{ key: "Set-Cookie", value: "token=wrong_value; Path=/" }], + } + + return expect( + runTest( + ` + pm.test("Wrong cookie value fails", function() { + pm.response.to.have.cookie("token", "expected_value") + }) + `, + { global: [], selected: [] }, + response + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + descriptor: "root", + children: [ + expect.objectContaining({ + descriptor: "Wrong cookie value fails", + expectResults: [ + { + status: "fail", + message: expect.stringContaining( + "Expected 'wrong_value' to equal 'expected_value'" + ), + }, + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts new file mode 100644 index 00000000..758b30c2 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/datauri-comprehensive.spec.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +describe("pm.response.dataURI() comprehensive coverage", () => { + test("should handle Content-Type without charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ test: "data" }), + headers: [{ key: "Content-Type", value: "application/json" }], + } + + const testScript = ` + pm.test("dataURI format without charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.include('data:') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/json') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI format without charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle Content-Type with charset parameter", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ test: "data" }), + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI format with charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.include('data:') + // Updated regex pattern that handles charset parameters + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/json') + pm.expect(dataUri).to.include('charset=utf-8') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI format with charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle text/html with charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Hello", + headers: [{ key: "Content-Type", value: "text/html; charset=utf-8" }], + } + + const testScript = ` + pm.test("dataURI with text/html and charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/html') + pm.expect(dataUri).to.include('charset=utf-8') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with text/html and charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle text/plain with multiple parameters", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Plain text content", + headers: [ + { + key: "Content-Type", + value: "text/plain; charset=utf-8; format=flowed", + }, + ], + } + + const testScript = ` + pm.test("dataURI with multiple parameters", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + // Regex should handle multiple semicolons + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/plain') + pm.expect(dataUri).to.include('base64,') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with multiple parameters", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle application/xml with charset", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: 'test', + headers: [ + { key: "Content-Type", value: "application/xml; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI with XML and charset", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('application/xml') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with XML and charset", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle missing Content-Type header", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: "Some content", + headers: [], + } + + const testScript = ` + pm.test("dataURI without Content-Type header", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + // Should default to application/octet-stream + pm.expect(dataUri).to.include('application/octet-stream') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI without Content-Type header", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should properly encode UTF-8 content", async () => { + const response: TestResponse = { + status: 200, + statusText: "OK", + body: JSON.stringify({ message: "Hello 世界 🌍" }), + headers: [ + { key: "Content-Type", value: "application/json; charset=utf-8" }, + ], + } + + const testScript = ` + pm.test("dataURI with UTF-8 characters", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri.length).to.be.above(50) + // Should contain valid base64 characters after "base64," + const base64Part = dataUri.split('base64,')[1] + pm.expect(base64Part).to.be.a('string') + pm.expect(base64Part.length).to.be.above(0) + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with UTF-8 characters", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) + + test("should handle empty response body", async () => { + const response: TestResponse = { + status: 204, + statusText: "No Content", + body: "", + headers: [{ key: "Content-Type", value: "text/plain" }], + } + + const testScript = ` + pm.test("dataURI with empty body", () => { + const dataUri = pm.response.dataURI() + pm.expect(dataUri).to.be.a('string') + pm.expect(dataUri).to.match(/^data:.+;base64,/) + pm.expect(dataUri).to.include('text/plain') + }) + ` + + return expect( + runTest(testScript, { global: [], selected: [] }, response)() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "dataURI with empty body", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ]) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts new file mode 100644 index 00000000..4a8ae098 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/response/method-existence.spec.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest" +import { runTestScript } from "~/node" +import { TestResponse } from "~/types/test-runner" + +describe("pm.response method existence checks", () => { + const mockResponse: TestResponse = { + status: 200, + headers: [{ key: "Content-Type", value: "application/json" }], + body: JSON.stringify({ message: "Hello" }), + } + + it("should recognize pm.response.reason as a function", async () => { + const testScript = ` + pm.test("pm.response.reason is a function", () => { + pm.expect(pm.response.reason).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.reason is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should recognize pm.response.dataURI as a function", async () => { + const testScript = ` + pm.test("pm.response.dataURI is a function", () => { + pm.expect(pm.response.dataURI).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.dataURI is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should recognize pm.response.jsonp as a function", async () => { + const testScript = ` + pm.test("pm.response.jsonp is a function", () => { + pm.expect(pm.response.jsonp).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "pm.response.jsonp is a function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should work with typeof checks", async () => { + const testScript = ` + pm.test("typeof pm.response.reason equals function", () => { + pm.expect(typeof pm.response.reason).to.equal('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "typeof pm.response.reason equals function", + expectResults: [ + expect.objectContaining({ + status: "pass", + }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should verify all three utility methods exist as functions", async () => { + const testScript = ` + pm.test("all response utility methods exist", () => { + pm.expect(pm.response.reason).to.be.a('function') + pm.expect(pm.response.dataURI).to.be.a('function') + pm.expect(pm.response.jsonp).to.be.a('function') + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "all response utility methods exist", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ], + }), + ], + }) + ) + }) + + it("should verify methods work correctly when called", async () => { + const testScript = ` + pm.test("response.reason() returns status text", () => { + pm.expect(pm.response.reason()).to.equal('OK') + }) + + pm.test("response.dataURI() returns data URI string", () => { + const uri = pm.response.dataURI() + pm.expect(uri).to.be.a('string') + pm.expect(uri).to.include('data:') + }) + + pm.test("response.jsonp() parses JSON", () => { + const result = pm.response.jsonp() + pm.expect(result).to.deep.equal({ message: "Hello" }) + }) + ` + + await expect( + runTestScript(testScript, { response: mockResponse })() + ).resolves.toEqualRight( + expect.objectContaining({ + tests: [ + expect.objectContaining({ + expectResults: [], + children: [ + expect.objectContaining({ + descriptor: "response.reason() returns status text", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + expect.objectContaining({ + descriptor: "response.dataURI() returns data URI string", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + expect.objectContaining({ + descriptor: "response.jsonp() parses JSON", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ], + }), + ], + }) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts new file mode 100644 index 00000000..973773e5 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/serialization-edge-cases.spec.ts @@ -0,0 +1,1224 @@ +// QuickJS sandbox boundary serialization: compute values BEFORE crossing to preserve type info + +import { describe, expect, test } from "vitest" +import { + runTestWithResponse, + fakeResponse, + runTest, +} from "~/utils/test-helpers" + +describe("Serialization Edge Cases - Object Reference Equality", () => { + describe("`pm.expect.equal()` - Reference Equality", () => { + test("should pass for same object reference", async () => { + const testScript = ` + pm.test("same reference equality", () => { + const obj = { a: 1 }; + pm.expect(obj).to.equal(obj); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "same reference equality", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different object references with same content", async () => { + const testScript = ` + pm.test("different references", () => { + pm.expect({ a: 1 }).to.not.equal({ a: 1 }); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different references", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with arrays", async () => { + const testScript = ` + pm.test("array reference equality", () => { + const arr = [1, 2, 3]; + pm.expect(arr).to.equal(arr); + pm.expect([1, 2, 3]).to.not.equal([1, 2, 3]); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array reference equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with primitives (always equal by value)", async () => { + const testScript = ` + pm.test("primitive equality", () => { + pm.expect(5).to.equal(5); + pm.expect('hello').to.equal('hello'); + pm.expect(true).to.equal(true); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "primitive equality", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Prototype Chain Preservation", () => { + describe("`pm.expect.own.property()` - Own vs Inherited Properties", () => { + test("should pass when object HAS own property", async () => { + const testScript = ` + pm.test("own property - positive case", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property - positive case", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should distinguish own properties from inherited (negation)", async () => { + const testScript = ` + pm.test("own property vs inherited", () => { + const obj = Object.create({ inherited: true }); + obj.own = true; + pm.expect(obj).to.have.own.property('own'); + pm.expect(obj).to.not.have.own.property('inherited'); + pm.expect(obj).to.have.property('inherited'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "own property vs inherited", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with plain objects", async () => { + const testScript = ` + pm.test("plain object own properties", () => { + const obj = { a: 1, b: 2 }; + pm.expect(obj).to.have.own.property('a'); + pm.expect(obj).to.have.own.property('b'); + pm.expect(obj).to.not.have.own.property('toString'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "plain object own properties", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when assertion is incorrect", async () => { + const testScript = ` + pm.test("incorrect assertion", () => { + const obj = { a: 1 }; + pm.expect(obj).to.not.have.own.property('a'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "incorrect assertion", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with arrays (inherited from Array.prototype)", async () => { + const testScript = ` + pm.test("array own vs inherited", () => { + const arr = [1, 2, 3]; + pm.expect(arr).to.have.own.property('0'); + pm.expect(arr).to.have.own.property('length'); + pm.expect(arr).to.not.have.own.property('push'); + pm.expect(arr).to.have.property('push'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array own vs inherited", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.itself.respondTo()` - Static vs Instance Methods", () => { + test("should pass when class HAS static method", async () => { + const testScript = ` + pm.test("static method - positive case", () => { + class MyClass { + static staticMethod() {} + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static method - positive case", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should distinguish static methods from instance methods", async () => { + const testScript = ` + pm.test("static vs instance methods", () => { + class MyClass { + static staticMethod() {} + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + pm.expect(MyClass).to.not.itself.respondTo('instanceMethod'); + pm.expect(MyClass).to.respondTo('instanceMethod'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static vs instance methods", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with built-in constructors", async () => { + const testScript = ` + pm.test("built-in static methods", () => { + pm.expect(Array).itself.to.respondTo('isArray'); + pm.expect(Array).itself.to.respondTo('from'); + pm.expect(Object).itself.to.respondTo('keys'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "built-in static methods", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when static method doesn't exist", async () => { + const testScript = ` + pm.test("non-existent static method", () => { + class MyClass { + instanceMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('nonExistent'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "non-existent static method", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should work with negation", async () => { + const testScript = ` + pm.test("static method negation", () => { + class MyClass { + static staticMethod() {} + } + pm.expect(MyClass).itself.to.respondTo('staticMethod'); + pm.expect(MyClass).itself.to.not.respondTo('nonExistent'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "static method negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should work with multiple static methods", async () => { + const testScript = ` + pm.test("multiple static methods", () => { + class MyClass { + static method1() {} + static method2() {} + static method3() {} + } + pm.expect(MyClass).itself.to.respondTo('method1'); + pm.expect(MyClass).itself.to.respondTo('method2'); + pm.expect(MyClass).itself.to.respondTo('method3'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "multiple static methods", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Special Object Types", () => { + describe("`pm.expect.eql()` - RegExp Comparison", () => { + test("should pass for identical RegExp patterns", async () => { + const testScript = ` + pm.test("identical regex", () => { + pm.expect(/test/i).to.eql(/test/i); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "identical regex", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different RegExp flags", async () => { + const testScript = ` + pm.test("different flags", () => { + pm.expect(/test/i).to.not.eql(/test/g); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different flags", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should fail for different RegExp patterns", async () => { + const testScript = ` + pm.test("different patterns", () => { + pm.expect(/test/).to.not.eql(/demo/); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different patterns", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.equal()` - Date Comparison", () => { + test("should pass for same Date reference", async () => { + const testScript = ` + pm.test("same date reference", () => { + const date = new Date('2024-01-01'); + pm.expect(date).to.equal(date); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "same date reference", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass when different Date references are not equal (reference inequality)", async () => { + const testScript = ` + pm.test("different date references", () => { + pm.expect(new Date('2024-01-01')).to.not.equal(new Date('2024-01-01')); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "different date references", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should use eql() for value equality on dates", async () => { + const testScript = ` + pm.test("date value equality", () => { + pm.expect(new Date('2024-01-01')).to.eql(new Date('2024-01-01')); + pm.expect(new Date('2024-01-01')).to.not.eql(new Date('2024-01-02')); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "date value equality", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + }) + + describe("`pm.expect.a()` - Array Type Checking", () => { + test("should pass for 'object' type check", async () => { + const testScript = ` + pm.test("array is object", () => { + pm.expect([]).to.be.a('object'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array is object", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass for 'array' type check", async () => { + const testScript = ` + pm.test("array is array", () => { + pm.expect([]).to.be.an('array'); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array is array", + expectResults: [expect.objectContaining({ status: "pass" })], + }), + ]), + }), + ]) + ) + }) + + test("should pass all typeof vs instanceof tests", async () => { + const testScript = ` + pm.test("typeof vs instanceof", () => { + pm.expect([]).to.be.a('object'); + pm.expect([]).to.be.an('array'); + pm.expect([]).to.be.instanceOf(Array); + pm.expect([]).to.be.instanceOf(Object); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "typeof vs instanceof", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) +}) + +describe("Serialization Edge Cases - Assertion Chaining", () => { + describe("`.length().which` - Value Proxy Chaining", () => { + test("should allow chaining after length check", async () => { + const testScript = ` + pm.test("length chaining", () => { + pm.expect([1, 2, 3]).to.have.length(3).which.is.above(2); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length chaining", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should chain with .that instead of .which", async () => { + const testScript = ` + pm.test("length with that", () => { + pm.expect('test').to.have.length(4).that.is.above(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "length with that", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should support complex chaining", async () => { + const testScript = ` + pm.test("complex chaining", () => { + pm.expect([1, 2, 3, 4, 5]).to.have.length(5).which.is.above(4).and.below(6); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "complex chaining", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("`.not.have.lengthOf()` - Negation Support", () => { + test("should pass when string does NOT have specified length", async () => { + const testScript = ` + pm.test("string lengthOf negation", () => { + pm.expect('test').to.have.lengthOf(4); + pm.expect('test').to.not.have.lengthOf(10); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "string lengthOf negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should pass when array does NOT have specified length", async () => { + const testScript = ` + pm.test("array lengthOf negation", () => { + pm.expect([1, 2]).to.have.lengthOf(2); + pm.expect([1, 2]).to.not.have.lengthOf(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "array lengthOf negation", + expectResults: [ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ], + }), + ]), + }), + ]) + ) + }) + + test("should fail when negation is incorrect", async () => { + const testScript = ` + pm.test("incorrect negation", () => { + pm.expect('test').to.not.have.lengthOf(4); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "incorrect negation", + expectResults: [expect.objectContaining({ status: "fail" })], + }), + ]), + }), + ]) + ) + }) + + test("should support empty string/array", async () => { + const testScript = ` + pm.test("empty values", () => { + pm.expect('').to.have.lengthOf(0); + pm.expect('').to.not.have.lengthOf(1); + pm.expect([]).to.have.lengthOf(0); + pm.expect([]).to.not.have.lengthOf(5); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "empty values", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + }) + + describe("Circular Reference Handling", () => { + test("should handle circular object references in property assertions", () => { + return expect( + runTest(` + pm.test("Circular reference property", function() { + const obj = { a: 1, b: 2 } + obj.self = obj // Circular reference + + pm.expect(obj).to.have.property('self') + pm.expect(obj.self).to.equal(obj) + pm.expect(obj).to.have.property('a', 1) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Circular reference property", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle nested circular references", () => { + return expect( + runTest(` + pm.test("Nested circular references", function() { + const parent = { name: "parent", child: null } + const child = { name: "child", parent: parent } + parent.child = child // Creates circular reference + + pm.expect(parent.child.parent).to.equal(parent) + pm.expect(parent).to.have.property('name', 'parent') + pm.expect(child).to.have.property('name', 'child') + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Nested circular references", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should not throw 'Converting circular structure to JSON' error", () => { + return expect( + runTest(` + pm.test("No JSON.stringify error", function() { + const obj = { data: 'test' } + obj.circular = obj + + // This should not throw even though obj has circular reference + pm.expect(obj).to.have.property('data') + pm.expect(obj.circular).to.equal(obj) + }) + `)() + ).resolves.toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "No JSON.stringify error", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should fail with stack overflow for circular array references (known limitation)", () => { + return expect( + runTest(` + pm.test("Circular array limitation", function() { + const arr = [1, 2, 3] + arr.push(arr) // Creates circular reference + + pm.expect(arr).to.have.lengthOf(4) + }) + `)() + ).resolves.toEqualLeft( + expect.stringContaining("Maximum call stack size exceeded") + ) + }) + }) +}) + +describe("Serialization Edge Cases - Symbol Properties", () => { + test("should not enumerate Symbol properties in assertions", async () => { + const testScript = ` + pm.test("Symbol properties are not enumerable", () => { + const sym = Symbol('test'); + const obj = { regular: 'value' }; + obj[sym] = 'symbol value'; + + // Should only see regular properties + pm.expect(obj).to.have.property('regular'); + pm.expect(Object.keys(obj)).to.have.lengthOf(1); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol properties are not enumerable", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle objects with Symbol.iterator", async () => { + const testScript = ` + pm.test("Symbol.iterator support", () => { + const customIterable = { + data: [1, 2, 3], + [Symbol.iterator]: function*() { + yield* this.data; + } + }; + + // Should be able to assert on regular properties + pm.expect(customIterable).to.have.property('data'); + pm.expect(customIterable.data).to.have.lengthOf(3); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol.iterator support", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should serialize objects with Symbol properties correctly", async () => { + const testScript = ` + pm.test("Symbol property serialization", () => { + const sym = Symbol('hidden'); + const obj = { + visible: 'data', + nested: { value: 42 } + }; + obj[sym] = 'should not appear in JSON'; + + const jsonStr = JSON.stringify(obj); + const parsed = JSON.parse(jsonStr); + + // After serialization, Symbol properties are lost + pm.expect(parsed).to.have.property('visible', 'data'); + pm.expect(parsed).to.have.property('nested'); + pm.expect(parsed.nested).to.have.property('value', 42); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Symbol property serialization", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) + +describe("Serialization Edge Cases - Special Numbers", () => { + test("should handle NaN assertions correctly", async () => { + const testScript = ` + pm.test("NaN handling", () => { + const result = 0 / 0; + + // NaN is not equal to itself + pm.expect(result).to.be.NaN; + pm.expect(result).to.not.equal(result); + pm.expect(Number.isNaN(result)).to.be.true; + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "NaN handling", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle Infinity and -Infinity", async () => { + const testScript = ` + pm.test("Infinity values", () => { + const posInf = 1 / 0; + const negInf = -1 / 0; + + pm.expect(posInf).to.equal(Infinity); + pm.expect(negInf).to.equal(-Infinity); + pm.expect(posInf).to.not.equal(negInf); + pm.expect(Number.isFinite(posInf)).to.be.false; + pm.expect(Number.isFinite(negInf)).to.be.false; + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Infinity values", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should distinguish between +0 and -0", async () => { + const testScript = ` + pm.test("Negative zero handling", () => { + const positiveZero = 0; + const negativeZero = -0; + + // In most contexts, +0 and -0 are equal + pm.expect(positiveZero).to.equal(negativeZero); + pm.expect(positiveZero === negativeZero).to.be.true; + + // But they can be distinguished with Object.is or 1/x + pm.expect(Object.is(positiveZero, negativeZero)).to.be.false; + pm.expect(1 / positiveZero).to.equal(Infinity); + pm.expect(1 / negativeZero).to.equal(-Infinity); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Negative zero handling", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) + + test("should handle special numbers in JSON serialization", async () => { + const testScript = ` + pm.test("Special numbers in JSON", () => { + const obj = { + nan: NaN, + inf: Infinity, + negInf: -Infinity, + zero: 0, + negZero: -0 + }; + + const jsonStr = JSON.stringify(obj); + const parsed = JSON.parse(jsonStr); + + // NaN, Infinity, -Infinity become null in JSON + pm.expect(parsed.nan).to.be.null; + pm.expect(parsed.inf).to.be.null; + pm.expect(parsed.negInf).to.be.null; + + // Both zeros become 0 + pm.expect(parsed.zero).to.equal(0); + pm.expect(parsed.negZero).to.equal(0); + }); + ` + + const result = await runTestWithResponse(testScript, fakeResponse)() + expect(result).toEqualRight( + expect.arrayContaining([ + expect.objectContaining({ + descriptor: "root", + children: expect.arrayContaining([ + expect.objectContaining({ + descriptor: "Special numbers in JSON", + expectResults: expect.arrayContaining([ + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + expect.objectContaining({ status: "pass" }), + ]), + }), + ]), + }), + ]) + ) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts new file mode 100644 index 00000000..e68d69ef --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/type-coercion.spec.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "vitest" +import { runTest } from "~/utils/test-helpers" + +// Postman compatibility: all types preserved during runtime, only undefined needs special handling + +describe("PM namespace type preservation (Postman compatibility)", () => { + describe("Array preservation", () => { + test("arrays are preserved as arrays with .length property", () => { + return expect( + runTest( + ` + pm.environment.set("array", [1, 2, 3]) + const value = pm.environment.get("array") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(3) + pm.expect(value[0]).toBe(1) + pm.expect(value[2]).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + + test("single-element arrays remain arrays", () => { + return expect( + runTest( + ` + pm.environment.set("single", [42]) + const value = pm.environment.get("single") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(1) + pm.expect(value[0]).toBe(42) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '1' to be '1'" }, + { status: "pass", message: "Expected '42' to be '42'" }, + ], + }), + ]) + }) + + test("empty arrays are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("empty", []) + const value = pm.environment.get("empty") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(0) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ], + }), + ]) + }) + + test("nested arrays are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("nested", [[1, 2], [3, 4]]) + const value = pm.environment.get("nested") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(2) + pm.expect(Array.isArray(value[0])).toBe(true) + pm.expect(value[0][1]).toBe(2) + pm.expect(value[1][0]).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '2' to be '2'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + }) + + describe("Object preservation", () => { + test("objects are preserved with accessible properties", () => { + return expect( + runTest( + ` + pm.environment.set("obj", { key: "value", num: 42 }) + const value = pm.environment.get("obj") + pm.expect(typeof value).toBe("object") + pm.expect(value.key).toBe("value") + pm.expect(value.num).toBe(42) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + { status: "pass", message: "Expected '42' to be '42'" }, + ], + }), + ]) + }) + + test("empty objects are preserved", () => { + return expect( + runTest( + ` + pm.environment.set("empty_obj", {}) + const value = pm.environment.get("empty_obj") + pm.expect(typeof value).toBe("object") + pm.expect(Array.isArray(value)).toBe(false) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + + test("nested objects are preserved", () => { + return expect( + runTest( + ` + const original = { key: "value", nested: { prop: 123, deep: { inner: "test" } } } + pm.environment.set("nested_obj", original) + const retrieved = pm.environment.get("nested_obj") + + pm.expect(typeof retrieved).toBe("object") + pm.expect(retrieved.key).toBe("value") + pm.expect(retrieved.nested.prop).toBe(123) + pm.expect(retrieved.nested.deep.inner).toBe("test") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + { status: "pass", message: "Expected '123' to be '123'" }, + { status: "pass", message: "Expected 'test' to be 'test'" }, + ], + }), + ]) + }) + }) + + describe("Null preservation", () => { + test("null is preserved as actual null value", () => { + return expect( + runTest( + ` + pm.environment.set("nullable", null) + const value = pm.environment.get("nullable") + pm.expect(value).toBe(null) + pm.expect(value === null).toBe(true) + pm.expect(typeof value).toBe("object") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'null' to be 'null'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'object' to be 'object'" }, + ], + }), + ]) + }) + }) + + describe("Undefined preservation (special case)", () => { + test("undefined is preserved as actual undefined", () => { + return expect( + runTest( + ` + pm.environment.set("undef", undefined) + const value = pm.environment.get("undef") + pm.expect(value).toBe(undefined) + pm.expect(typeof value).toBe("undefined") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + { + status: "pass", + message: "Expected 'undefined' to be 'undefined'", + }, + ], + }), + ]) + }) + + test("undefined is distinguishable from non-existent keys", () => { + return expect( + runTest( + ` + pm.environment.set("explicit_undef", undefined) + pm.expect(pm.environment.has("explicit_undef")).toBe(true) + pm.expect(pm.environment.has("never_set")).toBe(false) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + ], + }), + ]) + }) + }) + + describe("Primitive type preservation", () => { + test("numbers are preserved as numbers", () => { + return expect( + runTest( + ` + pm.environment.set("num", 123) + const value = pm.environment.get("num") + pm.expect(typeof value).toBe("number") + pm.expect(value).toBe(123) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'number' to be 'number'" }, + { status: "pass", message: "Expected '123' to be '123'" }, + ], + }), + ]) + }) + + test("booleans are preserved as booleans", () => { + return expect( + runTest( + ` + pm.environment.set("bool", true) + const value = pm.environment.get("bool") + pm.expect(typeof value).toBe("boolean") + pm.expect(value).toBe(true) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'boolean' to be 'boolean'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("strings remain strings", () => { + return expect( + runTest( + ` + pm.environment.set("str", "hello") + const value = pm.environment.get("str") + pm.expect(typeof value).toBe("string") + pm.expect(value).toBe("hello") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'string' to be 'string'" }, + { status: "pass", message: "Expected 'hello' to be 'hello'" }, + ], + }), + ]) + }) + }) + + describe("Cross-scope type preservation", () => { + test("pm.globals preserves arrays", () => { + return expect( + runTest( + ` + pm.globals.set("global_array", [1, 2, 3]) + const value = pm.globals.get("global_array") + pm.expect(Array.isArray(value)).toBe(true) + pm.expect(value.length).toBe(3) + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected '3' to be '3'" }, + ], + }), + ]) + }) + + test("pm.variables preserves objects", () => { + return expect( + runTest( + ` + pm.variables.set("var_obj", { key: "value" }) + const value = pm.variables.get("var_obj") + pm.expect(typeof value).toBe("object") + pm.expect(value.key).toBe("value") + `, + { global: [], selected: [] } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'object' to be 'object'" }, + { status: "pass", message: "Expected 'value' to be 'value'" }, + ], + }), + ]) + }) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts index df509bb1..490e4716 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/unsupported.spec.ts @@ -1,31 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "test response", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pm namespace - unsupported features", () => { test("pm.info.iteration throws error", () => { return expect( - func( + runTest( ` try { const iteration = pm.info.iteration @@ -57,7 +36,7 @@ describe("pm namespace - unsupported features", () => { test("pm.info.iterationCount throws error", () => { return expect( - func( + runTest( ` try { const iterationCount = pm.info.iterationCount @@ -89,7 +68,7 @@ describe("pm namespace - unsupported features", () => { test("pm.collectionVariables.get() throws error", () => { return expect( - func( + runTest( ` try { pm.collectionVariables.get("test") @@ -121,7 +100,7 @@ describe("pm namespace - unsupported features", () => { test("pm.vault.get() throws error", () => { return expect( - func( + runTest( ` try { pm.vault.get("test") @@ -153,7 +132,7 @@ describe("pm namespace - unsupported features", () => { test("pm.iterationData.get() throws error", () => { return expect( - func( + runTest( ` try { pm.iterationData.get("test") @@ -185,7 +164,7 @@ describe("pm namespace - unsupported features", () => { test("pm.execution.setNextRequest() throws error", () => { return expect( - func( + runTest( ` try { pm.execution.setNextRequest("next-request") @@ -217,7 +196,7 @@ describe("pm namespace - unsupported features", () => { test("pm.sendRequest() throws error", () => { return expect( - func( + runTest( ` try { pm.sendRequest("https://example.com", () => {}) @@ -246,4 +225,68 @@ describe("pm namespace - unsupported features", () => { }), ]) }) + + test("pm.visualizer.set() throws error", () => { + return expect( + runTest( + ` + try { + pm.visualizer.set("

Test

") + pm.test("Should not reach here", () => { + pm.expect(true).toBe(false) + }) + } catch (error) { + pm.test("Throws correct error", () => { + pm.expect(error.message).toInclude("pm.visualizer.set() is not supported") + }) + } + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Throws correct error", + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) + + test("pm.visualizer.clear() throws error", () => { + return expect( + runTest( + ` + try { + pm.visualizer.clear() + pm.test("Should not reach here", () => { + pm.expect(true).toBe(false) + }) + } catch (error) { + pm.test("Throws correct error", () => { + pm.expect(error.message).toInclude("pm.visualizer.clear() is not supported") + }) + } + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + descriptor: "Throws correct error", + expectResults: [{ status: "pass", message: expect.any(String) }], + }), + ], + }), + ]) + }) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts index d9c8fe7d..36a32709 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pm-namespace/variables.spec.ts @@ -1,31 +1,12 @@ import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { runTestScript, runPreRequestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runPreRequestScript } from "~/node" +import { runTest } from "~/utils/test-helpers" describe("pm.environment", () => { test("pm.environment.get returns the correct value for an existing active environment value", () => { return expect( - func( + runTest( ` const data = pm.environment.get("a") pm.expect(data).toBe("b") @@ -51,7 +32,7 @@ describe("pm.environment", () => { test("pm.environment.set creates and retrieves environment variable", () => { return expect( - func( + runTest( ` pm.environment.set("test_set", "set_value") const retrieved = pm.environment.get("test_set") @@ -76,7 +57,7 @@ describe("pm.environment", () => { test("pm.environment.set works correctly", () => { return expect( - func( + runTest( ` pm.environment.set("newVar", "newValue") const data = pm.environment.get("newVar") @@ -98,7 +79,7 @@ describe("pm.environment", () => { test("pm.environment.has correctly identifies existing and non-existing variables", () => { return expect( - func( + runTest( ` const hasExisting = pm.environment.has("existing_var") const hasNonExisting = pm.environment.has("non_existing_var") @@ -137,7 +118,7 @@ describe("pm.environment", () => { describe("pm.globals", () => { test("pm.globals.get returns the correct value for an existing global environment value", () => { return expect( - func( + runTest( ` const data = pm.globals.get("globalVar") pm.expect(data).toBe("globalValue") @@ -168,7 +149,7 @@ describe("pm.globals", () => { test("pm.globals.set creates and retrieves global variable", () => { return expect( - func( + runTest( ` pm.globals.set("test_global", "global_value") const retrieved = pm.globals.get("test_global") @@ -195,7 +176,7 @@ describe("pm.globals", () => { describe("pm.variables", () => { test("pm.variables.get returns the correct value from any scope", () => { return expect( - func( + runTest( ` const data = pm.variables.get("scopedVar") pm.expect(data).toBe("scopedValue") @@ -226,7 +207,7 @@ describe("pm.variables", () => { test("pm.variables.set creates and retrieves variable in active environment", () => { return expect( - func( + runTest( ` pm.variables.set("test_var", "test_value") const retrieved = pm.variables.get("test_var") @@ -251,7 +232,7 @@ describe("pm.variables", () => { test("pm.variables.has correctly identifies existing and non-existing variables", () => { return expect( - func( + runTest( ` const hasExisting = pm.variables.has("existing_var") const hasNonExisting = pm.variables.has("non_existing_var") @@ -288,7 +269,7 @@ describe("pm.variables", () => { test("pm.variables.replaceIn works correctly", () => { return expect( - func( + runTest( ` const template = "Hello {{name}}, welcome to {{place}}!" const result = pm.variables.replaceIn(template) @@ -327,7 +308,7 @@ describe("pm.variables", () => { test("pm.variables.replaceIn handles multiple variables", () => { return expect( - func( + runTest( ` const template = "User {{name}} has {{count}} items in {{location}}" const result = pm.variables.replaceIn(template) @@ -375,7 +356,7 @@ describe("pm.variables", () => { describe("pm.test", () => { test("pm.test works as expected", () => { return expect( - func( + runTest( ` pm.test("Simple test", function() { pm.expect(1 + 1).toBe(2) @@ -401,6 +382,238 @@ describe("pm.test", () => { }) }) +describe("pm environment get() null vs undefined behavior", () => { + test("pm.environment.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.environment.get("non_existent_key") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("pm.globals.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.globals.get("non_existent_global") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) + + test("pm.variables.get() returns undefined (not null) for non-existent keys", () => { + return expect( + runTest( + ` + const value = pm.variables.get("non_existent_var") + pw.expect(value).toBe(undefined) + pw.expect(value === null).toBe(false) + pw.expect(value === undefined).toBe(true) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { status: "pass", message: "Expected 'undefined' to be 'undefined'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected 'true' to be 'true'" }, + ], + }), + ]) + }) +}) + +describe("pm environment clear() and toObject() methods", () => { + test("pm.environment.clear() removes all environment variables", () => { + return expect( + runTest( + ` + // Set some variables + pm.environment.set("var1", "value1") + pm.environment.set("var2", "value2") + pm.environment.set("var3", "value3") + + // Verify they exist + pw.expect(pm.environment.has("var1")).toBe(true) + pw.expect(pm.environment.has("var2")).toBe(true) + pw.expect(pm.environment.has("var3")).toBe(true) + + // Clear all + pm.environment.clear() + + // Verify all are removed + pw.expect(pm.environment.has("var1")).toBe(false) + pw.expect(pm.environment.has("var2")).toBe(false) + pw.expect(pm.environment.has("var3")).toBe(false) + + // Verify toObject returns empty + const allVars = pm.environment.toObject() + pw.expect(Object.keys(allVars).length).toBe(0) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ]), + }), + ]) + }) + + test("pm.environment.toObject() returns all environment variables as object", () => { + return expect( + runTest( + ` + const allVars = pm.environment.toObject() + pw.expect(typeof allVars).toBe("object") + pw.expect(allVars.existing_var).toBe("existing_value") + `, + { + global: [], + selected: [ + { + key: "existing_var", + currentValue: "existing_value", + initialValue: "existing_value", + secret: false, + }, + ], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'object' to be 'object'", + }, + { + status: "pass", + message: "Expected 'existing_value' to be 'existing_value'", + }, + ], + }), + ]) + }) + + test("pm.globals.clear() removes all global variables", () => { + return expect( + runTest( + ` + // Set some globals + pm.globals.set("global1", "value1") + pm.globals.set("global2", "value2") + + // Verify they exist + pw.expect(pm.globals.has("global1")).toBe(true) + pw.expect(pm.globals.has("global2")).toBe(true) + + // Clear all + pm.globals.clear() + + // Verify all are removed + pw.expect(pm.globals.has("global1")).toBe(false) + pw.expect(pm.globals.has("global2")).toBe(false) + + // Verify toObject returns empty + const allGlobals = pm.globals.toObject() + pw.expect(Object.keys(allGlobals).length).toBe(0) + `, + { + global: [], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: expect.arrayContaining([ + { status: "pass", message: "Expected 'true' to be 'true'" }, + { status: "pass", message: "Expected 'false' to be 'false'" }, + { status: "pass", message: "Expected '0' to be '0'" }, + ]), + }), + ]) + }) + + test("pm.globals.toObject() returns all global variables as object", () => { + return expect( + runTest( + ` + const allGlobals = pm.globals.toObject() + pw.expect(typeof allGlobals).toBe("object") + pw.expect(allGlobals.global_var).toBe("global_value") + `, + { + global: [ + { + key: "global_var", + currentValue: "global_value", + initialValue: "global_value", + secret: false, + }, + ], + selected: [], + } + )() + ).resolves.toEqualRight([ + expect.objectContaining({ + expectResults: [ + { + status: "pass", + message: "Expected 'object' to be 'object'", + }, + { + status: "pass", + message: "Expected 'global_value' to be 'global_value'", + }, + ], + }), + ]) + }) +}) + describe("pm namespace - pre-request scripts", () => { const DEFAULT_REQUEST = getDefaultRESTRequest() diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts index 4071efb3..8257b96e 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/get.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pw.env.get", () => { test("returns the correct value for an existing selected environment value", () => { return expect( - func( + runTest( ` const data = pw.env.get("a") pw.expect(data).toBe("b") @@ -57,7 +35,7 @@ describe("pw.env.get", () => { test("returns the correct value for an existing global environment value", () => { return expect( - func( + runTest( ` const data = pw.env.get("a") pw.expect(data).toBe("b") @@ -88,7 +66,7 @@ describe("pw.env.get", () => { test("returns undefined for a key that is not present in both selected or environment", () => { return expect( - func( + runTest( ` const data = pw.env.get("a") pw.expect(data).toBe(undefined) @@ -112,7 +90,7 @@ describe("pw.env.get", () => { test("returns the value defined in selected environment if it is also present in global", () => { return expect( - func( + runTest( ` const data = pw.env.get("a") pw.expect(data).toBe("selected val") @@ -150,7 +128,7 @@ describe("pw.env.get", () => { test("does not resolve environment values", () => { return expect( - func( + runTest( ` const data = pw.env.get("a") pw.expect(data).toBe("<>") @@ -181,7 +159,7 @@ describe("pw.env.get", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` const data = pw.env.get(5) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts index 0a9f78a4..d6b93502 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/getResolve.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pw.env.getResolve", () => { test("returns the correct value for an existing selected environment value", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe("b") @@ -57,7 +35,7 @@ describe("pw.env.getResolve", () => { test("returns the correct value for an existing global environment value", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe("b") @@ -88,7 +66,7 @@ describe("pw.env.getResolve", () => { test("returns undefined for a key that is not present in both selected or environment", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe(undefined) @@ -112,7 +90,7 @@ describe("pw.env.getResolve", () => { test("returns the value defined in selected environment if it is also present in global", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe("selected val") @@ -150,7 +128,7 @@ describe("pw.env.getResolve", () => { test("resolve environment values", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe("there") @@ -187,7 +165,7 @@ describe("pw.env.getResolve", () => { test("returns unresolved value on infinite loop in resolution", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve("a") pw.expect(data).toBe("<>") @@ -224,7 +202,7 @@ describe("pw.env.getResolve", () => { test("errors if the key is not a string", () => { return expect( - func( + runTest( ` const data = pw.env.getResolve(5) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts index d2c04759..671b7852 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/resolve.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTest } from "~/utils/test-helpers" describe("pw.env.resolve", () => { test("value should be a string", () => { return expect( - func( + runTest( ` pw.env.resolve(5) `, @@ -40,7 +18,7 @@ describe("pw.env.resolve", () => { test("resolves global variables correctly", () => { return expect( - func( + runTest( ` const data = pw.env.resolve("<>") pw.expect(data).toBe("there") @@ -71,7 +49,7 @@ describe("pw.env.resolve", () => { test("resolves selected env variables correctly", () => { return expect( - func( + runTest( ` const data = pw.env.resolve("<>") pw.expect(data).toBe("there") @@ -102,7 +80,7 @@ describe("pw.env.resolve", () => { test("chooses selected env variable over global variables when both have same variable", () => { return expect( - func( + runTest( ` const data = pw.env.resolve("<>") pw.expect(data).toBe("there") @@ -140,7 +118,7 @@ describe("pw.env.resolve", () => { test("if infinite loop in resolution, abandons resolutions altogether", () => { return expect( - func( + runTest( ` const data = pw.env.resolve("<>") pw.expect(data).toBe("<>") diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts index 01762bb2..fec21bf1 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/set.spec.ts @@ -1,42 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.envs) - ) - -const funcTest = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" describe("pw.env.set", () => { test("updates the selected environment variable correctly", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set("a", "c") `, @@ -68,7 +36,7 @@ describe("pw.env.set", () => { test("updates the global environment variable correctly", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set("a", "c") `, @@ -100,7 +68,7 @@ describe("pw.env.set", () => { test("updates the selected environment if env present in both", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set("a", "c") `, @@ -147,7 +115,7 @@ describe("pw.env.set", () => { test("non existent keys are created in the selected environment", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set("a", "c") `, @@ -173,7 +141,7 @@ describe("pw.env.set", () => { test("keys should be a string", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set(5, "c") `, @@ -187,7 +155,7 @@ describe("pw.env.set", () => { test("values should be a string", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set("a", 5) `, @@ -201,7 +169,7 @@ describe("pw.env.set", () => { test("both keys and values should be strings", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.set(5, 5) `, @@ -215,7 +183,7 @@ describe("pw.env.set", () => { test("set environment values are reflected in the script execution", () => { return expect( - funcTest( + runTest( ` pw.env.set("a", "b") pw.expect(pw.env.get("a")).toBe("b") diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts index c9c9f4bc..10ba2f2a 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/env/unset.spec.ts @@ -1,42 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse, TestResult } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.envs) - ) - -const funcTest = (script: string, envs: TestResult["envs"]) => - pipe( - runTestScript(script, { - envs, - request: defaultRequest, - response: fakeResponse, - }), - TE.map((x) => x.tests) - ) +import { runTestAndGetEnvs, runTest } from "~/utils/test-helpers" describe("pw.env.unset", () => { test("removes the variable set in selected environment correctly", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -61,7 +29,7 @@ describe("pw.env.unset", () => { test("removes the variable set in global environment correctly", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -86,7 +54,7 @@ describe("pw.env.unset", () => { test("removes the variable from selected environment if the entry is present in both selected and global environments", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -126,7 +94,7 @@ describe("pw.env.unset", () => { test("removes the initial occurrence of an entry if duplicate entries exist in the selected environment", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -179,7 +147,7 @@ describe("pw.env.unset", () => { test("removes the initial occurrence of an entry if duplicate entries exist in the global environment", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -218,7 +186,7 @@ describe("pw.env.unset", () => { test("no change if attempting to delete non-existent keys", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset("baseUrl") `, @@ -237,7 +205,7 @@ describe("pw.env.unset", () => { test("keys should be a string", () => { return expect( - func( + runTestAndGetEnvs( ` pw.env.unset(5) `, @@ -251,7 +219,7 @@ describe("pw.env.unset", () => { test("set environment values are reflected in the script execution", () => { return expect( - funcTest( + runTest( ` pw.env.unset("baseUrl") pw.expect(pw.env.get("baseUrl")).toBe(undefined) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts index 0bd6cfea..4a02d217 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBe.spec.ts @@ -1,33 +1,11 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("toBe", () => { describe("general assertion (no negation)", () => { test("expect equals expected passes assertion", () => { return expect( - func( + runTest( ` pw.expect(2).toBe(2) `, @@ -44,7 +22,7 @@ describe("toBe", () => { test("expect not equals expected fails assertion", () => { return expect( - func( + runTest( ` pw.expect(2).toBe(4) `, @@ -63,7 +41,7 @@ describe("toBe", () => { describe("general assertion (with negation)", () => { test("expect equals expected fails assertion", () => { return expect( - func( + runTest( ` pw.expect(2).not.toBe(2) `, @@ -83,7 +61,7 @@ describe("toBe", () => { test("expect not equals expected passes assertion", () => { return expect( - func( + runTest( ` pw.expect(2).not.toBe(4) `, @@ -105,7 +83,7 @@ describe("toBe", () => { test("strict checks types", () => { return expect( - func( + runTest( ` pw.expect(2).toBe("2") `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts index e7417a1c..bedbf963 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeLevelxxx.spec.ts @@ -1,34 +1,12 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("toBeLevelxxx", { timeout: 100000 }, () => { describe("toBeLevel2xx", () => { test("assertion passes for 200 series with no negation", async () => { for (let i = 200; i < 300; i++) { await expect( - func(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -45,7 +23,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for non 200 series with no negation", async () => { for (let i = 300; i < 500; i++) { await expect( - func(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -61,7 +39,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value was not a number with no negation", async () => { await expect( - func(`pw.expect("foo").toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect("foo").toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -78,7 +56,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for 200 series with negation", async () => { for (let i = 200; i < 300; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -95,7 +73,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for non 200 series with negation", async () => { for (let i = 300; i < 500; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -111,7 +89,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value was not a number with negation", async () => { await expect( - func(`pw.expect("foo").not.toBeLevel2xx()`, fakeResponse)() + runTest(`pw.expect("foo").not.toBeLevel2xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -130,7 +108,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for 300 series with no negation", async () => { for (let i = 300; i < 400; i++) { await expect( - func(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -147,7 +125,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for non 300 series with no negation", async () => { for (let i = 400; i < 500; i++) { await expect( - func(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -163,7 +141,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value is not a number without negation", () => { return expect( - func(`pw.expect("foo").toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect("foo").toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -180,7 +158,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for 400 series with negation", async () => { for (let i = 300; i < 400; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -197,7 +175,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for non 200 series with negation", async () => { for (let i = 400; i < 500; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -213,7 +191,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value is not a number with negation", () => { return expect( - func(`pw.expect("foo").not.toBeLevel3xx()`, fakeResponse)() + runTest(`pw.expect("foo").not.toBeLevel3xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -232,7 +210,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for 400 series with no negation", async () => { for (let i = 400; i < 500; i++) { await expect( - func(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -249,7 +227,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for non 400 series with no negation", async () => { for (let i = 500; i < 600; i++) { await expect( - func(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -265,7 +243,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expected value is not a number without negation", () => { return expect( - func(`pw.expect("foo").toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect("foo").toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -282,7 +260,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for 400 series with negation", async () => { for (let i = 400; i < 500; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -299,7 +277,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for non 400 series with negation", async () => { for (let i = 500; i < 600; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -315,7 +293,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expected value is not a number with negation", () => { return expect( - func(`pw.expect("foo").not.toBeLevel4xx()`, fakeResponse)() + runTest(`pw.expect("foo").not.toBeLevel4xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -334,7 +312,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for 500 series with no negation", async () => { for (let i = 500; i < 600; i++) { await expect( - func(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -351,7 +329,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for non 500 series with no negation", async () => { for (let i = 200; i < 500; i++) { await expect( - func(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -367,7 +345,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value is not a number with no negation", () => { return expect( - func(`pw.expect("foo").toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect("foo").toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -384,7 +362,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion fails for 500 series with negation", async () => { for (let i = 500; i < 600; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -401,7 +379,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("assertion passes for non 500 series with negation", async () => { for (let i = 200; i < 500; i++) { await expect( - func(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -417,7 +395,7 @@ describe("toBeLevelxxx", { timeout: 100000 }, () => { test("give error if the expect value is not a number with negation", () => { return expect( - func(`pw.expect("foo").not.toBeLevel5xx()`, fakeResponse)() + runTest(`pw.expect("foo").not.toBeLevel5xx()`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts index af1ded1c..a7fd0408 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toBeType.spec.ts @@ -1,32 +1,10 @@ -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" -import { getDefaultRESTRequest } from "@hoppscotch/data" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("toBeType", () => { test("asserts true for valid type expectations with no negation", () => { return expect( - func( + runTest( ` pw.expect(2).toBeType("number") pw.expect("2").toBeType("string") @@ -77,7 +55,7 @@ describe("toBeType", () => { test("asserts false for invalid type expectations with no negation", () => { return expect( - func( + runTest( ` pw.expect(2).toBeType("string") pw.expect("2").toBeType("number") @@ -108,7 +86,7 @@ describe("toBeType", () => { test("asserts false for valid type expectations with negation", () => { return expect( - func( + runTest( ` pw.expect(2).not.toBeType("number") pw.expect("2").not.toBeType("string") @@ -142,7 +120,7 @@ describe("toBeType", () => { test("asserts true for invalid type expectations with negation", () => { return expect( - func( + runTest( ` pw.expect(2).not.toBeType("string") pw.expect("2").not.toBeType("number") @@ -197,7 +175,7 @@ describe("toBeType", () => { test("gives error for invalid type names without negation", () => { return expect( - func( + runTest( ` pw.expect(2).toBeType("foo") pw.expect("2").toBeType("bar") @@ -237,7 +215,7 @@ describe("toBeType", () => { test("gives error for invalid type names with negation", () => { return expect( - func( + runTest( ` pw.expect(2).not.toBeType("foo") pw.expect("2").not.toBeType("bar") diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts index 7e2f9f87..6449f5fc 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toHaveLength.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("toHaveLength", () => { test("asserts true for valid lengths with no negation", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).toHaveLength(4) pw.expect([]).toHaveLength(0) @@ -45,7 +23,7 @@ describe("toHaveLength", () => { test("asserts false for invalid lengths with no negation", () => { return expect( - func( + runTest( ` pw.expect([]).toHaveLength(4) pw.expect([1, 2, 3, 4]).toHaveLength(0) @@ -64,7 +42,7 @@ describe("toHaveLength", () => { test("asserts false for valid lengths with negation", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).not.toHaveLength(4) pw.expect([]).not.toHaveLength(0) @@ -89,7 +67,7 @@ describe("toHaveLength", () => { test("asserts true for invalid lengths with negation", () => { return expect( - func( + runTest( ` pw.expect([]).not.toHaveLength(4) pw.expect([1, 2, 3, 4]).not.toHaveLength(0) @@ -114,7 +92,7 @@ describe("toHaveLength", () => { test("gives error if not called on an array or a string with no negation", () => { return expect( - func( + runTest( ` pw.expect(5).toHaveLength(0) pw.expect(true).toHaveLength(0) @@ -141,7 +119,7 @@ describe("toHaveLength", () => { test("gives error if not called on an array or a string with negation", () => { return expect( - func( + runTest( ` pw.expect(5).not.toHaveLength(0) pw.expect(true).not.toHaveLength(0) @@ -168,7 +146,7 @@ describe("toHaveLength", () => { test("gives an error if toHaveLength parameter is not a number without negation", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).toHaveLength("a") `, @@ -188,7 +166,7 @@ describe("toHaveLength", () => { test("gives an error if toHaveLength parameter is not a number with negation", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).not.toHaveLength("a") `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts index 627d228e..0959d507 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/expect/toInclude.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("toInclude", () => { test("asserts true for collections with matching values", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3]).toInclude(1) pw.expect("123").toInclude(1) @@ -45,7 +23,7 @@ describe("toInclude", () => { test("asserts false for collections without matching values", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3]).toInclude(4) pw.expect("123").toInclude(4) @@ -64,7 +42,7 @@ describe("toInclude", () => { test("asserts false for empty collections", () => { return expect( - func( + runTest( ` pw.expect([]).not.toInclude(0) pw.expect("").not.toInclude(0) @@ -89,7 +67,7 @@ describe("toInclude", () => { test("asserts false for [number array].includes(string)", () => { return expect( - func( + runTest( ` pw.expect([1]).not.toInclude("1") `, @@ -112,7 +90,7 @@ describe("toInclude", () => { // (`"123".includes(123)` returns `True` in Node.js v14.19.1) // See https://tc39.es/ecma262/multipage/text-processing.html#sec-string.prototype.includes return expect( - func(`pw.expect("123").toInclude(123)`, fakeResponse)() + runTest(`pw.expect("123").toInclude(123)`, fakeResponse)() ).resolves.toEqualRight([ expect.objectContaining({ expectResults: [ @@ -127,7 +105,7 @@ describe("toInclude", () => { test("gives error if not called on an array or string", () => { return expect( - func( + runTest( ` pw.expect(5).not.toInclude(0) pw.expect(true).not.toInclude(0) @@ -152,7 +130,7 @@ describe("toInclude", () => { test("gives an error if toInclude parameter is null", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).not.toInclude(null) `, @@ -172,7 +150,7 @@ describe("toInclude", () => { test("gives an error if toInclude parameter is undefined", () => { return expect( - func( + runTest( ` pw.expect([1, 2, 3, 4]).not.toInclude(undefined) `, diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/test-runner.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/test-runner.spec.ts index 82c1f64d..c233cdeb 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/test-runner.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/pw-namespace/test-runner.spec.ts @@ -1,32 +1,10 @@ -import { getDefaultRESTRequest } from "@hoppscotch/data" -import * as TE from "fp-ts/TaskEither" -import { pipe } from "fp-ts/function" import { describe, expect, test } from "vitest" - -import { runTestScript } from "~/node" -import { TestResponse } from "~/types" - -const defaultRequest = getDefaultRESTRequest() -const fakeResponse: TestResponse = { - status: 200, - body: "hoi", - headers: [], -} - -const func = (script: string, res: TestResponse) => - pipe( - runTestScript(script, { - envs: { global: [], selected: [] }, - request: defaultRequest, - response: res, - }), - TE.map((x) => x.tests) - ) +import { runTest, fakeResponse } from "~/utils/test-helpers" describe("runTestScript", () => { test("returns a resolved promise for a valid test script with all green", () => { return expect( - func( + runTest( ` pw.test("Arithmetic operations", () => { const size = 500 + 500; @@ -43,7 +21,7 @@ describe("runTestScript", () => { test("resolves for tests with failed expectations", () => { return expect( - func( + runTest( ` pw.test("Arithmetic operations", () => { const size = 500 + 500; @@ -61,7 +39,7 @@ describe("runTestScript", () => { // TODO: We need a more concrete behavior for this test("rejects for invalid syntax on tests", () => { return expect( - func( + runTest( ` pw.test("Arithmetic operations", () => { const size = 500 + 500; diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts index 2ee4b30e..82229de0 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/pre-request.spec.ts @@ -8,7 +8,7 @@ import { describe, expect, test } from "vitest" import { getRequestSetterMethods } from "~/utils/pre-request" const baseRequest: HoppRESTRequest = { - v: "15", + v: "16", name: "Test Request", endpoint: "https://example.com/api", method: "GET", @@ -30,11 +30,11 @@ describe("getRequestSetterMethods", () => { expect(updatedRequest.endpoint).toBe("https://updated.com/api") }) - test("`setMethod` should update and uppercase the method", () => { + test("`setMethod` should update the method (case preserved)", () => { const { methods, updatedRequest } = getRequestSetterMethods(baseRequest) methods.setMethod("post") - expect(updatedRequest.method).toBe("POST") + expect(updatedRequest.method).toBe("post") }) test("`setHeader` setter should update existing header case-insensitively", () => { diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts index 631128c8..75d24282 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/shared.spec.ts @@ -1,11 +1,13 @@ import { getSharedCookieMethods, + getSharedEnvMethods, getSharedRequestProps, preventCyclicObjects, } from "~/utils/shared" import { Cookie, HoppRESTRequest } from "@hoppscotch/data" import { describe, expect, test } from "vitest" +import { TestResult } from "~/types" describe("preventCyclicObjects", () => { test("succeeds with a simple object", () => { @@ -30,7 +32,7 @@ describe("preventCyclicObjects", () => { describe("getSharedRequestProps", () => { const baseRequest: HoppRESTRequest = { - v: "15", + v: "16", name: "Test Request", endpoint: "https://example.com/api", method: "GET", @@ -73,21 +75,6 @@ describe("getSharedRequestProps", () => { const request = getSharedRequestProps(baseRequest) expect(request.auth).toEqual(baseRequest.auth) }) - - test("`params` getter", () => { - const request = getSharedRequestProps(baseRequest) - expect(request.params).toEqual(baseRequest.params) - }) - - test("`body` getter", () => { - const request = getSharedRequestProps(baseRequest) - expect(request.body).toEqual(baseRequest.body) - }) - - test("`auth` getter", () => { - const request = getSharedRequestProps(baseRequest) - expect(request.auth).toEqual(baseRequest.auth) - }) }) describe("getSharedCookieMethods", () => { @@ -184,3 +171,340 @@ describe("getSharedCookieMethods", () => { expect(() => methods.clear(123 as any)).toThrow() }) }) + +describe("getSharedEnvMethods - Experimental Sandbox (isHoppNamespace=true)", () => { + const baseEnvs: TestResult["envs"] = { + global: [ + { + key: "globalKey", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + selected: [ + { + key: "selectedKey", + currentValue: "selectedVal", + initialValue: "selectedVal", + secret: false, + }, + ], + } + + test("returns pw and hopp namespace structure", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(methods).toHaveProperty("pw") + expect(methods).toHaveProperty("hopp") + expect(methods.pw).toHaveProperty("get") + expect(methods.hopp).toHaveProperty("set") + }) + + test("pw.get retrieves from selected then global", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(methods.pw.get("selectedKey")).toBe("selectedVal") + expect(methods.pw.get("globalKey")).toBe("globalVal") + expect(methods.pw.get("nonexistent")).toBeUndefined() + }) + + test("pw.set updates selected environment", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.pw.set("newKey", "newVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "newKey", + currentValue: "newVal", + initialValue: "newVal", + secret: false, + }) + }) + + test("pw.set validates string key and value", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(() => methods.pw.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.pw.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("pw.resolve handles template strings", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "name", + currentValue: "Alice", + initialValue: "Alice", + secret: false, + }, + { + key: "greeting", + currentValue: "Hello <>", + initialValue: "Hello <>", + secret: false, + }, + ], + }, + true + ) + + const resolved = methods.pw.resolve("<>") + expect(resolved).toBe("Hello Alice") + }) + + test("pw.getResolve combines get and resolve", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "baseUrl", + currentValue: "https://api.example.com", + initialValue: "https://api.example.com", + secret: false, + }, + { + key: "endpoint", + currentValue: "<>/users", + initialValue: "<>/users", + secret: false, + }, + ], + }, + true + ) + + const resolved = methods.pw.getResolve("endpoint") + expect(resolved).toBe("https://api.example.com/users") + }) + + test("hopp.set creates new variable in selected scope (default source='all')", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.set("hoppKey", "hoppVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "hoppKey", + currentValue: "hoppVal", + initialValue: "hoppVal", + secret: false, + }) + }) + + test("hopp.set validates string types", () => { + const { methods } = getSharedEnvMethods(baseEnvs, true) + + expect(() => methods.hopp.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.hopp.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("hopp.delete removes variable from selected scope", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.delete("selectedKey") + + expect(updatedEnvs.selected).not.toContainEqual( + expect.objectContaining({ key: "selectedKey" }) + ) + expect(updatedEnvs.global.length).toBe(1) + }) + + test("hopp.reset resets a variable to its initial value", () => { + const { methods, updatedEnvs } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "testKey", + currentValue: "modified", + initialValue: "original", + secret: false, + }, + ], + }, + true + ) + + methods.hopp.reset("testKey") + + const variable = updatedEnvs.selected.find((e) => e.key === "testKey") + expect(variable?.currentValue).toBe("original") + expect(variable?.initialValue).toBe("original") + }) + + test("hopp.getInitialRaw returns initial value", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "testKey", + currentValue: "currentVal", + initialValue: "initialVal", + secret: false, + }, + ], + }, + true + ) + + expect(methods.hopp.getInitialRaw("testKey")).toBe("initialVal") + expect(methods.hopp.getInitialRaw("nonexistent")).toBeNull() + }) + + test("hopp.setInitial sets initial value", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, true) + + methods.hopp.setInitial("initKey", "initVal") + + const created = updatedEnvs.selected.find((e) => e.key === "initKey") + expect(created).toBeDefined() + expect(created?.initialValue).toBe("initVal") + expect(created?.currentValue).toBe("initVal") + }) +}) + +describe("getSharedEnvMethods - Legacy Sandbox (isHoppNamespace=false)", () => { + const baseEnvs: TestResult["envs"] = { + global: [ + { + key: "globalKey", + currentValue: "globalVal", + initialValue: "globalVal", + secret: false, + }, + ], + selected: [ + { + key: "selectedKey", + currentValue: "selectedVal", + initialValue: "selectedVal", + secret: false, + }, + ], + } + + test("returns env object structure (not pw/hopp)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(methods).toHaveProperty("env") + expect(methods).not.toHaveProperty("pw") + expect(methods).not.toHaveProperty("hopp") + }) + + test("env object has all expected methods", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(typeof methods.env.get).toBe("function") + expect(typeof methods.env.set).toBe("function") + expect(typeof methods.env.resolve).toBe("function") + expect(typeof methods.env.getResolve).toBe("function") + }) + + test("env.get retrieves from selected then global", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + expect(methods.env.get("selectedKey")).toBe("selectedVal") + expect(methods.env.get("globalKey")).toBe("globalVal") + expect(methods.env.get("nonexistent")).toBeUndefined() + }) + + test("env.set updates environment correctly", () => { + const { methods, updatedEnvs } = getSharedEnvMethods(baseEnvs, false) + + methods.env.set("newKey", "newVal") + + expect(updatedEnvs.selected).toContainEqual({ + key: "newKey", + currentValue: "newVal", + initialValue: "newVal", + secret: false, + }) + }) + + test("env.set validates string types (regression test for #5433)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + // This is the bug that was fixed in #5433 - missing validation + expect(() => methods.env.set(123 as any, "value")).toThrow( + "Expected key to be a string" + ) + expect(() => methods.env.set("key", 123 as any)).toThrow( + "Expected value to be a string" + ) + }) + + test("env.resolve handles template strings", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "user", + currentValue: "Bob", + initialValue: "Bob", + secret: false, + }, + { + key: "message", + currentValue: "Hello <>", + initialValue: "Hello <>", + secret: false, + }, + ], + }, + false + ) + + const resolved = methods.env.resolve("<>") + expect(resolved).toBe("Hello Bob") + }) + + test("env.getResolve returns resolved value", () => { + const { methods } = getSharedEnvMethods( + { + global: [], + selected: [ + { + key: "domain", + currentValue: "example.com", + initialValue: "example.com", + secret: false, + }, + { + key: "apiUrl", + currentValue: "https://<>/api", + initialValue: "https://<>/api", + secret: false, + }, + ], + }, + false + ) + + const resolved = methods.env.getResolve("apiUrl") + expect(resolved).toBe("https://example.com/api") + }) + + test("env object structure prevents #5433 regression (pw.env not recognized)", () => { + const { methods } = getSharedEnvMethods(baseEnvs, false) + + // In legacy sandbox, this gets assigned to pw.env + // The bug was that pw.env was undefined because the structure wasn't correct + expect(methods.env).toBeDefined() + expect(typeof methods.env).toBe("object") + expect(methods.env.get).toBeDefined() + expect(methods.env.set).toBeDefined() + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js index 325f1c5a..78624ddd 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js @@ -2,6 +2,1892 @@ ;(inputs) => { // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code "use strict" + + // Chai proxy builder - creates a Chai-like API using actual Chai SDK + if (!globalThis.__createChaiProxy) { + globalThis.__createChaiProxy = function ( + expectVal, + inputs, + modifiers = " to" + ) { + const proxy = {} + + // Helper to create a new proxy with updated modifiers + const withModifiers = (newModifiers) => { + return globalThis.__createChaiProxy(expectVal, inputs, newModifiers) + } + + // Assertion methods + proxy.equal = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Special handling for Date/RegExp which serialize to primitives + let isSameReference = undefined + let typeInfo = undefined + + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + + // Track type for objects that lose identity during serialization + if (expectVal instanceof Date && expected instanceof Date) { + typeInfo = { + type: "Date", + valueTime: expectVal.getTime(), + expectedTime: expected.getTime(), + } + } else if ( + expectVal instanceof RegExp && + expected instanceof RegExp + ) { + typeInfo = { + type: "RegExp", + valueSource: expectVal.source, + valueFlags: expectVal.flags, + expectedSource: expected.source, + expectedFlags: expected.flags, + } + } + } + + inputs.chaiEqual( + expectVal, + expected, + modifiers, + "equal", + isSameReference, + typeInfo + ) + return withModifiers(modifiers) + } + proxy.equals = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Only pass isSameReference when BOTH values are objects + let isSameReference = undefined + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + } + inputs.chaiEqual( + expectVal, + expected, + modifiers, + "equals", + isSameReference + ) + return withModifiers(modifiers) + } + proxy.eq = (expected) => { + // PRE-CHECK PATTERN: Track object identity for reference equality + // Only pass isSameReference when BOTH values are objects + let isSameReference = undefined + if ( + expectVal !== null && + expected !== null && + typeof expectVal === "object" && + typeof expected === "object" + ) { + isSameReference = expectVal === expected + } + inputs.chaiEqual(expectVal, expected, modifiers, "eq", isSameReference) + return withModifiers(modifiers) + } + proxy.eql = (expected) => { + // PRE-CHECK PATTERN: Extract metadata from special objects before serialization + let valueMetadata = undefined + let expectedMetadata = undefined + + // Handle RegExp objects - extract pattern and flags + if (expectVal instanceof RegExp && expected instanceof RegExp) { + valueMetadata = { + type: "RegExp", + source: expectVal.source, + flags: expectVal.flags, + } + expectedMetadata = { + type: "RegExp", + source: expected.source, + flags: expected.flags, + } + } + // Handle Date objects - extract timestamp + else if (expectVal instanceof Date && expected instanceof Date) { + valueMetadata = { + type: "Date", + time: expectVal.getTime(), + } + expectedMetadata = { + type: "Date", + time: expected.getTime(), + } + } + + inputs.chaiEql( + expectVal, + expected, + modifiers, + valueMetadata, + expectedMetadata + ) + return withModifiers(modifiers) + } + + // Custom Postman methods - delegates to chai-helpers.ts + proxy.jsonSchema = (schema) => { + if (!inputs.chaiJsonSchema) { + throw new Error("chaiJsonSchema method not found in inputs") + } + inputs.chaiJsonSchema(expectVal, schema, modifiers) + return withModifiers(modifiers) + } + + proxy.charset = (expectedCharset) => { + if (!inputs.chaiCharset) { + throw new Error("chaiCharset method not found in inputs") + } + inputs.chaiCharset(expectVal, expectedCharset, modifiers) + return withModifiers(modifiers) + } + + proxy.cookie = (cookieName, cookieValue) => { + if (!inputs.chaiCookie) { + throw new Error("chaiCookie method not found in inputs") + } + inputs.chaiCookie(expectVal, cookieName, cookieValue, modifiers) + return withModifiers(modifiers) + } + + proxy.jsonPath = (path, expectedValue) => { + if (!inputs.chaiJsonPath) { + throw new Error("chaiJsonPath method not found in inputs") + } + inputs.chaiJsonPath(expectVal, path, expectedValue, modifiers) + return withModifiers(modifiers) + } + + // .a() and .an() can be both type assertion methods and language chains for .instanceof + const aMethod = (type) => { + // PRE-CHECK PATTERN: Check typeof BEFORE serialization + // Special handling for null (typeof null === 'object' in JS) + let actualType = typeof expectVal + if (expectVal === null) { + actualType = "null" + } else if (Array.isArray(expectVal)) { + actualType = "array" + } + // Record the type assertion (valid terminal assertion) + inputs.chaiTypeOf(expectVal, type, modifiers, actualType) + return withModifiers(modifiers + ` a ${type}`) + } + // Add .instanceof as a property of the function + const aInstanceOfMethod = function (constructor) { + // CRITICAL: Perform instanceof check HERE in the sandbox before serialization + const actualInstanceCheck = expectVal instanceof constructor + + const objectType = Object.prototype.toString.call(expectVal) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + return withModifiers(modifiers) + } + aMethod.instanceof = aInstanceOfMethod + aMethod.instanceOf = aInstanceOfMethod // Support both lowercase and camelCase + proxy.a = aMethod + + const anMethod = (type) => { + // Record the type assertion (valid terminal assertion) + inputs.chaiTypeOf(expectVal, type, modifiers) + return withModifiers(modifiers + ` an ${type}`) + } + // Add .instanceof as a property of the function + const instanceOfMethod = function (constructor) { + // CRITICAL: Perform instanceof check HERE in the sandbox before serialization + const actualInstanceCheck = expectVal instanceof constructor + + const objectType = Object.prototype.toString.call(expectVal) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + anMethod.instanceof = instanceOfMethod + anMethod.instanceOf = instanceOfMethod // Support both lowercase and camelCase + proxy.an = anMethod + + // Include can be both a method and have properties (for .include.members and .include.all.keys) + const includeMethod = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + includeMethod.members = (members) => { + inputs.chaiIncludeMembers(expectVal, members, modifiers) + return withModifiers(modifiers + ` include members`) + } + // Add .all for .include.all.keys + Object.defineProperty(includeMethod, "all", { + get: () => { + const newModifiers = modifiers + " include all" + const allProxy = {} + allProxy.keys = (...keys) => { + inputs.chaiAllKeys(expectVal, keys, newModifiers) + return withModifiers(newModifiers) + } + return allProxy + }, + }) + // Add .any for .include.any.keys + Object.defineProperty(includeMethod, "any", { + get: () => { + const newModifiers = modifiers + " include any" + const anyProxy = {} + anyProxy.keys = (...keys) => { + inputs.chaiAnyKeys(expectVal, keys, newModifiers) + return withModifiers(newModifiers) + } + return anyProxy + }, + }) + // Add .keys for .include.keys + includeMethod.keys = (...keys) => { + const keysArray = + keys.length === 1 && Array.isArray(keys[0]) ? keys[0] : keys + inputs.chaiIncludeKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + // Add .deep for .include.deep.ordered.members and deep.include() + Object.defineProperty(includeMethod, "deep", { + get: () => { + const newModifiers = modifiers + " include deep" + // Create a function that can be called as deep.include(obj) + const deepIncludeFn = (item) => { + inputs.chaiDeepInclude(expectVal, item, newModifiers) + return withModifiers(newModifiers) + } + // Add .ordered for .include.deep.ordered + Object.defineProperty(deepIncludeFn, "ordered", { + get: () => { + const orderedModifiers = newModifiers + " ordered" + const orderedProxy = {} + orderedProxy.members = (members) => { + inputs.chaiIncludeDeepOrderedMembers( + expectVal, + members, + orderedModifiers + ) + return withModifiers(orderedModifiers) + } + return orderedProxy + }, + }) + return deepIncludeFn + }, + }) + proxy.include = includeMethod + + proxy.includes = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + + // Contain can be both a method and have properties (for .contain.oneOf and .contain.members) + const containMethod = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + containMethod.oneOf = (list) => { + inputs.chaiOneOf(expectVal, list, modifiers + " include") + return withModifiers(modifiers + " include oneOf") + } + containMethod.members = (members) => { + inputs.chaiIncludeMembers(expectVal, members, modifiers) + return withModifiers(modifiers + ` include members`) + } + proxy.contain = containMethod + + proxy.contains = (item) => { + inputs.chaiInclude(expectVal, item, modifiers) + return withModifiers(modifiers + ` include ${JSON.stringify(item)}`) + } + + // .lengthOf can be used both as a method and as a language chain + // As method: expect(arr).to.have.lengthOf(5) + // As chain: expect(arr).to.have.lengthOf.at.least(5) + Object.defineProperty(proxy, "lengthOf", { + get: () => { + // Extract actual length for comparison operations + let actualLength + let actualSize, typeName, serializedContent + + // PRE-CHECK PATTERN: Extract size from Set/Map before serialization + if (expectVal instanceof Set) { + actualSize = expectVal.size + actualLength = actualSize + typeName = "Set" + serializedContent = Array.from(expectVal) + } else if (expectVal instanceof Map) { + actualSize = expectVal.size + actualLength = actualSize + typeName = "Map" + serializedContent = Array.from(expectVal) + } else { + try { + actualLength = + expectVal && typeof expectVal.length !== "undefined" + ? expectVal.length + : expectVal && typeof expectVal.size !== "undefined" + ? expectVal.size + : undefined + } catch (_e) { + actualLength = undefined + } + } + + // Create a callable function that also has chainable properties + const lengthOfFn = (length) => { + inputs.chaiLengthOf( + serializedContent || expectVal, + length, + modifiers, + "lengthOf", + actualSize, + typeName + ) + return withModifiers(modifiers + ` lengthOf ${length}`) + } + + // Add comparison methods for chaining (like .lengthOf.at.least()) + if (actualLength !== undefined) { + lengthOfFn.above = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.below = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.within = (start, end) => { + inputs.chaiWithin(actualLength, start, end, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.greaterThan = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.lessThan = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.gte = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthOfFn.lte = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + // Add .at language chain for .lengthOf.at.least() and .lengthOf.at.most() + Object.defineProperty(lengthOfFn, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + atProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + return atProxy + }, + configurable: true, + enumerable: false, + }) + } + + return lengthOfFn + }, + configurable: true, + enumerable: false, + }) + // .length as getter property for chaining: .length.above(), .length.below(), .length.within() + // Also supports .length(n) as method for exact length + Object.defineProperty(proxy, "length", { + get: () => { + // Extract actual length value + let actualLength + try { + actualLength = expectVal.length + } catch (_e) { + actualLength = undefined + } + + // Create callable function for exact length: .length(n) + const lengthProxy = (length) => { + // PRE-CHECK PATTERN: Extract size from Set/Map before serialization + let actualSize, typeName, serializedContent + if (expectVal instanceof Set) { + actualSize = expectVal.size + typeName = "Set" + serializedContent = Array.from(expectVal) + } else if (expectVal instanceof Map) { + actualSize = expectVal.size + typeName = "Map" + serializedContent = Array.from(expectVal) + } + inputs.chaiLengthOf( + serializedContent || expectVal, + length, + modifiers, + "length", + actualSize, + typeName + ) + // Return proxy wrapping the LENGTH VALUE for chaining (.which, .that, etc.) + return globalThis.__createChaiProxy( + actualLength, + inputs, + modifiers + ` length ${length}` + ) + } + + // Add comparison methods for chaining + lengthProxy.above = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.below = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.within = (start, end) => { + inputs.chaiWithin(actualLength, start, end, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.greaterThan = (n) => { + inputs.chaiAbove(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.lessThan = (n) => { + inputs.chaiBelow(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.gte = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + lengthProxy.lte = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + + // Add .at language chain for .length.at.least() and .length.at.most() + Object.defineProperty(lengthProxy, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => { + inputs.chaiAtLeast(actualLength, n, modifiers) + return withModifiers(modifiers) + } + atProxy.most = (n) => { + inputs.chaiAtMost(actualLength, n, modifiers) + return withModifiers(modifiers) + } + return atProxy + }, + configurable: true, + enumerable: false, + }) + + return lengthProxy + }, + configurable: true, + enumerable: false, + }) + + proxy.property = (prop, val) => { + // PRE-CHECK PATTERN: Special handling for Map/Set size property + // Map and Set serialize as {} losing .size property, so extract it first + if ( + prop === "size" && + (expectVal instanceof Map || expectVal instanceof Set) + ) { + const actualSize = expectVal.size + // Call chaiProperty with actual size value + inputs.chaiProperty( + { size: actualSize }, // Wrap size in object + prop, + val, + modifiers + ) + // For chaining, return proxy wrapping the size value + return globalThis.__createChaiProxy(actualSize, inputs, modifiers) + } + + // PRE-CHECK PATTERN: Check property existence (including inherited) BEFORE serialization + // The 'in' operator checks for both own and inherited properties + let hasProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + hasProperty = prop in expectVal + } + + // When val is provided, assert the property value directly + inputs.chaiProperty(expectVal, prop, val, modifiers, hasProperty) + + // For chaining (.that, .which), we need to return a proxy wrapping the PROPERTY VALUE + // Extract the property value from the object + let propertyValue + try { + propertyValue = expectVal[prop] + } catch (_e) { + propertyValue = undefined + } + + // Return a new proxy wrapping the property value, not the original object + // This allows: .property('a').that.equals(1) to work + return globalThis.__createChaiProxy(propertyValue, inputs, modifiers) + } + proxy.ownProperty = (prop) => { + // PRE-CHECK PATTERN: Check hasOwnProperty BEFORE serialization + // Prototype chain is lost when objects cross sandbox boundary + // Only pass isOwnProperty when value is an object + let isOwnProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + isOwnProperty = Object.prototype.hasOwnProperty.call(expectVal, prop) + } + + inputs.chaiOwnProperty(expectVal, prop, modifiers, isOwnProperty) + + // For chaining, return proxy wrapping the property value + let propertyValue + try { + propertyValue = expectVal[prop] + } catch (_e) { + propertyValue = undefined + } + + return globalThis.__createChaiProxy(propertyValue, inputs, modifiers) + } + proxy.ownPropertyDescriptor = (prop, descriptor) => { + // PRE-CHECK PATTERN: Check property descriptor in sandbox before serialization + let hasDescriptor = false + let actualDescriptor = null + let matchesExpected = false + + try { + actualDescriptor = Object.getOwnPropertyDescriptor(expectVal, prop) + hasDescriptor = actualDescriptor !== undefined + + // If descriptor argument provided, check if it matches + if (hasDescriptor && descriptor !== undefined) { + matchesExpected = true + // Compare each property of the descriptor + for (const key in descriptor) { + if (descriptor[key] !== actualDescriptor[key]) { + matchesExpected = false + break + } + } + } + } catch (_e) { + hasDescriptor = false + } + + inputs.chaiOwnPropertyDescriptor( + expectVal, + prop, + descriptor, + modifiers, + hasDescriptor, + matchesExpected + ) + + // Return a proxy that can chain with .that to access the descriptor + const descriptorProxy = withModifiers(modifiers) + // Delete existing .that property if it exists, then redefine it + delete descriptorProxy.that + Object.defineProperty(descriptorProxy, "that", { + configurable: true, + enumerable: false, + get: () => { + // Return a new Chai proxy wrapping the descriptor itself + return globalThis.__createChaiProxy(actualDescriptor, inputs, " to") + }, + }) + return descriptorProxy + } + + proxy.above = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.gt = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.greaterThan = (n) => { + inputs.chaiAbove(expectVal, n, modifiers) + return withModifiers(modifiers + ` above ${n}`) + } + proxy.greaterThanOrEqual = (n) => { + inputs.chaiAtLeast(expectVal, n, modifiers) + return withModifiers(modifiers + ` at least ${n}`) + } + proxy.gte = (n) => { + inputs.chaiAtLeast(expectVal, n, modifiers) + return withModifiers(modifiers + ` at least ${n}`) + } + + proxy.below = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lt = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lessThan = (n) => { + inputs.chaiBelow(expectVal, n, modifiers) + return withModifiers(modifiers + ` below ${n}`) + } + proxy.lessThanOrEqual = (n) => { + inputs.chaiAtMost(expectVal, n, modifiers) + return withModifiers(modifiers + ` at most ${n}`) + } + proxy.lte = (n) => { + inputs.chaiAtMost(expectVal, n, modifiers) + return withModifiers(modifiers + ` at most ${n}`) + } + + proxy.within = (start, end) => { + inputs.chaiWithin(expectVal, start, end, modifiers) + return withModifiers(modifiers) + } + proxy.closeTo = (expected, delta) => { + inputs.chaiCloseTo(expectVal, expected, delta, modifiers, "closeTo") + return withModifiers(modifiers) + } + proxy.approximately = (expected, delta) => { + inputs.chaiCloseTo( + expectVal, + expected, + delta, + modifiers, + "approximately" + ) + return withModifiers(modifiers) + } + + proxy.keys = (...keys) => { + // Support both keys('a', 'b') and keys(['a', 'b']) + const keysArray = + keys.length === 1 && Array.isArray(keys[0]) ? keys[0] : keys + inputs.chaiKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + proxy.key = (key) => { + // Support both key('a') and key(['a']) + const keysArray = Array.isArray(key) ? key : [key] + inputs.chaiKeys(expectVal, keysArray, modifiers) + return withModifiers(modifiers) + } + + proxy.match = (pattern) => { + // PRE-CHECK PATTERN: Extract RegExp source and flags before serialization + let regexSource, regexFlags + if (pattern instanceof RegExp) { + regexSource = pattern.source + regexFlags = pattern.flags + } + return inputs.chaiMatch( + expectVal, + pattern, + modifiers, + regexSource, + regexFlags + ) + } + proxy.matches = (pattern) => { + // PRE-CHECK PATTERN: Extract RegExp source and flags before serialization + let regexSource, regexFlags + if (pattern instanceof RegExp) { + regexSource = pattern.source + regexFlags = pattern.flags + } + return inputs.chaiMatch( + expectVal, + pattern, + modifiers, + regexSource, + regexFlags + ) + } + proxy.string = (substring) => + inputs.chaiString(expectVal, substring, modifiers) + + proxy.members = (members) => + inputs.chaiMembers(expectVal, members, modifiers) + proxy.oneOf = (list) => inputs.chaiOneOf(expectVal, list, modifiers) + + proxy.throw = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does: + // .throw() - no params + // .throw(ErrorType) - constructor function + // .throw('message') - string message + // .throw(/pattern/) - regex pattern + // .throw(ErrorType, 'message') - constructor + message + // .throw(ErrorType, /pattern/) - constructor + regex + + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + proxy.throws = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + proxy.Throw = (errorLike, errMsgMatcher) => { + // PRE-CHECK PATTERN: Execute function in sandbox, pass results + let threwError = false + let errorTypeName = null + let errorMessage = null + + if (typeof expectVal === "function") { + try { + expectVal() + } catch (e) { + threwError = true + errorTypeName = e.constructor?.name || "Error" + errorMessage = e.message || String(e) + } + } + + // Handle parameter interpretation like Chai does + let actualErrorLike = errorLike + let actualErrMsgMatcher = errMsgMatcher + + // If first param is string or regex but no second param, + // treat first param as message matcher, not error type + if (errorLike !== undefined && errMsgMatcher === undefined) { + if (typeof errorLike === "string" || errorLike instanceof RegExp) { + actualErrorLike = undefined + actualErrMsgMatcher = errorLike + } + } + + // Get error type name for matching + let expectedTypeName = null + if (actualErrorLike && typeof actualErrorLike === "function") { + expectedTypeName = actualErrorLike.name + } + + // Convert RegExp to serializable form or pass string message + let regexSource, regexFlags + let isRegexMatcher = false + if (actualErrMsgMatcher instanceof RegExp) { + regexSource = actualErrMsgMatcher.source + regexFlags = actualErrMsgMatcher.flags + isRegexMatcher = true + } + + return inputs.chaiThrow( + expectVal, + threwError, + errorTypeName, + errorMessage, + expectedTypeName, + actualErrMsgMatcher, + regexSource, + regexFlags, + isRegexMatcher, + modifiers + ) + } + + // PRE-CHECK PATTERN: Check method existence BEFORE serialization + proxy.respondTo = (method) => { + // Check if method exists on value or its prototype/constructor + // When .itself modifier is present, check for static methods on the constructor itself + const hasItselfModifier = String(modifiers).includes("itself") + let hasMethod = false + + if (hasItselfModifier) { + // .itself.respondTo() checks for static methods directly on the constructor/class + hasMethod = typeof expectVal?.[method] === "function" + } else { + // Regular .respondTo() checks instance methods (on value or prototype) + hasMethod = + typeof expectVal?.[method] === "function" || + typeof expectVal?.prototype?.[method] === "function" + } + + return inputs.chaiRespondTo(expectVal, method, modifiers, hasMethod) + } + proxy.respondsTo = (method) => { + // Check if method exists on value or its prototype/constructor + // When .itself modifier is present, check for static methods on the constructor itself + const hasItselfModifier = String(modifiers).includes("itself") + let hasMethod = false + + if (hasItselfModifier) { + // .itself.respondTo() checks for static methods directly on the constructor/class + hasMethod = typeof expectVal?.[method] === "function" + } else { + // Regular .respondTo() checks instance methods (on value or prototype) + hasMethod = + typeof expectVal?.[method] === "function" || + typeof expectVal?.prototype?.[method] === "function" + } + + return inputs.chaiRespondTo(expectVal, method, modifiers, hasMethod) + } + + proxy.satisfy = (matcher) => { + // PRE-CHECK PATTERN: Execute matcher function in sandbox, pass result + let satisfyResult = false + let matcherString = String(matcher) + + if (typeof matcher === "function") { + try { + satisfyResult = Boolean(matcher(expectVal)) + } catch (_e) { + satisfyResult = false + } + } + + return inputs.chaiSatisfy( + expectVal, + satisfyResult, + matcherString, + modifiers + ) + } + proxy.satisfies = (matcher) => { + // PRE-CHECK PATTERN: Execute matcher function in sandbox, pass result + let satisfyResult = false + let matcherString = String(matcher) + + if (typeof matcher === "function") { + try { + satisfyResult = Boolean(matcher(expectVal)) + } catch (_e) { + satisfyResult = false + } + } + + return inputs.chaiSatisfy( + expectVal, + satisfyResult, + matcherString, + modifiers + ) + } + + // PRE-CHECK PATTERN: change/increase/decrease assertions + proxy.change = (obj, prop) => { + // Support both patterns: + // 1. change(getter) - getter function pattern + // 2. change(obj, prop) - object + property pattern + let initialValue + let finalValue + let changed = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + changed = initialValue !== finalValue + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + } + } catch (_e) { + changed = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiChange(propName, modifiers, changed, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiChangeBy( + propName, + modifiers, + changed, + delta, + expectedDelta + ) + }, + } + } + + proxy.increase = (obj, prop) => { + // Support both patterns: + // 1. increase(getter) - getter function pattern + // 2. increase(obj, prop) - object + property pattern + let initialValue + let finalValue + let increased = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + increased = delta > 0 + } + } catch (_e) { + increased = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiIncrease(propName, modifiers, increased, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiIncreaseBy( + propName, + modifiers, + increased, + delta, + expectedDelta + ) + }, + } + } + + proxy.decrease = (obj, prop) => { + // Support both patterns: + // 1. decrease(getter) - getter function pattern + // 2. decrease(obj, prop) - object + property pattern + let initialValue + let finalValue + let decreased = false + let delta = 0 + let propName = prop + + try { + // Pattern 1: Single argument that is a function (getter) + if (typeof obj === "function" && prop === undefined) { + initialValue = obj() + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj() + propName = "value" // Use generic name for getter pattern + } + // Pattern 2: Object + property name + else { + initialValue = obj[prop] + if (typeof expectVal === "function") { + expectVal() + } + finalValue = obj[prop] + } + + if ( + typeof initialValue === "number" && + typeof finalValue === "number" + ) { + delta = finalValue - initialValue + decreased = delta < 0 + } + } catch (_e) { + decreased = false + } + + // Call the assertion (adds result to testStack) + inputs.chaiDecrease(propName, modifiers, decreased, delta) + + // Return a proxy with .by() method for chaining + return { + by: (expectedDelta) => { + inputs.chaiDecreaseBy( + propName, + modifiers, + decreased, + delta, + expectedDelta + ) + }, + } + } + + // PRE-CHECK PATTERN: Check instanceof BEFORE serialization (for custom classes) + proxy.instanceof = function (constructor) { + // Debug logging + if (typeof inputs.chaiInstanceOf !== "function") { + throw new Error( + "inputs.chaiInstanceOf is not a function: " + + typeof inputs.chaiInstanceOf + ) + } + + // CRITICAL: Perform instanceof check HERE in the sandbox before serialization + // This is essential for custom user-defined classes to work correctly + const actualInstanceCheck = expectVal instanceof constructor + + // Get the actual type using Object.prototype.toString for built-ins + const objectType = Object.prototype.toString.call(expectVal) + // Get constructor name before serialization (constructors don't cross boundary) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + proxy.instanceOf = function (constructor) { + // CRITICAL: Perform instanceof check HERE in the sandbox before serialization + const actualInstanceCheck = expectVal instanceof constructor + + // Get the actual type using Object.prototype.toString for built-ins + const objectType = Object.prototype.toString.call(expectVal) + // Get constructor name before serialization (constructors don't cross boundary) + let constructorName = "Unknown" + try { + if (constructor && typeof constructor.name === "string") { + constructorName = constructor.name + } else { + constructorName = String(constructor) + } + } catch (_e) { + constructorName = String(constructor) + } + + // PRE-FORMAT: Create display string for Set/Map before serialization + let displayValue = null + if (expectVal instanceof Set) { + const values = Array.from(expectVal).slice(0, 10) + displayValue = + values.length > 0 ? `new Set([${values.join(", ")}])` : "new Set()" + } else if (expectVal instanceof Map) { + const entries = Array.from(expectVal.entries()).slice(0, 3) + if (entries.length > 0) { + const formatted = entries.map(([k, v]) => `['${k}', ${v}]`) + displayValue = `new Map([${formatted.join(", ")}])` + } else { + displayValue = "new Map()" + } + } + + return inputs.chaiInstanceOf( + expectVal, + constructorName, + modifiers, + objectType, + displayValue, + actualInstanceCheck // Pass the pre-checked result! + ) + } + + // Assertion getters + Object.defineProperty(proxy, "ok", { + get: () => { + inputs.chaiOk(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "true", { + get: () => { + inputs.chaiTrue(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "false", { + get: () => { + inputs.chaiFalse(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "null", { + get: () => { + inputs.chaiNull(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "undefined", { + get: () => { + inputs.chaiUndefined(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "NaN", { + get: () => { + inputs.chaiNaN(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "exist", { + get: () => { + inputs.chaiExist(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "empty", { + get: () => { + // PRE-CHECK PATTERN: Pass type info for Set/Map before serialization + let typeName = null + let actualSize = null + if (expectVal instanceof Set) { + typeName = "Set" + actualSize = expectVal.size + } else if (expectVal instanceof Map) { + typeName = "Map" + actualSize = expectVal.size + } + inputs.chaiEmpty(expectVal, modifiers, typeName, actualSize) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "finite", { + get: () => { + inputs.chaiFinite(expectVal, modifiers) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "arguments", { + get: () => { + // PRE-CHECK PATTERN: Check if value is arguments object before serialization + const isArguments = + Object.prototype.toString.call(expectVal) === "[object Arguments]" + inputs.chaiArguments(expectVal, modifiers + " arguments", isArguments) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "Arguments", { + get: () => { + // PRE-CHECK PATTERN: Check if value is arguments object before serialization + const isArguments = + Object.prototype.toString.call(expectVal) === "[object Arguments]" + inputs.chaiArguments(expectVal, modifiers + " Arguments", isArguments) + return withModifiers(modifiers) + }, + }) + // PRE-CHECK PATTERN: Check object state in sandbox BEFORE serialization + Object.defineProperty(proxy, "extensible", { + get: () => { + // Check extensibility in sandbox context before value crosses boundary + const isExtensible = Object.isExtensible(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiExtensible(expectVal, modifiers, isExtensible) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "sealed", { + get: () => { + // Check sealed state in sandbox context before value crosses boundary + const isSealed = Object.isSealed(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiSealed(expectVal, modifiers, isSealed) + return withModifiers(modifiers) + }, + }) + Object.defineProperty(proxy, "frozen", { + get: () => { + // Check frozen state in sandbox context before value crosses boundary + const isFrozen = Object.isFrozen(expectVal) + // Pass both the value and the pre-checked boolean + inputs.chaiFrozen(expectVal, modifiers, isFrozen) + return withModifiers(modifiers) + }, + }) + + // Language chains - return new proxy + Object.defineProperty(proxy, "to", { + get: () => withModifiers(" to"), + }) + Object.defineProperty(proxy, "be", { + get: () => withModifiers(modifiers + " be"), + }) + Object.defineProperty(proxy, "been", { + get: () => withModifiers(modifiers + " been"), + }) + Object.defineProperty(proxy, "is", { + get: () => withModifiers(modifiers + " is"), + }) + Object.defineProperty(proxy, "that", { + configurable: true, // Allow ownPropertyDescriptor to override this + get: () => withModifiers(modifiers + " that"), + }) + Object.defineProperty(proxy, "which", { + get: () => withModifiers(modifiers + " which"), + }) + Object.defineProperty(proxy, "and", { + get: () => withModifiers(modifiers + " and"), + }) + Object.defineProperty(proxy, "has", { + get: () => withModifiers(modifiers + " has"), + }) + Object.defineProperty(proxy, "have", { + get: () => { + const haveProxy = withModifiers(modifiers + " have") + // Add ownPropertyDescriptor method to have proxy + haveProxy.ownPropertyDescriptor = (prop, descriptor) => { + // PRE-CHECK PATTERN: Check property descriptor in sandbox before serialization + let hasDescriptor = false + let actualDescriptor = null + let matchesExpected = false + + try { + actualDescriptor = Object.getOwnPropertyDescriptor( + expectVal, + prop + ) + hasDescriptor = actualDescriptor !== undefined + + // If descriptor argument provided, check if it matches + if (hasDescriptor && descriptor !== undefined) { + matchesExpected = true + // Compare each property of the descriptor + for (const key in descriptor) { + if (descriptor[key] !== actualDescriptor[key]) { + matchesExpected = false + break + } + } + } + } catch (_e) { + hasDescriptor = false + } + + inputs.chaiOwnPropertyDescriptor( + expectVal, + prop, + descriptor, + modifiers + " have", + hasDescriptor, + matchesExpected + ) + + // Return a proxy that can chain with .that to access the descriptor + const descriptorProxy = withModifiers(modifiers + " have") + // Delete existing .that property if it exists, then redefine it + delete descriptorProxy.that + Object.defineProperty(descriptorProxy, "that", { + configurable: true, + enumerable: false, + get: () => { + // Return a new Chai proxy wrapping the descriptor itself + return globalThis.__createChaiProxy( + actualDescriptor, + inputs, + " to" + ) + }, + }) + return descriptorProxy + } + return haveProxy + }, + }) + Object.defineProperty(proxy, "with", { + get: () => withModifiers(modifiers + " with"), + }) + Object.defineProperty(proxy, "at", { + get: () => { + const atProxy = withModifiers(modifiers + " at") + atProxy.least = (n) => inputs.chaiAtLeast(expectVal, n, modifiers) + atProxy.most = (n) => inputs.chaiAtMost(expectVal, n, modifiers) + return atProxy + }, + }) + Object.defineProperty(proxy, "of", { + get: () => withModifiers(modifiers + " of"), + }) + Object.defineProperty(proxy, "same", { + get: () => withModifiers(modifiers + " same"), + }) + Object.defineProperty(proxy, "but", { + get: () => withModifiers(modifiers + " but"), + }) + Object.defineProperty(proxy, "does", { + get: () => withModifiers(modifiers + " does"), + }) + Object.defineProperty(proxy, "itself", { + get: () => withModifiers(modifiers + " itself"), + }) + + // Modifiers - return new proxy with updated modifiers + Object.defineProperty(proxy, "not", { + get: () => withModifiers(" to not"), + }) + Object.defineProperty(proxy, "deep", { + get: () => { + const newModifiers = modifiers + " deep" + const deepProxy = withModifiers(newModifiers) + deepProxy.property = (prop, val) => { + if (val !== undefined) { + inputs.chaiDeepOwnProperty(expectVal, prop, val, newModifiers) + } else { + inputs.chaiProperty(expectVal, prop, val, newModifiers) + } + } + deepProxy.members = (members) => + inputs.chaiDeepMembers(expectVal, members, newModifiers) + deepProxy.include = (item) => { + inputs.chaiDeepInclude(expectVal, item, newModifiers) + return withModifiers(newModifiers) + } + return deepProxy + }, + }) + Object.defineProperty(proxy, "own", { + get: () => { + const newModifiers = modifiers + " own" + const ownProxy = withModifiers(newModifiers) + ownProxy.property = (prop, val) => { + if (val !== undefined) { + inputs.chaiDeepOwnProperty(expectVal, prop, val, newModifiers) + } else { + // PRE-CHECK PATTERN: Check hasOwnProperty BEFORE serialization + // Only pass isOwnProperty when value is an object + let isOwnProperty = undefined + if (expectVal !== null && typeof expectVal === "object") { + isOwnProperty = Object.prototype.hasOwnProperty.call( + expectVal, + prop + ) + } + inputs.chaiOwnProperty( + expectVal, + prop, + newModifiers, + isOwnProperty + ) + } + } + return ownProxy + }, + }) + Object.defineProperty(proxy, "nested", { + get: () => { + const newModifiers = modifiers + " nested" + const nestedProxy = withModifiers(newModifiers) + nestedProxy.property = (prop, val) => + inputs.chaiNestedProperty(expectVal, prop, val, newModifiers) + // Add .include() for .nested.include() pattern + nestedProxy.include = (obj) => { + inputs.chaiNestedInclude(expectVal, obj, newModifiers) + return withModifiers(newModifiers) + } + return nestedProxy + }, + }) + Object.defineProperty(proxy, "ordered", { + get: () => { + const newModifiers = modifiers + " ordered" + const orderedProxy = withModifiers(newModifiers) + orderedProxy.members = (members) => + inputs.chaiOrderedMembers(expectVal, members, newModifiers) + return orderedProxy + }, + }) + Object.defineProperty(proxy, "all", { + get: () => { + const newModifiers = modifiers + " all" + const allProxy = withModifiers(newModifiers) + allProxy.keys = (...keys) => + inputs.chaiAllKeys(expectVal, keys, newModifiers) + return allProxy + }, + }) + Object.defineProperty(proxy, "any", { + get: () => { + const newModifiers = modifiers + " any" + const anyProxy = withModifiers(newModifiers) + anyProxy.keys = (...keys) => + inputs.chaiAnyKeys(expectVal, keys, newModifiers) + return anyProxy + }, + }) + // .include is defined above as a method with .members property + + // Postman custom Chai assertions (jsonSchema, charset, cookie, jsonPath) + // These are Postman-specific extensions that work with pm.expect() + + proxy.jsonSchema = (schema) => { + // Basic JSON Schema validation (supports common keywords) + const validateSchema = (data, schema) => { + // Type validation + if (schema.type) { + const actualType = Array.isArray(data) + ? "array" + : data === null + ? "null" + : typeof data + if (actualType !== schema.type) { + return `Expected type ${schema.type}, got ${actualType}` + } + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const prop of schema.required) { + if (!(prop in data)) { + return `Required property '${prop}' is missing` + } + } + } + + // Properties validation + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (key in data) { + const error = validateSchema(data[key], propSchema) + if (error) return `Property '${key}': ${error}` + } + } + } + + // Enum validation + if (schema.enum) { + if (!schema.enum.includes(data)) { + return `Value must be one of: ${schema.enum.join(", ")}` + } + } + + // Number validation + if (typeof data === "number") { + if (schema.minimum !== undefined && data < schema.minimum) { + return `Value ${data} is below minimum ${schema.minimum}` + } + if (schema.maximum !== undefined && data > schema.maximum) { + return `Value ${data} exceeds maximum ${schema.maximum}` + } + } + + // String validation + if (typeof data === "string") { + if ( + schema.minLength !== undefined && + data.length < schema.minLength + ) { + return `String length ${data.length} is below minimum ${schema.minLength}` + } + if ( + schema.maxLength !== undefined && + data.length > schema.maxLength + ) { + return `String length ${data.length} exceeds maximum ${schema.maxLength}` + } + if (schema.pattern) { + const regex = new RegExp(schema.pattern) + if (!regex.test(data)) { + return `String does not match pattern ${schema.pattern}` + } + } + } + + // Array validation + if (Array.isArray(data)) { + if ( + schema.minItems !== undefined && + data.length < schema.minItems + ) { + return `Array length ${data.length} is below minimum ${schema.minItems}` + } + if ( + schema.maxItems !== undefined && + data.length > schema.maxItems + ) { + return `Array length ${data.length} exceeds maximum ${schema.maxItems}` + } + if (schema.items) { + for (let i = 0; i < data.length; i++) { + const error = validateSchema(data[i], schema.items) + if (error) return `Item [${i}]: ${error}` + } + } + } + + return null // Validation passed + } + + const error = validateSchema(expectVal, schema) + if (error) { + throw new Error(`JSON Schema validation failed: ${error}`) + } + return withModifiers(modifiers) + } + + proxy.charset = (expectedCharset) => { + // expectVal should be a string (typically Content-Type header) + if (typeof expectVal !== "string") { + throw new Error( + "charset() expects a string value (typically Content-Type header)" + ) + } + + const lowerExpected = expectedCharset.toLowerCase() + const lowerActual = expectVal.toLowerCase() + + if (!lowerActual.includes(lowerExpected)) { + throw new Error( + `Expected charset "${expectedCharset}" not found in "${expectVal}"` + ) + } + return withModifiers(modifiers) + } + + proxy.cookie = (cookieName, cookieValue) => { + // This works when expectVal is pm.response or similar + // For the Chai extension, we need to check if cookies exist + // This is typically used as: pm.expect(pm.response).to.have.cookie('name') + // But since we're on a Chai proxy, we assume expectVal is the response object + + if (!expectVal || typeof expectVal !== "object" || !expectVal.cookies) { + throw new Error( + "cookie() assertion requires a response object with cookies" + ) + } + + const hasCookie = expectVal.cookies.has(cookieName) + if (!hasCookie) { + throw new Error(`Cookie "${cookieName}" not found in response`) + } + + if (cookieValue !== undefined) { + const actualValue = expectVal.cookies.get(cookieName) + if (actualValue !== cookieValue) { + throw new Error( + `Cookie "${cookieName}" has value "${actualValue}", expected "${cookieValue}"` + ) + } + } + return withModifiers(modifiers) + } + + // Legacy pw.expect API compatibility methods + // These provide backward compatibility for tests using the old pw.expect API + // Legacy API compatibility - delegate to existing legacy functions + // These handle errors properly by returning test results instead of throwing + // Check modifiers to determine if negation is needed + const isNegated = modifiers.includes("not") + + proxy.toBe = (expectedVal) => + isNegated + ? inputs.expectNotToBe(expectVal, expectedVal) + : inputs.expectToBe(expectVal, expectedVal) + + proxy.toBeLevel2xx = () => + isNegated + ? inputs.expectNotToBeLevel2xx(expectVal) + : inputs.expectToBeLevel2xx(expectVal) + + proxy.toBeLevel3xx = () => + isNegated + ? inputs.expectNotToBeLevel3xx(expectVal) + : inputs.expectToBeLevel3xx(expectVal) + + proxy.toBeLevel4xx = () => + isNegated + ? inputs.expectNotToBeLevel4xx(expectVal) + : inputs.expectToBeLevel4xx(expectVal) + + proxy.toBeLevel5xx = () => + isNegated + ? inputs.expectNotToBeLevel5xx(expectVal) + : inputs.expectToBeLevel5xx(expectVal) + + proxy.toBeType = (expectedType) => { + const isDateInstance = expectVal instanceof Date + return isNegated + ? inputs.expectNotToBeType(expectVal, expectedType, isDateInstance) + : inputs.expectToBeType(expectVal, expectedType, isDateInstance) + } + + proxy.toHaveLength = (expectedLength) => + isNegated + ? inputs.expectNotToHaveLength(expectVal, expectedLength) + : inputs.expectToHaveLength(expectVal, expectedLength) + + proxy.toInclude = (needle) => + isNegated + ? inputs.expectNotToInclude(expectVal, needle) + : inputs.expectToInclude(expectVal, needle) + + return proxy + } + } + const toJSON = (input) => { if (input == null) return null @@ -97,6 +1983,131 @@ }) }) + // Add utility methods to hoppResponse + Object.defineProperty(hoppResponse, "text", { + value: () => toText(body), + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "json", { + value: () => toJSON(body), + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "reason", { + value: () => { + // Return HTTP reason phrase without status code + // statusText might be "200 OK" or just "OK" + const text = statusText || "" + // If it starts with a number (status code), extract just the reason + const match = text.match(/^\d+\s+(.+)$/) + return match ? match[1] : text + }, + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "dataURI", { + value: () => { + // Convert response to data URI format + try { + const bytes = toBytes(body) + const contentTypeHeader = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const mimeType = contentTypeHeader + ? contentTypeHeader.value.split(";")[0].trim() + : "application/octet-stream" + + // Convert bytes to base64 + let base64 = "" + if (bytes && typeof bytes === "object") { + // Handle both array-like and object representations + const byteArray = Array.isArray(bytes) + ? bytes + : Object.keys(bytes) + .filter((k) => !isNaN(k)) + .map((k) => bytes[k]) + + // Convert to binary string + let binary = "" + for (let i = 0; i < byteArray.length; i++) { + binary += String.fromCharCode(byteArray[i]) + } + + // Convert to base64 using btoa if available + if (typeof btoa !== "undefined") { + base64 = btoa(binary) + } else { + // Fallback: manual base64 encoding + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + for (let i = 0; i < binary.length; i += 3) { + const b1 = binary.charCodeAt(i) & 0xff + const b2 = i + 1 < binary.length ? binary.charCodeAt(i + 1) : 0 + const b3 = i + 2 < binary.length ? binary.charCodeAt(i + 2) : 0 + + const enc1 = b1 >> 2 + const enc2 = ((b1 & 3) << 4) | (b2 >> 4) + const enc3 = ((b2 & 15) << 2) | (b3 >> 6) + const enc4 = b3 & 63 + + base64 += chars[enc1] + chars[enc2] + base64 += i + 1 < binary.length ? chars[enc3] : "=" + base64 += i + 2 < binary.length ? chars[enc4] : "=" + } + } + } + + return `data:${mimeType};base64,${base64}` + } catch (_e) { + // Fallback: return text as data URI + try { + const text = toText(body) + return `data:text/plain;charset=utf-8,${encodeURIComponent(text)}` + } catch { + return "data:," + } + } + }, + writable: false, + enumerable: true, + configurable: false, + }) + + Object.defineProperty(hoppResponse, "jsonp", { + value: (callbackName = "callback") => { + // Parse JSONP response by extracting JSON from callback wrapper + try { + const text = toText(body) + + // Match pattern: callbackName({...}) + const regex = new RegExp( + `^\\s*${callbackName}\\s*\\((.*)\\)\\s*;?\\s*$`, + "s" + ) + const match = text.match(regex) + + if (match && match[1]) { + return JSON.parse(match[1]) + } + + // If no callback wrapper, try parsing as JSON directly + return JSON.parse(text) + } catch (e) { + throw new Error(`Failed to parse JSONP response: ${e.message}`) + } + }, + writable: false, + enumerable: true, + configurable: false, + }) + // Freeze the entire response object Object.freeze(hoppResponse) @@ -109,6 +2120,7 @@ resolve: (key) => inputs.envResolve(key), }, expect: (expectVal) => { + // Legacy expectation system only (no Chai for backward compatibility) const isDateInstance = expectVal instanceof Date const expectation = { @@ -183,42 +2195,113 @@ // Freeze the entire requestProps object for additional protection Object.freeze(requestProps) + // Special markers for undefined and null values to preserve them across sandbox boundary + // NOTE: These values MUST match constants/sandbox-markers.ts + // (Cannot import directly as this runs in QuickJS sandbox) + const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" + const NULL_MARKER = "__HOPPSCOTCH_NULL__" + + // Helper function to convert markers back to their original values + const convertMarkerToValue = (value) => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value + } + + // Helper function for PM namespace setters with marker handling + const pmSetWithMarkers = (key, value, source) => { + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source }) + } else { + return inputs.pmEnvSetAny(key, value, { source }) + } + } + + // Helper function for PM namespace getters with tracking + const pmGetWithTracking = (getRawFn, trackingSet, key) => { + const value = getRawFn(key) + // Return undefined for missing keys, preserve null for explicit null values + if (value === null && (!trackingSet || !trackingSet.has(key))) { + return undefined + } + return value + } + globalThis.hopp = { env: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "all" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "all" }), + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "all", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "all", + }) + return convertMarkerToValue(value) + }, set: (key, value) => inputs.envSet(key, value), delete: (key) => inputs.envUnset(key), reset: (key) => inputs.envReset(key), - getInitialRaw: (key) => inputs.envGetInitialRaw(key), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key) + return convertMarkerToValue(value) + }, setInitial: (key, value) => inputs.envSetInitial(key, value), active: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "active" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "active" }), + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "active", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "active", + }) + return convertMarkerToValue(value) + }, set: (key, value) => inputs.envSet(key, value, { source: "active" }), delete: (key) => inputs.envUnset(key, { source: "active" }), reset: (key) => inputs.envReset(key, { source: "active" }), - getInitialRaw: (key) => - inputs.envGetInitialRaw(key, { source: "active" }), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key, { source: "active" }) + return convertMarkerToValue(value) + }, setInitial: (key, value) => inputs.envSetInitial(key, value, { source: "active" }), }, global: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "global" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "global" }), + get: (key) => { + const value = inputs.envGetResolve(key, { + fallbackToNull: true, + source: "global", + }) + return convertMarkerToValue(value) + }, + getRaw: (key) => { + const value = inputs.envGet(key, { + fallbackToNull: true, + source: "global", + }) + return convertMarkerToValue(value) + }, set: (key, value) => inputs.envSet(key, value, { source: "global" }), delete: (key) => inputs.envUnset(key, { source: "global" }), reset: (key) => inputs.envReset(key, { source: "global" }), - getInitialRaw: (key) => - inputs.envGetInitialRaw(key, { source: "global" }), + getInitialRaw: (key) => { + const value = inputs.envGetInitialRaw(key, { source: "global" }) + return convertMarkerToValue(value) + }, setInitial: (key, value) => inputs.envSetInitial(key, value, { source: "global" }), }, @@ -232,39 +2315,69 @@ delete: (domain, name) => inputs.cookieDelete(domain, name), clear: (domain) => inputs.cookieClear(domain), }, - expect: (expectVal) => { - const isDateInstance = expectVal instanceof Date + expect: Object.assign( + (expectVal) => { + // Use Chai if available + if (inputs.chaiEqual) { + return globalThis.__createChaiProxy(expectVal, inputs) + } - const expectation = { - toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal), - toBeLevel2xx: () => inputs.expectToBeLevel2xx(expectVal), - toBeLevel3xx: () => inputs.expectToBeLevel3xx(expectVal), - toBeLevel4xx: () => inputs.expectToBeLevel4xx(expectVal), - toBeLevel5xx: () => inputs.expectToBeLevel5xx(expectVal), - toBeType: (expectedType) => - inputs.expectToBeType(expectVal, expectedType, isDateInstance), - toHaveLength: (expectedLength) => - inputs.expectToHaveLength(expectVal, expectedLength), - toInclude: (needle) => inputs.expectToInclude(expectVal, needle), - } + // Fallback to legacy expectation system + const isDateInstance = expectVal instanceof Date - Object.defineProperty(expectation, "not", { - get: () => ({ - toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), - toBeLevel2xx: () => inputs.expectNotToBeLevel2xx(expectVal), - toBeLevel3xx: () => inputs.expectNotToBeLevel3xx(expectVal), - toBeLevel4xx: () => inputs.expectNotToBeLevel4xx(expectVal), - toBeLevel5xx: () => inputs.expectNotToBeLevel5xx(expectVal), + const expectation = { + toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectToBeLevel5xx(expectVal), toBeType: (expectedType) => - inputs.expectNotToBeType(expectVal, expectedType, isDateInstance), + inputs.expectToBeType(expectVal, expectedType, isDateInstance), toHaveLength: (expectedLength) => - inputs.expectNotToHaveLength(expectVal, expectedLength), - toInclude: (needle) => inputs.expectNotToInclude(expectVal, needle), - }), - }) + inputs.expectToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectToInclude(expectVal, needle), + } - return expectation - }, + Object.defineProperty(expectation, "not", { + get: () => ({ + toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal), + toBeLevel2xx: () => inputs.expectNotToBeLevel2xx(expectVal), + toBeLevel3xx: () => inputs.expectNotToBeLevel3xx(expectVal), + toBeLevel4xx: () => inputs.expectNotToBeLevel4xx(expectVal), + toBeLevel5xx: () => inputs.expectNotToBeLevel5xx(expectVal), + toBeType: (expectedType) => + inputs.expectNotToBeType(expectVal, expectedType, isDateInstance), + toHaveLength: (expectedLength) => + inputs.expectNotToHaveLength(expectVal, expectedLength), + toInclude: (needle) => inputs.expectNotToInclude(expectVal, needle), + }), + }) + + return expectation + }, + { + // expect.fail() - Chai compatibility + // Supports multiple signatures: + // expect.fail() + // expect.fail(message) + // expect.fail(actual, expected) + // expect.fail(actual, expected, message) + // expect.fail(actual, expected, message, operator) + fail: (actual, expected, message, operator) => { + if (inputs.chaiFail) { + inputs.chaiFail(actual, expected, message, operator) + } else { + // Fallback: throw an error with the message + const errorMessage = + message || + (actual !== undefined && expected !== undefined + ? `expected ${actual} to ${operator || "equal"} ${expected}` + : "expect.fail()") + throw new Error(errorMessage) + } + }, + } + ), test: (descriptor, testFn) => { inputs.preTest(descriptor) testFn() @@ -273,37 +2386,222 @@ response: hoppResponse, } + // Initialize tracking Sets for PM namespace from incoming envs + // This allows toObject() to work even when variables were set in previous scripts + if (!globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys = new Set() + } + if (!globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys = new Set() + } + + // Populate tracking Sets from inputs.envs if available + // This allows toObject() to work even when variables were set in previous scripts + if (inputs && inputs.envs) { + // Track all selected/active environment variables + if (inputs.envs.selected && Array.isArray(inputs.envs.selected)) { + inputs.envs.selected.forEach((envVar) => { + if (envVar && envVar.key && envVar.currentValue !== undefined) { + globalThis.__pmEnvKeys.add(envVar.key) + } + }) + } + // Track all global variables + if (inputs.envs.global && Array.isArray(inputs.envs.global)) { + inputs.envs.global.forEach((envVar) => { + if (envVar && envVar.key && envVar.currentValue !== undefined) { + globalThis.__pmGlobalKeys.add(envVar.key) + } + }) + } + } + // PM Namespace - Postman Compatibility Layer globalThis.pm = { environment: { - get: (key) => globalThis.hopp.env.active.get(key), - set: (key, value) => globalThis.hopp.env.active.set(key, value), - unset: (key) => globalThis.hopp.env.active.delete(key), + name: "active", + get: (key) => { + return pmGetWithTracking( + globalThis.hopp.env.active.getRaw, + globalThis.__pmEnvKeys, + key + ) + }, + set: (key, value) => { + // Track the key for clear() and toObject() + if (!globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys = new Set() + } + globalThis.__pmEnvKeys.add(key) + return pmSetWithMarkers(key, value, "active") + }, + unset: (key) => { + // Remove from tracking when unset + if (globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys.delete(key) + } + return globalThis.hopp.env.active.delete(key) + }, has: (key) => globalThis.hopp.env.active.get(key) !== null, clear: () => { - throw new Error("pm.environment.clear() not yet implemented") + // Clear ALL environment variables (not just tracked ones) + // Get all keys from the inputs array + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + globalThis.hopp.env.active.delete(envVar.key) + } + }) + } + } + + // Also clear any tracked keys + if (globalThis.__pmEnvKeys) { + globalThis.__pmEnvKeys.forEach((key) => { + globalThis.hopp.env.active.delete(key) + }) + globalThis.__pmEnvKeys.clear() + } }, toObject: () => { - throw new Error("pm.environment.toObject() not yet implemented") + // Return all environment variables as an object + // Read from getAllSelectedEnvs but verify each key still exists in the Map + // (to respect deletions made via unset() or clear()) + const result = {} + + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + // Check if this key still exists in the active environment + // (it might have been deleted via unset() or clear()) + const currentValue = globalThis.hopp.env.active.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, + replaceIn: (template) => { + // Replace {{varName}} with actual values + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { + const value = globalThis.hopp.env.active.get(varName.trim()) + return value !== null ? value : match + }) }, }, globals: { - get: (key) => globalThis.hopp.env.global.get(key), - set: (key, value) => globalThis.hopp.env.global.set(key, value), - unset: (key) => globalThis.hopp.env.global.delete(key), + get: (key) => { + return pmGetWithTracking( + globalThis.hopp.env.global.getRaw, + globalThis.__pmGlobalKeys, + key + ) + }, + set: (key, value) => { + // Track the key for clear() and toObject() + if (!globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys = new Set() + } + globalThis.__pmGlobalKeys.add(key) + return pmSetWithMarkers(key, value, "global") + }, + unset: (key) => { + // Remove from tracking when unset + if (globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys.delete(key) + } + return globalThis.hopp.env.global.delete(key) + }, has: (key) => globalThis.hopp.env.global.get(key) !== null, clear: () => { - throw new Error("pm.globals.clear() not yet implemented") + // Clear ALL global variables (not just tracked ones) + // Get all keys from the inputs array + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + globalThis.hopp.env.global.delete(envVar.key) + } + }) + } + } + + // Also clear any tracked keys + if (globalThis.__pmGlobalKeys) { + globalThis.__pmGlobalKeys.forEach((key) => { + globalThis.hopp.env.global.delete(key) + }) + globalThis.__pmGlobalKeys.clear() + } }, toObject: () => { - throw new Error("pm.globals.toObject() not yet implemented") + // Return all global variables as an object + // Read from getAllGlobalEnvs but verify each key still exists in the Map + // (to respect deletions made via unset() or clear()) + const result = {} + + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + // Check if this key still exists in the global environment + const currentValue = globalThis.hopp.env.global.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, + replaceIn: (template) => { + // Replace {{varName}} with actual values + if (typeof template !== "string") return template + return template.replace(/\{\{([^}]+)\}\}/g, (match, varName) => { + const value = globalThis.hopp.env.global.get(varName.trim()) + return value !== null ? value : match + }) }, }, variables: { - get: (key) => globalThis.hopp.env.get(key), - set: (key, value) => globalThis.hopp.env.active.set(key, value), + get: (key) => { + // pm.variables searches both active and global scopes + // Try active first + let value = globalThis.hopp.env.active.getRaw(key) + let isTracked = + globalThis.__pmEnvKeys && globalThis.__pmEnvKeys.has(key) + + // If not found in active, try global + if (value === null && !isTracked) { + value = globalThis.hopp.env.global.getRaw(key) + isTracked = + globalThis.__pmGlobalKeys && globalThis.__pmGlobalKeys.has(key) + } + + // Return undefined for missing keys, preserve null for explicit null values + if (value === null && !isTracked) { + return undefined // Key doesn't exist in either scope + } + return value // Returns null (from NULL_MARKER) or actual value + }, + set: (key, value) => { + return pmSetWithMarkers(key, value, "active") + }, has: (key) => globalThis.hopp.env.get(key) !== null, replaceIn: (template) => { if (typeof template !== "string") return template @@ -312,14 +2610,287 @@ return value !== null ? value : match }) }, + toObject: () => { + // Variables scope includes both environment and global with precedence + // Read from arrays but verify keys still exist in Maps (respect deletions) + const result = {} + + // Add global variables first + if (typeof inputs !== "undefined" && inputs.getAllGlobalEnvs) { + const globalEnvs = inputs.getAllGlobalEnvs() + if (Array.isArray(globalEnvs)) { + globalEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + const currentValue = globalThis.hopp.env.global.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + // Then override with environment variables (environment takes precedence) + if (typeof inputs !== "undefined" && inputs.getAllSelectedEnvs) { + const selectedEnvs = inputs.getAllSelectedEnvs() + if (Array.isArray(selectedEnvs)) { + selectedEnvs.forEach((envVar) => { + if (envVar && envVar.key) { + const currentValue = globalThis.hopp.env.active.get(envVar.key) + if (currentValue !== null) { + result[envVar.key] = currentValue + } + } + }) + } + } + + return result + }, }, request: { + // ID and name (read-only, exposed from inputs) + get id() { + return inputs.pmInfoRequestId() + }, + + get name() { + return inputs.pmInfoRequestName() + }, + + // Certificate and proxy (read-only, not accessible in scripts) + // These are configured at app/collection level in Postman, not in scripts + get certificate() { + return withModifiers(modifiers) + }, + + get proxy() { + return withModifiers(modifiers) + }, + + // URL - Read-only with Postman-compatible structure (no setters in post-request) get url() { - const urlString = globalThis.hopp.request.url - return { - toString: () => urlString, + const urlObj = { + // toString reads current URL dynamically + toString: () => globalThis.hopp.request.url, + + _parseUrl: () => { + const urlString = globalThis.hopp.request.url + try { + const parsed = new URL(urlString) + return { + protocol: parsed.protocol.slice(0, -1), + host: parsed.hostname.split("."), + port: + parsed.port || (parsed.protocol === "https:" ? "443" : "80"), + path: parsed.pathname.split("/").filter(Boolean), + queryParams: Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value }) + ), + } + } catch { + return { + protocol: "https", + host: [], + port: "443", + path: [], + queryParams: [], + } + } + }, } + + // Read-only properties (no setters in post-request script) + Object.defineProperty(urlObj, "protocol", { + get: () => urlObj._parseUrl().protocol, + enumerable: true, + }) + + Object.defineProperty(urlObj, "host", { + get: () => urlObj._parseUrl().host, + enumerable: true, + }) + + Object.defineProperty(urlObj, "path", { + get: () => urlObj._parseUrl().path, + enumerable: true, + }) + + // Postman-compatible URL helper methods (read-only in post-request) + urlObj.getHost = () => urlObj._parseUrl().host.join(".") + + urlObj.getPath = (_unresolved = false) => { + const pathArray = urlObj._parseUrl().path + return pathArray.length > 0 ? "/" + pathArray.join("/") : "/" + } + + urlObj.getPathWithQuery = () => { + const parsed = urlObj._parseUrl() + const path = + parsed.path.length > 0 ? "/" + parsed.path.join("/") : "/" + const query = + parsed.queryParams.length > 0 + ? "?" + + parsed.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + return path + query + } + + urlObj.getQueryString = (_options = {}) => { + const params = urlObj._parseUrl().queryParams + if (params.length === 0) return "" + return params + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + } + + urlObj.getRemote = (forcePort = false) => { + const parsed = urlObj._parseUrl() + const host = parsed.host.join(".") + const showPort = + forcePort || (parsed.port !== "443" && parsed.port !== "80") + return showPort ? `${host}:${parsed.port}` : host + } + + // hostname property (string alias for host array) + Object.defineProperty(urlObj, "hostname", { + get: () => urlObj._parseUrl().host.join("."), + enumerable: true, + }) + + // hash property for URL fragments + Object.defineProperty(urlObj, "hash", { + get: () => { + try { + const parsed = new URL(globalThis.hopp.request.url) + return parsed.hash ? parsed.hash.slice(1) : "" + } catch { + return "" + } + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "query", { + get: () => { + return { + all: () => { + // Parse current URL dynamically + const parsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + parsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + // PropertyList methods (read-only in post-request) + get: (key) => { + const params = urlObj._parseUrl().queryParams + const param = params.find((p) => p.key === key) + return param ? param.value : null + }, + + has: (key) => { + const params = urlObj._parseUrl().queryParams + return params.some((p) => p.key === key) + }, + + toObject: () => { + const parsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + parsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + each: (callback) => { + const params = urlObj._parseUrl().queryParams + params.forEach(callback) + }, + + map: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.map(callback) + }, + + filter: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.filter(callback) + }, + + count: () => { + return urlObj._parseUrl().queryParams.length + }, + + idx: (index) => { + const params = urlObj._parseUrl().queryParams + return params[index] || null + }, + + // Advanced PropertyList methods (read-only) + find: (rule, context) => { + const params = urlObj._parseUrl().queryParams + if (typeof rule === "function") { + return ( + params.find(context ? rule.bind(context) : rule) || null + ) + } + // String rule: find by key + if (typeof rule === "string") { + return params.find((p) => p.key === rule) || null + } + return null + }, + + indexOf: (item) => { + const params = urlObj._parseUrl().queryParams + if (typeof item === "string") { + // Find by key + return params.findIndex((p) => p.key === item) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key + return params.findIndex((p) => p.key === item.key) + } + return -1 + }, + } + }, + enumerable: true, + }) + + return urlObj }, get method() { @@ -348,6 +2919,69 @@ }) return result }, + + // PropertyList methods (read-only in post-request) + toObject: () => { + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + each: (callback) => { + globalThis.hopp.request.headers.forEach(callback) + }, + + map: (callback) => { + return globalThis.hopp.request.headers.map(callback) + }, + + filter: (callback) => { + return globalThis.hopp.request.headers.filter(callback) + }, + + count: () => { + return globalThis.hopp.request.headers.length + }, + + idx: (index) => { + return globalThis.hopp.request.headers[index] || null + }, + + // Advanced PropertyList methods (read-only) + find: (rule, context) => { + const headers = globalThis.hopp.request.headers + if (typeof rule === "function") { + return headers.find(context ? rule.bind(context) : rule) || null + } + // String rule: find by key (case-insensitive) + if (typeof rule === "string") { + return ( + headers.find( + (h) => h.key.toLowerCase() === rule.toLowerCase() + ) || null + ) + } + return null + }, + + indexOf: (item) => { + const headers = globalThis.hopp.request.headers + if (typeof item === "string") { + // Find by key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.toLowerCase() + ) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.key.toLowerCase() + ) + } + return -1 + }, } }, @@ -365,21 +2999,176 @@ return globalThis.hopp.response.statusCode }, get status() { - return globalThis.hopp.response.statusText + // Get status text from response, fallback to standard reason phrase if empty + const statusText = globalThis.hopp.response.statusText + if (statusText && statusText.trim() !== "") { + return statusText + } + + // Map status code to standard HTTP reason phrase (for HTTP/2 or empty responses) + const statusCode = globalThis.hopp.response.statusCode + const standardReasonPhrases = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 103: "Early Hints", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", + } + + return standardReasonPhrases[statusCode] || "" }, get responseTime() { return globalThis.hopp.response.responseTime }, + get responseSize() { + // Calculate response size from body bytes + try { + // Check if response and body exist + if ( + !globalThis.hopp || + !globalThis.hopp.response || + !globalThis.hopp.response.body + ) { + return withModifiers(modifiers) + } + + // Try bytes() first + if (typeof globalThis.hopp.response.body.bytes === "function") { + const bytes = globalThis.hopp.response.body.bytes() + if (bytes && typeof bytes.length === "number") { + return bytes.length + } + } + + // Fallback: calculate from text representation + if (typeof globalThis.hopp.response.body.asText === "function") { + const text = globalThis.hopp.response.body.asText() + if (typeof text === "string") { + // Use TextEncoder if available + if (typeof TextEncoder !== "undefined") { + try { + const encoder = new TextEncoder() + const encoded = encoder.encode(text) + + // Try standard Uint8Array properties first + if ( + encoded && + typeof encoded.length === "number" && + encoded.length > 0 + ) { + return encoded.length + } else if ( + encoded && + typeof encoded.byteLength === "number" && + encoded.byteLength > 0 + ) { + return encoded.byteLength + } else if (encoded && Object.keys(encoded).length > 0) { + // QuickJS represents Uint8Array as object with numeric keys + // Count numeric keys to get byte length + const len = Object.keys(encoded).filter( + (k) => !isNaN(k) + ).length + if (len > 0) { + return len + } + } + } catch (_e) { + // Fall through to manual calculation + } + } + + // Fallback: manual UTF-8 byte length calculation + let byteLength = 0 + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + if (code < 0x80) byteLength += 1 + else if (code < 0x800) byteLength += 2 + else if (code < 0x10000) byteLength += 3 + else byteLength += 4 + } + return byteLength + } + } + + return withModifiers(modifiers) + } catch (_e) { + return withModifiers(modifiers) + } + }, text: () => globalThis.hopp.response.body.asText(), json: () => globalThis.hopp.response.body.asJSON(), - stream: globalThis.hopp.response.body.bytes(), + get stream() { + return globalThis.hopp.response.body.bytes() + }, + reason: inputs.responseReason, + dataURI: inputs.responseDataURI, + jsonp: (callbackName) => inputs.responseJsonp(callbackName), headers: { get: (name) => { const headers = globalThis.hopp.response.headers const header = headers.find( (h) => h.key.toLowerCase() === name.toLowerCase() ) - return header ? header.value : null + // NOTE: Postman returns undefined for non-existent headers, not null + return header ? header.value : undefined }, has: (name) => { const headers = globalThis.hopp.response.headers @@ -394,14 +3183,419 @@ }, }, cookies: { - get: (_name) => { - throw new Error("pm.response.cookies.get() not yet implemented") + get: (name) => { + // Parse cookies from Set-Cookie headers + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieStr = header.value + const cookieName = cookieStr.split("=")[0].trim() + if (cookieName === name) { + // Extract cookie value (everything after first =, before first ;) + const parts = cookieStr.split(";") + const [, ...valueRest] = parts[0].split("=") + const value = valueRest.join("=").trim() + + // Return just the value string, matching Postman behavior + return value + } + } + return null }, - has: (_name) => { - throw new Error("pm.response.cookies.has() not yet implemented") + has: (name) => { + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + for (const header of setCookieHeaders) { + const cookieName = header.value.split("=")[0].trim() + if (cookieName === name) { + return true + } + } + return false }, toObject: () => { - throw new Error("pm.response.cookies.toObject() not yet implemented") + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + const cookies = {} + for (const header of setCookieHeaders) { + const parts = header.value.split(";") + const [nameValue] = parts + const [name, ...valueRest] = nameValue.split("=") + const value = valueRest.join("=").trim() + cookies[name.trim()] = value + } + return cookies + }, + }, + + // Postman BDD-style assertion helpers + to: { + have: { + status: (expectedCode) => { + const actual = globalThis.hopp.response.statusCode + globalThis.hopp.expect(actual).to.equal(expectedCode) + }, + header: (headerName, headerValue) => { + const headers = globalThis.hopp.response.headers + const header = headers.find( + (h) => h.key.toLowerCase() === headerName.toLowerCase() + ) + if (headerValue !== undefined) { + globalThis.hopp + .expect(header ? header.value : null) + .to.equal(headerValue) + } else { + globalThis.hopp.expect(header).to.exist + } + }, + body: (expectedBody) => { + const actualBody = globalThis.hopp.response.body.asText() + globalThis.hopp.expect(actualBody).to.equal(expectedBody) + }, + jsonBody: (keyOrSchema, expectedValue) => { + const jsonData = globalThis.hopp.response.body.asJSON() + if (keyOrSchema === undefined) { + // No arguments: assert response is JSON object + globalThis.hopp.expect(jsonData).to.be.an("object") + } else if (typeof keyOrSchema === "string") { + // Key provided + if (expectedValue !== undefined) { + // Key and value: assert property equals value + globalThis.hopp + .expect(jsonData[keyOrSchema]) + .to.equal(expectedValue) + } else { + // Key only: assert property exists + globalThis.hopp.expect(jsonData).to.have.property(keyOrSchema) + } + } else if (typeof keyOrSchema === "object") { + // Schema validation (basic deep equality check) + globalThis.hopp.expect(jsonData).to.deep.equal(keyOrSchema) + } + }, + responseTime: { + below: (ms) => { + const actual = globalThis.hopp.response.responseTime + globalThis.hopp.expect(actual).to.be.below(ms) + }, + above: (ms) => { + const actual = globalThis.hopp.response.responseTime + globalThis.hopp.expect(actual).to.be.above(ms) + }, + }, + jsonSchema: (schema) => { + // Basic JSON Schema validation (supports common keywords) + const jsonData = globalThis.hopp.response.body.asJSON() + + const validateSchema = (data, schema) => { + // Type validation + if (schema.type) { + const actualType = Array.isArray(data) + ? "array" + : data === null + ? "null" + : typeof data + if (actualType !== schema.type) { + return `Expected type ${schema.type}, got ${actualType}` + } + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const prop of schema.required) { + if (!(prop in data)) { + return `Required property '${prop}' is missing` + } + } + } + + // Properties validation + if (schema.properties && typeof data === "object") { + for (const prop in schema.properties) { + if (prop in data) { + const error = validateSchema( + data[prop], + schema.properties[prop] + ) + if (error) return error + } + } + } + + // Array validation + if (schema.items && Array.isArray(data)) { + for (const item of data) { + const error = validateSchema(item, schema.items) + if (error) return error + } + } + + // Enum validation + if (schema.enum && Array.isArray(schema.enum)) { + if (!schema.enum.includes(data)) { + return `Value must be one of ${JSON.stringify(schema.enum)}` + } + } + + // Min/max validation + if (typeof data === "number") { + if (schema.minimum !== undefined && data < schema.minimum) { + return `Value must be >= ${schema.minimum}` + } + if (schema.maximum !== undefined && data > schema.maximum) { + return `Value must be <= ${schema.maximum}` + } + } + + // String length validation + if (typeof data === "string") { + if ( + schema.minLength !== undefined && + data.length < schema.minLength + ) { + return `String length must be >= ${schema.minLength}` + } + if ( + schema.maxLength !== undefined && + data.length > schema.maxLength + ) { + return `String length must be <= ${schema.maxLength}` + } + if (schema.pattern) { + const regex = new RegExp(schema.pattern) + if (!regex.test(data)) { + return `String must match pattern ${schema.pattern}` + } + } + } + + // Array length validation + if (Array.isArray(data)) { + if ( + schema.minItems !== undefined && + data.length < schema.minItems + ) { + return `Array must have >= ${schema.minItems} items` + } + if ( + schema.maxItems !== undefined && + data.length > schema.maxItems + ) { + return `Array must have <= ${schema.maxItems} items` + } + } + + return null + } + + const error = validateSchema(jsonData, schema) + if (error) { + // Schema validation failed - this would throw in Postman, + // but we record it as a test failure instead for better UX + throw new Error(error) + } + // On success, no assertion is recorded (Postman behavior) + }, + charset: (expectedCharset) => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const contentTypeValue = contentType ? contentType.value : "" + const charsetMatch = contentTypeValue.match(/charset=([^;]+)/) + const actualCharset = charsetMatch + ? charsetMatch[1].trim().toLowerCase() + : null + globalThis.hopp + .expect(actualCharset) + .to.equal(expectedCharset.toLowerCase()) + }, + cookie: (cookieName, cookieValue) => { + const headers = globalThis.hopp.response.headers + const setCookieHeaders = headers.filter( + (h) => h.key.toLowerCase() === "set-cookie" + ) + + let found = false + let actualValue = null + for (const header of setCookieHeaders) { + const cookieStr = header.value + const name = cookieStr.split("=")[0].trim() + if (name === cookieName) { + found = true + const [, ...valueRest] = cookieStr.split("=") + actualValue = valueRest.join("=").split(";")[0].trim() + break + } + } + + if (!found) { + // Cookie not found - record expectation failure + globalThis.hopp.expect(found, `Cookie '${cookieName}' not found`) + .to.be.true + } else if (cookieValue !== undefined) { + // Cookie found and value specified - check value + globalThis.hopp.expect(actualValue).to.equal(cookieValue) + } else { + // Cookie found and no value specified - just assert it exists + globalThis.hopp.expect(found).to.be.true + } + }, + jsonPath: (path, expectedValue) => { + // Basic JSONPath implementation (supports simple paths) + const jsonData = globalThis.hopp.response.body.asJSON() + + const evaluatePath = (data, path) => { + // Remove leading $ if present + const cleanPath = path.startsWith("$") ? path.slice(1) : path + if (!cleanPath || cleanPath === ".") { + return { success: true, value: data } + } + + // Split by dots and brackets, handling array indices + const parts = cleanPath + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((p) => p) + + let current = data + for (const part of parts) { + if (current === null || current === undefined) { + return { + success: false, + error: `Cannot access property '${part}' of ${current}`, + } + } + if (Array.isArray(current)) { + const index = parseInt(part) + if (isNaN(index) || index >= current.length) { + return { + success: false, + error: `Array index '${part}' out of bounds`, + } + } + current = current[index] + } else if (typeof current === "object") { + if (!(part in current)) { + return { + success: false, + error: `Property '${part}' not found`, + } + } + current = current[part] + } else { + return { + success: false, + error: `Cannot access property '${part}' of ${typeof current}`, + } + } + } + return { success: true, value: current } + } + + const result = evaluatePath(jsonData, path) + if (!result.success) { + throw new Error(result.error) + } + + if (expectedValue !== undefined) { + globalThis.hopp.expect(result.value).to.deep.equal(expectedValue) + } else { + globalThis.hopp.expect(result.value).to.exist + } + }, + }, + be: { + // Status code convenience methods + ok: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 200 && code < 300).to.be.true + }, + success: () => { + // Alias for ok - validates 2xx status codes + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 200 && code < 300).to.be.true + }, + accepted: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(202) + }, + badRequest: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(400) + }, + unauthorized: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(401) + }, + forbidden: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(403) + }, + notFound: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(404) + }, + rateLimited: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code).to.equal(429) + }, + clientError: () => { + // Validates 4xx status codes + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 400 && code < 500).to.be.true + }, + serverError: () => { + const code = globalThis.hopp.response.statusCode + globalThis.hopp.expect(code >= 500 && code < 600).to.be.true + }, + // Content type checks + json: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("application/json") + }, + html: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("text/html") + }, + xml: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + const ct = contentType ? contentType.value : "" + globalThis.hopp.expect( + ct.includes("application/xml") || ct.includes("text/xml") + ).to.be.true + }, + text: () => { + const headers = globalThis.hopp.response.headers + const contentType = headers.find( + (h) => h.key.toLowerCase() === "content-type" + ) + globalThis.hopp + .expect(contentType ? contentType.value : "") + .to.include("text/plain") + }, }, }, }, @@ -423,11 +3617,14 @@ }, test: (name, fn) => globalThis.hopp.test(name, fn), - expect: (value) => globalThis.hopp.expect(value), + expect: Object.assign((value) => globalThis.hopp.expect(value), { + // pm.expect.fail() - Postman compatibility + fail: globalThis.hopp.expect.fail, + }), // Script context information info: { - eventName: "post-request", // post-request context + eventName: "test", // Postman uses "test" for test scripts, not "post-request" get requestName() { return inputs.pmInfoRequestName() }, @@ -452,40 +3649,6 @@ throw new Error("pm.sendRequest() is not yet implemented in Hoppscotch") }, - // Collection variables (unsupported) - collectionVariables: { - get: () => { - throw new Error( - "pm.collectionVariables.get() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - set: () => { - throw new Error( - "pm.collectionVariables.set() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - unset: () => { - throw new Error( - "pm.collectionVariables.unset() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - has: () => { - throw new Error( - "pm.collectionVariables.has() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - clear: () => { - throw new Error( - "pm.collectionVariables.clear() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - toObject: () => { - throw new Error( - "pm.collectionVariables.toObject() is not supported in Hoppscotch (use environment or request variables instead)" - ) - }, - }, - // Postman Vault (unsupported) vault: { get: () => { @@ -505,6 +3668,20 @@ }, }, + // Postman Visualizer (unsupported) + visualizer: { + set: () => { + throw new Error( + "pm.visualizer.set() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + clear: () => { + throw new Error( + "pm.visualizer.clear() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + }, + // Iteration data (unsupported) iterationData: { get: () => { @@ -532,15 +3709,86 @@ "pm.iterationData.toObject() is not supported in Hoppscotch (Collection Runner feature)" ) }, + toJSON: () => { + throw new Error( + "pm.iterationData.toJSON() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, }, - // Execution control (unsupported) + // Collection variables (unsupported) + collectionVariables: { + get: () => { + throw new Error( + "pm.collectionVariables.get() is not supported in Hoppscotch (Workspace feature)" + ) + }, + set: () => { + throw new Error( + "pm.collectionVariables.set() is not supported in Hoppscotch (Workspace feature)" + ) + }, + unset: () => { + throw new Error( + "pm.collectionVariables.unset() is not supported in Hoppscotch (Workspace feature)" + ) + }, + has: () => { + throw new Error( + "pm.collectionVariables.has() is not supported in Hoppscotch (Workspace feature)" + ) + }, + clear: () => { + throw new Error( + "pm.collectionVariables.clear() is not supported in Hoppscotch (Workspace feature)" + ) + }, + toObject: () => { + throw new Error( + "pm.collectionVariables.toObject() is not supported in Hoppscotch (Workspace feature)" + ) + }, + replaceIn: () => { + throw new Error( + "pm.collectionVariables.replaceIn() is not supported in Hoppscotch (Workspace feature)" + ) + }, + }, + + // Execution control execution: { + location: (() => { + const location = ["Hoppscotch"] + Object.defineProperty(location, "current", { + value: "Hoppscotch", + writable: false, + enumerable: true, + }) + Object.freeze(location) + return location + })(), setNextRequest: () => { throw new Error( "pm.execution.setNextRequest() is not supported in Hoppscotch (Collection Runner feature)" ) }, + skipRequest: () => { + throw new Error( + "pm.execution.skipRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + runRequest: () => { + throw new Error( + "pm.execution.runRequest() is not supported in Hoppscotch (Collection Runner feature)" + ) + }, + }, + + // Package imports (unsupported) + require: (packageName) => { + throw new Error( + `pm.require('${packageName}') is not supported in Hoppscotch (Package Library feature)` + ) }, } } diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js index e8e341de..f29164d7 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js @@ -4,8 +4,8 @@ "use strict" globalThis.pw = { env: { - get: (key) => inputs.envGet(key), - getResolve: (key) => inputs.envGetResolve(key), + get: (key) => convertMarkerToValue(inputs.envGet(key)), + getResolve: (key) => convertMarkerToValue(inputs.envGetResolve(key)), set: (key, value) => inputs.envSet(key, value), unset: (key) => inputs.envUnset(key), resolve: (key) => inputs.envResolve(key), @@ -50,42 +50,99 @@ // Freeze the entire requestProps object for additional protection Object.freeze(requestProps) + // Special markers for undefined and null values to preserve them across sandbox boundary + // NOTE: These values MUST match constants/sandbox-markers.ts + // (Cannot import directly as this runs in QuickJS sandbox) + const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" + const NULL_MARKER = "__HOPPSCOTCH_NULL__" + + // Helper function to convert markers back to their original values + const convertMarkerToValue = (value) => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value + } + globalThis.hopp = { env: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "all" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "all" }), + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "all", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "all", + }) + ) + }, set: (key, value) => inputs.envSet(key, value), delete: (key) => inputs.envUnset(key), reset: (key) => inputs.envReset(key), - getInitialRaw: (key) => inputs.envGetInitialRaw(key), + getInitialRaw: (key) => { + return convertMarkerToValue(inputs.envGetInitialRaw(key)) + }, setInitial: (key, value) => inputs.envSetInitial(key, value), active: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "active" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "active" }), + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "active", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "active", + }) + ) + }, set: (key, value) => inputs.envSet(key, value, { source: "active" }), delete: (key) => inputs.envUnset(key, { source: "active" }), reset: (key) => inputs.envReset(key, { source: "active" }), - getInitialRaw: (key) => - inputs.envGetInitialRaw(key, { source: "active" }), + getInitialRaw: (key) => { + return convertMarkerToValue( + inputs.envGetInitialRaw(key, { source: "active" }) + ) + }, setInitial: (key, value) => inputs.envSetInitial(key, value, { source: "active" }), }, global: { - get: (key) => - inputs.envGetResolve(key, { fallbackToNull: true, source: "global" }), - getRaw: (key) => - inputs.envGet(key, { fallbackToNull: true, source: "global" }), + get: (key) => { + return convertMarkerToValue( + inputs.envGetResolve(key, { + fallbackToNull: true, + source: "global", + }) + ) + }, + getRaw: (key) => { + return convertMarkerToValue( + inputs.envGet(key, { + fallbackToNull: true, + source: "global", + }) + ) + }, set: (key, value) => inputs.envSet(key, value, { source: "global" }), delete: (key) => inputs.envUnset(key, { source: "global" }), reset: (key) => inputs.envReset(key, { source: "global" }), - getInitialRaw: (key) => - inputs.envGetInitialRaw(key, { source: "global" }), + getInitialRaw: (key) => { + return convertMarkerToValue( + inputs.envGetInitialRaw(key, { source: "global" }) + ) + }, setInitial: (key, value) => inputs.envSetInitial(key, value, { source: "global" }), }, @@ -104,34 +161,100 @@ // PM Namespace - Postman Compatibility Layer globalThis.pm = { environment: { - get: (key) => globalThis.hopp.env.active.get(key), - set: (key, value) => globalThis.hopp.env.active.set(key, value), + get: (key) => { + const value = globalThis.hopp.env.active.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "active" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "active" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "active" }) + } + }, unset: (key) => globalThis.hopp.env.active.delete(key), has: (key) => globalThis.hopp.env.active.get(key) !== null, clear: () => { - throw new Error("pm.environment.clear() not yet implemented") + // Get all active environment variables and delete them + const envVars = inputs.getAllSelectedEnvs() + envVars.forEach((envVar) => { + globalThis.hopp.env.active.delete(envVar.key) + }) }, toObject: () => { - throw new Error("pm.environment.toObject() not yet implemented") + // Get all active environment variables as an object + const envVars = inputs.getAllSelectedEnvs() + const result = {} + envVars.forEach((envVar) => { + const value = globalThis.hopp.env.active.get(envVar.key) + if (value !== null) { + result[envVar.key] = value + } + }) + return result }, }, globals: { - get: (key) => globalThis.hopp.env.global.get(key), - set: (key, value) => globalThis.hopp.env.global.set(key, value), + get: (key) => { + const value = globalThis.hopp.env.global.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "global" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "global" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "global" }) + } + }, unset: (key) => globalThis.hopp.env.global.delete(key), has: (key) => globalThis.hopp.env.global.get(key) !== null, clear: () => { - throw new Error("pm.globals.clear() not yet implemented") + // Get all global environment variables and delete them + const envVars = inputs.getAllGlobalEnvs() + envVars.forEach((envVar) => { + globalThis.hopp.env.global.delete(envVar.key) + }) }, toObject: () => { - throw new Error("pm.globals.toObject() not yet implemented") + // Get all global environment variables as an object + const envVars = inputs.getAllGlobalEnvs() + const result = {} + envVars.forEach((envVar) => { + const value = globalThis.hopp.env.global.get(envVar.key) + if (value !== null) { + result[envVar.key] = value + } + }) + return result }, }, variables: { - get: (key) => globalThis.hopp.env.get(key), - set: (key, value) => globalThis.hopp.env.active.set(key, value), + get: (key) => { + const value = globalThis.hopp.env.get(key) + // Postman returns undefined for missing keys, not null + return value === null ? undefined : value + }, + set: (key, value) => { + // PM namespace preserves all types - use pmEnvSetAny directly + // variables.set uses active scope + if (typeof value === "undefined") { + return inputs.pmEnvSetAny(key, UNDEFINED_MARKER, { source: "active" }) + } else if (value === null) { + return inputs.pmEnvSetAny(key, NULL_MARKER, { source: "active" }) + } else { + return inputs.pmEnvSetAny(key, value, { source: "active" }) + } + }, has: (key) => globalThis.hopp.env.get(key) !== null, replaceIn: (template) => { if (typeof template !== "string") return template @@ -143,19 +266,596 @@ }, request: { + // ID and name (read-only, exposed from inputs) + get id() { + return inputs.pmInfoRequestId() + }, + + get name() { + return inputs.pmInfoRequestName() + }, + + // Certificate and proxy (read-only, not accessible in scripts) + // These are configured at app/collection level in Postman, not in scripts + get certificate() { + return undefined + }, + + get proxy() { + return undefined + }, + + // URL - Mutable with Postman-compatible structure get url() { - const urlString = globalThis.hopp.request.url - return { - toString: () => urlString, + // Return Postman-compatible URL object + const urlObj = { + // toString reads current URL dynamically (not cached) + toString: () => globalThis.hopp.request.url, + + // Helper to parse URL into components + _parseUrl: () => { + const urlString = globalThis.hopp.request.url + try { + const parsed = new URL(urlString) + + // Get query params from URL + const urlParams = Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value }) + ) + + // Only merge from hopp.request.params if URL has no query params + // This supports imported collections while allowing mutations to work + let finalParams = urlParams + if (urlParams.length === 0) { + // No params in URL - check hopp.request.params (for imported collections) + const requestParams = globalThis.hopp.request.params || [] + const activeRequestParams = requestParams + .filter((p) => p.active !== false) + .map((p) => ({ key: p.key, value: p.value })) + finalParams = activeRequestParams + } + + return { + protocol: parsed.protocol.slice(0, -1), // Remove trailing : + host: parsed.hostname.split("."), + port: + parsed.port || (parsed.protocol === "https:" ? "443" : "80"), + path: parsed.pathname.split("/").filter(Boolean), + queryParams: finalParams, + hash: parsed.hash ? parsed.hash.slice(1) : "", // Remove leading # + } + } catch { + // Fallback: try to get params from hopp.request.params + const requestParams = globalThis.hopp.request?.params || [] + const activeParams = requestParams + .filter((p) => p.active !== false) + .map((p) => ({ key: p.key, value: p.value })) + + return { + protocol: "https", + host: [], + port: "443", + path: [], + queryParams: activeParams, + } + } + }, + + // Helper to rebuild URL from components + _rebuildUrl: (components) => { + const protocol = components.protocol || "https" + const host = Array.isArray(components.host) + ? components.host.join(".") + : components.host + const port = + components.port && + components.port !== "443" && + components.port !== "80" + ? `:${components.port}` + : "" + const path = Array.isArray(components.path) + ? "/" + components.path.join("/") + : components.path + const query = + components.queryParams && components.queryParams.length > 0 + ? "?" + + components.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + const hash = components.hash ? `#${components.hash}` : "" + return `${protocol}://${host}${port}${path}${query}${hash}` + }, + + // Postman-compatible URL methods + getHost: () => urlObj._parseUrl().host.join("."), + + getPath: (_unresolved = false) => { + const pathArray = urlObj._parseUrl().path + return pathArray.length > 0 ? "/" + pathArray.join("/") : "/" + }, + + getPathWithQuery: () => { + const parsed = urlObj._parseUrl() + const path = + parsed.path.length > 0 ? "/" + parsed.path.join("/") : "/" + const query = + parsed.queryParams.length > 0 + ? "?" + + parsed.queryParams + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + : "" + return path + query + }, + + getQueryString: (_options = {}) => { + const params = urlObj._parseUrl().queryParams + if (params.length === 0) return "" + return params + .map( + (p) => + `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}` + ) + .join("&") + }, + + getRemote: (forcePort = false) => { + const parsed = urlObj._parseUrl() + const host = parsed.host.join(".") + const showPort = + forcePort || (parsed.port !== "443" && parsed.port !== "80") + return showPort ? `${host}:${parsed.port}` : host + }, + + update: (urlString) => { + if (typeof urlString === "string") { + globalThis.hopp.request.setUrl(urlString) + } else if ( + urlString && + typeof urlString === "object" && + typeof urlString.toString === "function" + ) { + globalThis.hopp.request.setUrl(urlString.toString()) + } else { + throw new Error( + "URL update requires a string or object with toString() method" + ) + } + }, + + addQueryParams: (params) => { + if (!Array.isArray(params)) { + throw new Error("addQueryParams requires an array of parameters") + } + const currentParsed = urlObj._parseUrl() + params.forEach((param) => { + if (param && param.key) { + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + } + }) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(currentParsed)) + }, + + removeQueryParams: (params) => { + if (!Array.isArray(params) && typeof params !== "string") { + throw new Error( + "removeQueryParams requires an array of param names or a single param name" + ) + } + const keysToRemove = Array.isArray(params) ? params : [params] + const currentParsed = urlObj._parseUrl() + const updatedParams = currentParsed.queryParams.filter( + (p) => !keysToRemove.includes(p.key) + ) + currentParsed.queryParams = updatedParams + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(currentParsed)) + // Also update the params array to ensure consistency + globalThis.hopp.request.setParams( + updatedParams.map((p) => ({ + key: p.key, + value: p.value, + active: true, + description: "", + })) + ) + }, + } + + // Lazy-loaded mutable properties + Object.defineProperty(urlObj, "protocol", { + get: () => urlObj._parseUrl().protocol, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.protocol = value + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "host", { + get: () => urlObj._parseUrl().host, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.host = Array.isArray(value) ? value : value.split(".") + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + // hostname is an alias for host as a string + Object.defineProperty(urlObj, "hostname", { + get: () => urlObj._parseUrl().host.join("."), + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.host = String(value).split(".") + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "port", { + get: () => urlObj._parseUrl().port, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.port = String(value) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "path", { + get: () => urlObj._parseUrl().path, + set: (value) => { + const parsed = urlObj._parseUrl() + parsed.path = Array.isArray(value) + ? value + : value.split("/").filter(Boolean) + globalThis.hopp.request.setUrl(urlObj._rebuildUrl(parsed)) + }, + enumerable: true, + }) + + // hash property for URL fragments + Object.defineProperty(urlObj, "hash", { + get: () => { + try { + const parsed = new URL(globalThis.hopp.request.url) + return parsed.hash ? parsed.hash.slice(1) : "" + } catch { + return "" + } + }, + set: (value) => { + const current = globalThis.hopp.request.url + const baseUrl = current.split("#")[0] + const hashValue = value + ? value.startsWith("#") + ? value + : `#${value}` + : "" + globalThis.hopp.request.setUrl(baseUrl + hashValue) + }, + enumerable: true, + }) + + Object.defineProperty(urlObj, "query", { + get: () => { + return { + // Basic manipulation methods + add: (param) => { + if (!param || !param.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + remove: (key) => { + if (typeof key !== "string") + throw new Error("Query param key must be a string") + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => p.key !== key + ) + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + upsert: (param) => { + if (!param || !param.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + const idx = currentParsed.queryParams.findIndex( + (p) => p.key === param.key + ) + if (idx >= 0) { + currentParsed.queryParams[idx].value = param.value || "" + } else { + currentParsed.queryParams.push({ + key: param.key, + value: param.value || "", + }) + } + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + clear: () => { + const currentParsed = urlObj._parseUrl() + currentParsed.queryParams = [] + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + // Also clear the params array to ensure consistency + globalThis.hopp.request.setParams([]) + }, + + // Read methods + get: (key) => { + const params = urlObj._parseUrl().queryParams + const param = params.find((p) => p.key === key) + return param ? param.value : null + }, + + has: (key) => { + const params = urlObj._parseUrl().queryParams + return params.some((p) => p.key === key) + }, + + all: () => { + const currentParsed = urlObj._parseUrl() + const result = {} + + // Handle duplicate keys by converting to arrays + currentParsed.queryParams.forEach((p) => { + if (Object.prototype.hasOwnProperty.call(result, p.key)) { + if (!Array.isArray(result[p.key])) { + result[p.key] = [result[p.key]] + } + result[p.key].push(p.value) + } else { + result[p.key] = p.value + } + }) + + return result + }, + + toObject: () => { + // Alias for all() for Postman compatibility + return urlObj.query.all() + }, + + // PropertyList iteration methods + each: (callback) => { + const params = urlObj._parseUrl().queryParams + params.forEach(callback) + }, + + map: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.map(callback) + }, + + filter: (callback) => { + const params = urlObj._parseUrl().queryParams + return params.filter(callback) + }, + + count: () => { + return urlObj._parseUrl().queryParams.length + }, + + idx: (index) => { + const params = urlObj._parseUrl().queryParams + return params[index] || null + }, + + // Advanced PropertyList methods + find: (rule, context) => { + const params = urlObj._parseUrl().queryParams + if (typeof rule === "function") { + return ( + params.find(context ? rule.bind(context) : rule) || null + ) + } + // String rule: find by key + if (typeof rule === "string") { + return params.find((p) => p.key === rule) || null + } + return null + }, + + indexOf: (item) => { + const params = urlObj._parseUrl().queryParams + if (typeof item === "string") { + // Find by key + return params.findIndex((p) => p.key === item) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key + return params.findIndex((p) => p.key === item.key) + } + return -1 + }, + + insert: (item, before) => { + if (!item || !item.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + + if (before) { + // Find position to insert before + const beforeIdx = currentParsed.queryParams.findIndex( + (p) => p.key === before + ) + if (beforeIdx >= 0) { + currentParsed.queryParams.splice(beforeIdx, 0, { + key: item.key, + value: item.value || "", + }) + } else { + // If 'before' not found, append to end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + } else { + // No 'before' specified, add to end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + append: (item) => { + if (!item || !item.key) + throw new Error("Query param must have a 'key' property") + const currentParsed = urlObj._parseUrl() + + // Remove existing instances of this key + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => p.key !== item.key + ) + + // Add at end + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + + assimilate: (source, prune) => { + if (!source || typeof source !== "object") { + throw new Error("Source must be an array or object") + } + + const currentParsed = urlObj._parseUrl() + + // Convert source to array format + let sourceArray + if (Array.isArray(source)) { + sourceArray = source + } else { + // Convert object to array of {key, value} + sourceArray = Object.entries(source).map(([key, value]) => ({ + key, + value: String(value), + })) + } + + // Update or add each item from source + sourceArray.forEach((item) => { + if (!item || !item.key) return + const idx = currentParsed.queryParams.findIndex( + (p) => p.key === item.key + ) + if (idx >= 0) { + // Update existing + currentParsed.queryParams[idx].value = item.value || "" + } else { + // Add new + currentParsed.queryParams.push({ + key: item.key, + value: item.value || "", + }) + } + }) + + if (prune) { + // Remove params not in source + const sourceKeys = sourceArray + .filter((i) => i && i.key) + .map((i) => i.key) + currentParsed.queryParams = currentParsed.queryParams.filter( + (p) => sourceKeys.includes(p.key) + ) + } + + globalThis.hopp.request.setUrl( + urlObj._rebuildUrl(currentParsed) + ) + }, + } + }, + enumerable: true, + }) + + return urlObj + }, + + // URL setter (Postman pattern: pm.request.url = "...") + set url(value) { + let urlString + if (typeof value === "string") { + urlString = value + } else if (value && typeof value.toString === "function") { + urlString = value.toString() + } else { + throw new Error("URL must be a string or have a toString() method") + } + + globalThis.hopp.request.setUrl(urlString) + + // Parse query params from the new URL and update params array + try { + const parsed = new URL(urlString) + const urlParams = Array.from(parsed.searchParams.entries()).map( + ([key, value]) => ({ key, value, active: true, description: "" }) + ) + globalThis.hopp.request.setParams(urlParams) + } catch { + // If URL parsing fails, clear params + globalThis.hopp.request.setParams([]) } }, + // Method - Mutable + // NOTE: Postman does NOT normalize method to uppercase, so we preserve the original case get method() { return globalThis.hopp.request.method }, + set method(value) { + if (typeof value !== "string") { + throw new Error( + "Method must be a string (GET, POST, PUT, DELETE, etc.)" + ) + } + globalThis.hopp.request.setMethod(value) + }, + + // Headers - With Postman mutation methods and PropertyList interface get headers() { return { + // Read methods get: (name) => { const headers = globalThis.hopp.request.headers const header = headers.find( @@ -163,12 +863,14 @@ ) return header ? header.value : null }, + has: (name) => { const headers = globalThis.hopp.request.headers return headers.some( (h) => h.key.toLowerCase() === name.toLowerCase() ) }, + all: () => { const result = {} globalThis.hopp.request.headers.forEach((header) => { @@ -176,16 +878,291 @@ }) return result }, + + toObject: () => { + // Alias for all() for Postman compatibility + const result = {} + globalThis.hopp.request.headers.forEach((header) => { + result[header.key] = header.value + }) + return result + }, + + // Mutation methods (Postman compatibility) + add: (header) => { + if (!header || typeof header !== "object") { + throw new Error( + "Header must be an object with 'key' and 'value' properties" + ) + } + if (!header.key) { + throw new Error("Header must have a 'key' property") + } + globalThis.hopp.request.setHeader(header.key, header.value || "") + }, + + remove: (headerName) => { + if (typeof headerName !== "string") { + throw new Error("Header name must be a string") + } + globalThis.hopp.request.removeHeader(headerName) + }, + + upsert: (header) => { + if (!header || typeof header !== "object") { + throw new Error( + "Header must be an object with 'key' and 'value' properties" + ) + } + if (!header.key) { + throw new Error("Header must have a 'key' property") + } + // Remove existing (case-insensitive) then add + globalThis.hopp.request.removeHeader(header.key) + globalThis.hopp.request.setHeader(header.key, header.value || "") + }, + + clear: () => { + globalThis.hopp.request.setHeaders([]) + }, + + // PropertyList iteration methods + each: (callback) => { + globalThis.hopp.request.headers.forEach(callback) + }, + + map: (callback) => { + return globalThis.hopp.request.headers.map(callback) + }, + + filter: (callback) => { + return globalThis.hopp.request.headers.filter(callback) + }, + + count: () => { + return globalThis.hopp.request.headers.length + }, + + idx: (index) => { + return globalThis.hopp.request.headers[index] || null + }, + + // Advanced PropertyList methods + find: (rule, context) => { + const headers = globalThis.hopp.request.headers + if (typeof rule === "function") { + return headers.find(context ? rule.bind(context) : rule) || null + } + // String rule: find by key (case-insensitive) + if (typeof rule === "string") { + return ( + headers.find( + (h) => h.key.toLowerCase() === rule.toLowerCase() + ) || null + ) + } + return null + }, + + indexOf: (item) => { + const headers = globalThis.hopp.request.headers + if (typeof item === "string") { + // Find by key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.toLowerCase() + ) + } + if (item && typeof item === "object" && item.key) { + // Find by object with key (case-insensitive) + return headers.findIndex( + (h) => h.key.toLowerCase() === item.key.toLowerCase() + ) + } + return -1 + }, + + insert: (item, before) => { + if (!item || !item.key) + throw new Error("Header must have a 'key' property") + + const headers = globalThis.hopp.request.headers + + if (before) { + // Find position to insert before (case-insensitive) + const beforeIdx = headers.findIndex( + (h) => h.key.toLowerCase() === before.toLowerCase() + ) + if (beforeIdx >= 0) { + const newHeaders = [...headers] + newHeaders.splice(beforeIdx, 0, { + key: item.key, + value: item.value || "", + active: true, + }) + globalThis.hopp.request.setHeaders(newHeaders) + } else { + // If 'before' not found, append to end + globalThis.hopp.request.setHeader(item.key, item.value || "") + } + } else { + // No 'before' specified, add to end + globalThis.hopp.request.setHeader(item.key, item.value || "") + } + }, + + append: (item) => { + if (!item || !item.key) + throw new Error("Header must have a 'key' property") + + // Remove existing instances of this key (case-insensitive) + globalThis.hopp.request.removeHeader(item.key) + + // Add at end + globalThis.hopp.request.setHeader(item.key, item.value || "") + }, + + assimilate: (source, prune) => { + if (!source || typeof source !== "object") { + throw new Error("Source must be an array or object") + } + + let sourceArray + + if (Array.isArray(source)) { + sourceArray = source + } else { + // Convert object to array of {key, value} + sourceArray = Object.entries(source).map(([key, value]) => ({ + key, + value: String(value), + active: true, + })) + } + + // Update or add each item from source + sourceArray.forEach((item) => { + if (!item || !item.key) return + // Remove existing (case-insensitive) + globalThis.hopp.request.removeHeader(item.key) + // Add new/updated + globalThis.hopp.request.setHeader(item.key, item.value || "") + }) + + if (prune) { + // Remove headers not in source (case-insensitive) + const sourceKeys = sourceArray + .filter((i) => i && i.key) + .map((i) => i.key.toLowerCase()) + const currentHeaders = globalThis.hopp.request.headers + const filteredHeaders = currentHeaders.filter((h) => + sourceKeys.includes(h.key.toLowerCase()) + ) + globalThis.hopp.request.setHeaders(filteredHeaders) + } + }, } }, + // Body - With Postman update() method get body() { - return globalThis.hopp.request.body + const currentBody = globalThis.hopp.request.body + + // Return body with update() method + return { + // Spread current body properties + ...currentBody, + + // Postman-compatible update() method + update: (bodySpec) => { + if (typeof bodySpec === "string") { + // Direct string assignment + globalThis.hopp.request.setBody({ + contentType: "text/plain", + body: bodySpec, + }) + } else if (bodySpec && typeof bodySpec === "object") { + const mode = bodySpec.mode || "raw" + + switch (mode) { + case "raw": + globalThis.hopp.request.setBody({ + contentType: + bodySpec.options?.raw?.language === "json" + ? "application/json" + : "text/plain", + body: bodySpec.raw || "", + }) + break + + case "urlencoded": + globalThis.hopp.request.setBody({ + contentType: "application/x-www-form-urlencoded", + body: bodySpec.urlencoded || [], + }) + break + + case "formdata": + globalThis.hopp.request.setBody({ + contentType: "multipart/form-data", + body: bodySpec.formdata || [], + }) + break + + case "file": + globalThis.hopp.request.setBody({ + contentType: "binary", + body: bodySpec.file || null, + }) + break + + default: + throw new Error( + `Unsupported body mode: ${mode}. Supported modes: raw, urlencoded, formdata, file` + ) + } + } else { + throw new Error( + "Body spec must be a string or object with mode property" + ) + } + }, + } }, + // Body setter (legacy pattern for direct assignment) + set body(value) { + if (typeof value === "string") { + globalThis.hopp.request.setBody({ + contentType: "text/plain", + body: value, + }) + } else if (typeof value === "object" && value !== null) { + globalThis.hopp.request.setBody({ + contentType: "application/json", + body: JSON.stringify(value), + }) + } else { + throw new Error("Body must be a string or object") + } + }, + + // Auth - Mutable get auth() { return globalThis.hopp.request.auth }, + + set auth(value) { + if (value === null || value === undefined) { + globalThis.hopp.request.setAuth({ + authType: "none", + authActive: false, + }) + } else if (typeof value === "object") { + globalThis.hopp.request.setAuth(value) + } else { + throw new Error("Auth must be an object or null") + } + }, }, // Script context information @@ -268,6 +1245,20 @@ }, }, + // Postman Visualizer (unsupported) + visualizer: { + set: () => { + throw new Error( + "pm.visualizer.set() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + clear: () => { + throw new Error( + "pm.visualizer.clear() is not supported in Hoppscotch (Postman Visualizer feature)" + ) + }, + }, + // Iteration data (unsupported) iterationData: { get: () => { diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts index 9737a89b..fa308cb4 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pm-namespace.ts @@ -17,7 +17,9 @@ export const createPmNamespaceMethods = ( return config.request.name }), pmInfoRequestId: defineSandboxFn(ctx, "pmInfoRequestId", () => { - return config.request.id + // Use request.id if available, fallback to request.name + // Postman uses a unique ID, but for compatibility we use name if ID not set + return config.request.id || config.request.name }), } } diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts index 7308c06a..2e3d6847 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/namespaces/pw-namespace.ts @@ -1,6 +1,11 @@ import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" -import type { EnvMethods, RequestProps, PwNamespaceMethods } from "~/types" +import type { + EnvMethods, + RequestProps, + PwNamespaceMethods, + SandboxValue, +} from "~/types" /** * Creates pw namespace methods for the sandbox environment @@ -13,41 +18,49 @@ export const createPwNamespaceMethods = ( ): PwNamespaceMethods => { return { // `pw` namespace environment methods - envGet: defineSandboxFn(ctx, "envGet", function (key: any, options: any) { - return envMethods.pw.get(key, options) - }), + envGet: defineSandboxFn( + ctx, + "envGet", + function (key: SandboxValue, options: SandboxValue) { + return envMethods.pw.get(key, options) + } + ), envGetResolve: defineSandboxFn( ctx, "envGetResolve", - function (key: any, options: any) { + function (key: SandboxValue, options: SandboxValue) { return envMethods.pw.getResolve(key, options) } ), envSet: defineSandboxFn( ctx, "envSet", - function (key: any, value: any, options: any) { + function (key: SandboxValue, value: SandboxValue, options: SandboxValue) { return envMethods.pw.set(key, value, options) } ), envUnset: defineSandboxFn( ctx, "envUnset", - function (key: any, options: any) { + function (key: SandboxValue, options: SandboxValue) { return envMethods.pw.unset(key, options) } ), - envResolve: defineSandboxFn(ctx, "envResolve", function (key: any) { - return envMethods.pw.resolve(key) - }), + envResolve: defineSandboxFn( + ctx, + "envResolve", + function (key: SandboxValue) { + return envMethods.pw.resolve(key) + } + ), // Request variable operations getRequestVariable: defineSandboxFn( ctx, "getRequestVariable", - function (key: any) { + function (key: SandboxValue) { const reqVarEntry = requestProps.requestVariables.find( - (reqVar: any) => reqVar.key === key + (reqVar: SandboxValue) => reqVar.key === key ) return reqVarEntry ? reqVarEntry.value : null } diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts index 59db7e1c..8cb33eac 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts @@ -6,10 +6,12 @@ import { defineSandboxObject, } from "faraday-cage/modules" +import { getStatusReason } from "~/constants/http-status-codes" import { TestDescriptor, TestResponse, TestResult } from "~/types" import postRequestBootstrapCode from "../bootstrap-code/post-request?raw" import preRequestBootstrapCode from "../bootstrap-code/pre-request?raw" import { createBaseInputs } from "./utils/base-inputs" +import { createChaiMethods } from "./utils/chai-helpers" import { createExpectationMethods } from "./utils/expectation-helpers" import { createRequestSetterMethods } from "./utils/request-setters" @@ -125,20 +127,21 @@ const createScriptingInputsObj = ( type: ModuleType, config: ModuleConfig ) => { - // Create base inputs shared across all namespaces - const baseInputs = createBaseInputs(ctx, { - envs: config.envs, - request: config.request, - cookies: config.cookies, - }) - if (type === "pre") { const preConfig = config as PreRequestModuleConfig - // Create request setter methods for pre-request scripts + // Create request setter methods FIRST for pre-request scripts const { methods: requestSetterMethods, getUpdatedRequest } = createRequestSetterMethods(ctx, preConfig.request) + // Create base inputs with access to updated request + const baseInputs = createBaseInputs(ctx, { + envs: config.envs, + request: config.request, + cookies: config.cookies, + getUpdatedRequest, // Pass the updater function for pre-request + }) + // Register hook with helper function registerAfterScriptExecutionHook(ctx, "pre", preConfig, baseInputs, { getUpdatedRequest, @@ -150,6 +153,13 @@ const createScriptingInputsObj = ( } } + // Create base inputs shared across all namespaces (post-request path) + const baseInputs = createBaseInputs(ctx, { + envs: config.envs, + request: config.request, + cookies: config.cookies, + }) + if (type === "post") { const postConfig = config as PostRequestModuleConfig @@ -159,20 +169,24 @@ const createScriptingInputsObj = ( postConfig.testRunStack ) + // Create Chai methods + const chaiMethods = createChaiMethods(ctx, postConfig.testRunStack) + // Register hook with helper function registerAfterScriptExecutionHook(ctx, "post", postConfig, baseInputs) return { ...baseInputs, ...expectationMethods, + ...chaiMethods, // Test management methods preTest: defineSandboxFn( ctx, "preTest", - function preTest(descriptor: any) { + function preTest(descriptor: unknown) { postConfig.testRunStack.push({ - descriptor, + descriptor: descriptor as string, expectResults: [], children: [], }) @@ -187,6 +201,90 @@ const createScriptingInputsObj = ( getResponse: defineSandboxFn(ctx, "getResponse", function getResponse() { return postConfig.response }), + // Response utility methods as cage functions + responseReason: defineSandboxFn( + ctx, + "responseReason", + function responseReason() { + return getStatusReason(postConfig.response.status) + } + ), + responseDataURI: defineSandboxFn( + ctx, + "responseDataURI", + function responseDataURI() { + try { + const body = postConfig.response.body + const contentType = + postConfig.response.headers.find( + (h) => h.key.toLowerCase() === "content-type" + )?.value || "application/octet-stream" + + // Convert body to base64 (browser and Node.js compatible) + let base64Body: string + const bodyString = typeof body === "string" ? body : String(body) + + // Check if we're in a browser environment (btoa available) + if (typeof btoa !== "undefined") { + // Browser environment: use btoa + // btoa requires binary string, so we need to handle UTF-8 properly + const utf8Bytes = new TextEncoder().encode(bodyString) + const binaryString = Array.from(utf8Bytes, (byte) => + String.fromCharCode(byte) + ).join("") + base64Body = btoa(binaryString) + } else if (typeof Buffer !== "undefined") { + // Node.js environment: use Buffer + base64Body = Buffer.from(bodyString).toString("base64") + } else { + throw new Error("No base64 encoding method available") + } + + return `data:${contentType};base64,${base64Body}` + } catch (error) { + throw new Error(`Failed to convert response to data URI: ${error}`) + } + } + ), + responseJsonp: defineSandboxFn( + ctx, + "responseJsonp", + function responseJsonp(...args: unknown[]) { + const callbackName = args[0] + const body = postConfig.response.body + const text = typeof body === "string" ? body : String(body) + + if (callbackName && typeof callbackName === "string") { + // Escape special regex characters in callback name + const escapedName = callbackName.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&" + ) + const regex = new RegExp( + `^\\s*${escapedName}\\s*\\(([\\s\\S]*)\\)\\s*;?\\s*$` + ) + const match = text.match(regex) + if (match && match[1]) { + return JSON.parse(match[1]) + } + } + + // Auto-detect callback wrapper + const autoDetect = text.match( + /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(([\s\S]*)\)\s*;?\s*$/ + ) + if (autoDetect && autoDetect[2]) { + try { + return JSON.parse(autoDetect[2]) + } catch { + // If parsing fails, fall through to plain JSON + } + } + + // No JSONP wrapper found, parse as plain JSON + return JSON.parse(text) + } + ), } } diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts index fa32f57a..b6238f3f 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/base-inputs.ts @@ -1,12 +1,13 @@ import { Cookie, HoppRESTRequest } from "@hoppscotch/data" import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" -import { TestResult, BaseInputs } from "~/types" +import { TestResult, BaseInputs, SandboxValue } from "~/types" import { getSharedCookieMethods, getSharedEnvMethods, getSharedRequestProps, } from "~/utils/shared" +import { UNDEFINED_MARKER, NULL_MARKER } from "~/constants/sandbox-markers" import { createHoppNamespaceMethods } from "../namespaces/hopp-namespace" import { createPmNamespaceMethods } from "../namespaces/pm-namespace" import { createPwNamespaceMethods } from "../namespaces/pw-namespace" @@ -15,6 +16,7 @@ type BaseInputsConfig = { envs: TestResult["envs"] request: HoppRESTRequest cookies: Cookie[] | null + getUpdatedRequest?: () => HoppRESTRequest } /** @@ -25,56 +27,146 @@ export const createBaseInputs = ( config: BaseInputsConfig ): BaseInputs => { // Get environment methods - Applicable to both hopp and pw namespaces - const { methods: envMethods, updatedEnvs } = getSharedEnvMethods( - config.envs, - true - ) + const { + methods: envMethods, + pmSetAny, + updatedEnvs, + } = getSharedEnvMethods(config.envs, true) const { methods: cookieMethods, getUpdatedCookies } = getSharedCookieMethods( config.cookies ) // Get request properties - shared across pre and post request contexts - const requestProps = getSharedRequestProps(config.request) + // For pre-request, use the updater function to read from mutated request + const requestProps = getSharedRequestProps( + config.request, + config.getUpdatedRequest + ) // Cookie accessors const cookieProps = { - cookieGet: defineSandboxFn(ctx, "cookieGet", (domain: any, name: any) => { - return cookieMethods.get(domain, name) || null - }), - cookieSet: defineSandboxFn(ctx, "cookieSet", (domain: any, cookie: any) => { - return cookieMethods.set(domain, cookie) - }), - cookieHas: defineSandboxFn(ctx, "cookieHas", (domain: any, name: any) => { - return cookieMethods.has(domain, name) - }), - cookieGetAll: defineSandboxFn(ctx, "cookieGetAll", (domain: any) => { - return cookieMethods.getAll(domain) - }), + cookieGet: defineSandboxFn( + ctx, + "cookieGet", + (domain: SandboxValue, name: SandboxValue) => { + return cookieMethods.get(domain, name) || null + } + ), + cookieSet: defineSandboxFn( + ctx, + "cookieSet", + (domain: SandboxValue, cookie: SandboxValue) => { + return cookieMethods.set(domain, cookie) + } + ), + cookieHas: defineSandboxFn( + ctx, + "cookieHas", + (domain: SandboxValue, name: SandboxValue) => { + return cookieMethods.has(domain, name) + } + ), + cookieGetAll: defineSandboxFn( + ctx, + "cookieGetAll", + (domain: SandboxValue) => { + return cookieMethods.getAll(domain) + } + ), cookieDelete: defineSandboxFn( ctx, "cookieDelete", - (domain: any, name: any) => { + (domain: SandboxValue, name: SandboxValue) => { return cookieMethods.delete(domain, name) } ), - cookieClear: defineSandboxFn(ctx, "cookieClear", (domain: any) => { + cookieClear: defineSandboxFn(ctx, "cookieClear", (domain: SandboxValue) => { return cookieMethods.clear(domain) }), } + // Environment accessors for toObject() support + const envAccessors = { + getAllSelectedEnvs: defineSandboxFn(ctx, "getAllSelectedEnvs", () => { + return updatedEnvs.selected || [] + }), + getAllGlobalEnvs: defineSandboxFn(ctx, "getAllGlobalEnvs", () => { + return updatedEnvs.global || [] + }), + } + // Combine all namespace methods const pwMethods = createPwNamespaceMethods(ctx, envMethods, requestProps) const hoppMethods = createHoppNamespaceMethods(ctx, envMethods, requestProps) const pmMethods = createPmNamespaceMethods(ctx, config) + // PM namespace-specific setter that accepts any type (for type preservation) + const pmEnvSetAny = defineSandboxFn( + ctx, + "pmEnvSetAny", + function (key: SandboxValue, value: SandboxValue, options: SandboxValue) { + return pmSetAny(key, value, options) + } + ) + return { ...pwMethods, ...hoppMethods, ...pmMethods, ...cookieProps, + ...envAccessors, + // PM-specific env setter (preserves all types) + pmEnvSetAny, // Expose the updated state accessors - getUpdatedEnvs: () => updatedEnvs, + getUpdatedEnvs: () => { + // Convert markers back to strings for UI display + // (using centralized markers from constants/sandbox-markers.ts) + + // Handle case where envs is not provided + if (!updatedEnvs) { + return { global: [], selected: [] } + } + + const convertMarkersToStrings = (env: SandboxValue) => { + const convertValue = (value: SandboxValue) => { + // Convert markers to string representations + if (value === UNDEFINED_MARKER) return "undefined" + if (value === NULL_MARKER) return "null" + + // Convert complex types (arrays, objects) to JSON strings for UI display + // This prevents Vue UI from calling .match() on non-string values + if (typeof value === "object" && value !== null) { + try { + return JSON.stringify(value) + } catch (_) { + // If JSON.stringify fails (circular refs, etc.), return string representation + return String(value) + } + } + + // Convert all non-string primitives to strings for UI compatibility + // Vue UI calls .match() on values, which only works on strings + if (typeof value !== "string") { + return String(value) + } + + // Return strings as-is + return value + } + + return { + ...env, + currentValue: convertValue(env.currentValue), + initialValue: convertValue(env.initialValue), + } + } + + return { + global: (updatedEnvs.global || []).map(convertMarkersToStrings), + selected: (updatedEnvs.selected || []).map(convertMarkersToStrings), + } + }, getUpdatedCookies, } } diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts new file mode 100644 index 00000000..dfbe1222 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/chai-helpers.ts @@ -0,0 +1,2126 @@ +import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" +import * as chai from "chai" +import { TestDescriptor, SandboxValue } from "~/types" + +/** + * Creates Chai-based assertion methods that can be used across the sandbox boundary + * Each method wraps actual Chai.js assertions and records results to the test stack + */ +export const createChaiMethods: ( + ctx: CageModuleCtx, + testStack: TestDescriptor[] +) => Record = (ctx, testStack) => { + /** + * Helper to execute a Chai assertion and record the result + */ + const executeChaiAssertion = (assertionFn: () => void, message: string) => { + if (testStack.length === 0) return + + try { + assertionFn() + // Record success + testStack[testStack.length - 1].expectResults.push({ + status: "pass", + message, + }) + } catch (_error: any) { + // Record failure but DON'T throw - allow test to continue + testStack[testStack.length - 1].expectResults.push({ + status: "fail", + message, + }) + // Don't throw - let the test continue to execute all assertions + } + } + + /** + * Helper to format values for display in messages + */ + // Helper to apply modifiers (not, deep, include, etc.) to a Chai assertion + const applyModifiers = (value: SandboxValue, modifiers: string) => { + let assertion: any = chai.expect(value) + const isNot = modifiers.includes("not") + const isDeep = modifiers.includes("deep") + const isInclude = modifiers.includes("include") + + if (isNot) assertion = assertion.to.not + else assertion = assertion.to + + if (isInclude) assertion = assertion.include + if (isDeep) assertion = assertion.deep + + return assertion + } + + const formatValue = (val: unknown): string => { + if (val === null) return "null" + if (val === undefined) return "undefined" + // Handle BigInt + if (typeof val === "bigint") return String(val) + "n" + // Handle Symbol + if (typeof val === "symbol") return String(val) + // Handle functions (including constructors) - return as-is without quotes + if (typeof val === "function") { + // For named constructors, return just the name + if (val.name && /^[A-Z]/.test(val.name)) { + return val.name + } + // For other functions, return the string representation without quotes + return String(val) + } + // Handle strings that look like functions/constructors (serialized from sandbox) + if (typeof val === "string") { + const trimmed = val.trim() + + // Check for pre-formatted special values (Set, Map, RegExp patterns) + if (trimmed.startsWith("new Set(") || trimmed.startsWith("new Map(")) { + return val // Return without quotes - already formatted + } + if (trimmed.match(/^\/.*\/[gimsuvy]*$/)) { + return val // Return regex pattern without quotes - already formatted + } + + // Check for constructor names (capitalized identifiers) + // Only match known built-in constructors to avoid matching regular strings like "Alice" + const knownConstructors = [ + "Array", + "Object", + "String", + "Number", + "Boolean", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "ReferenceError", + "SyntaxError", + "Set", + "Map", + "WeakSet", + "WeakMap", + "Promise", + "Symbol", + "Function", + ] + // Also allow any identifier ending with "Error" (for custom error types) + const isErrorConstructor = + /^[A-Z][a-zA-Z0-9]*Error$/.test(trimmed) || + knownConstructors.includes(trimmed) + + if (/^[A-Z][a-zA-Z0-9]*$/.test(trimmed) && isErrorConstructor) { + return trimmed // Return constructor name without quotes + } + + // Check if string looks like a function definition + if ( + trimmed.startsWith("function") || + trimmed.match(/^\(.*\)\s*=>/) || + trimmed.match(/^[a-zA-Z_$][\w$]*\s*\(/) + ) { + // Keep the original function structure instead of simplifying to [Function] + // This preserves function signatures like "function Cat() {}" in messages + return trimmed + } + + // Check for native code functions + if (trimmed.includes("[native code]")) { + // Extract function name from "function TypeEr[ror() {\n [native code]\n}" + const nameMatch = trimmed.match(/function\s+([A-Z][a-zA-Z0-9]*)\s*\(/) + if (nameMatch) { + return nameMatch[1] + } + } + + return `'${val}'` + } + if (typeof val === "number") { + if (isNaN(val)) return "NaN" + if (val === Infinity) return "Infinity" + if (val === -Infinity) return "-Infinity" + if (val === Math.PI) return "Math.PI" + if (val === Math.E) return "Math.E" + return String(val) + } + if (typeof val === "boolean") return String(val) + if (Array.isArray(val)) { + if (val.length === 0) return "[]" + const items = val.slice(0, 10).map(formatValue) + return `[${items.join(", ")}]` // Space after comma for readability + } + if (typeof val === "object") { + try { + // Handle special object types + if (val instanceof Map) { + const entries = Array.from(val.entries()).slice(0, 3) + if (entries.length === 0) return "new Map()" + // Fix Map formatting for .map((entry: unknown) => ...) + const formatted = entries.map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + return `new Map([${formatted.join(", ")}])` + } + if (val instanceof Set) { + const values = Array.from(val).slice(0, 10) + if (values.length === 0) return "new Set()" + return `new Set([${values.map(formatValue).join(", ")}])` + } + if (val instanceof Date) return `new Date(${val.toISOString()})` + if (val instanceof RegExp) return val.toString() + + // Check constructor name for objects that lost their prototype + const constructorName = (val as { constructor?: { name?: string } }) + .constructor?.name + if (constructorName && constructorName !== "Object") { + // Special handling for Set/Map that lost prototype but have size property + const objWithSize = val as { size?: unknown } + if ( + constructorName === "Set" && + typeof objWithSize.size === "number" + ) { + return `new Set()` + } + if ( + constructorName === "Map" && + typeof objWithSize.size === "number" + ) { + return `new Map()` + } + // Special handling for RegExp that lost prototype + const objWithRegex = val as { source?: unknown; flags?: unknown } + if ( + constructorName === "RegExp" && + typeof objWithRegex.source === "string" + ) { + const flags = objWithRegex.flags || "" + return `/${objWithRegex.source}/${flags}` + } + return `new ${constructorName}()` + } + + // Check if it's a RegExp that lost its prototype + const objWithRegex = val as { source?: unknown; flags?: unknown } + if (typeof objWithRegex.source === "string") { + const flags = objWithRegex.flags || "" + return `/${objWithRegex.source}/${flags}` + } + + // Check if it's a Set or Map that lost its prototype + const objWithMethods = val as { + size?: unknown + has?: unknown + forEach?: unknown + } + if ( + typeof objWithMethods.size === "number" && + typeof objWithMethods.has === "function" + ) { + // Likely a Set or Map + return objWithMethods.forEach ? "new Set()" : "new Map()" + } + + const keys = Object.keys(val) + if (keys.length === 0) return "{}" + const pairs = keys + .slice(0, 5) + .map( + (k) => `${k}: ${formatValue((val as Record)[k])}` + ) + return `{${pairs.join(", ")}}` + } catch { + return "[object Object]" + } + } + if (typeof val === "function") { + return val.name || "[Function]" + } + return String(val) + } + + /** + * Build message with modifiers + * Cleans up duplicate words and formats properly + */ + const buildMessage = ( + value: SandboxValue, + modifiers: string, + assertion: string, + args: unknown[] = [] + ): string => { + const valueStr = formatValue(value) + + // Clean up modifiers + let cleanModifiers = modifiers + .replace(/\s+/g, " ") // normalize whitespace + .trim() + + // Remove language chain words that don't add meaning to assertions + // Handle "that has" and "that does" carefully + const hasTypePrefix = cleanModifiers.match( + /\b(array|object|number|string|boolean|function)\s+that\s+has\b/ + ) + + if (!hasTypePrefix) { + // Standalone "that has" -> "have", "that does" -> "" + cleanModifiers = cleanModifiers.replace(/\bthat\s+has\b/g, "have") + cleanModifiers = cleanModifiers.replace(/\bthat\s+does\b/g, "") + // Replace standalone "has" with "have" + cleanModifiers = cleanModifiers.replace(/\bhas\b/g, "have") + } else { + // Keep "that has" when preceded by a type (e.g., "be an array that has lengthOf") + // Just remove "that does" + cleanModifiers = cleanModifiers.replace(/\bthat\s+does\b/g, "") + } + + // Replace "is" with "be" + cleanModifiers = cleanModifiers.replace(/\bis\b/g, "be") + + // Remove remaining pure language chains (but keep "itself" as it's meaningful) + const languageChains = ["which", "does", "but"] + languageChains.forEach((word) => { + cleanModifiers = cleanModifiers.replace( + new RegExp(`\\b${word}\\b`, "g"), + "" + ) + }) + + // Clean up extra spaces after removing words + cleanModifiers = cleanModifiers.replace(/\s+/g, " ").trim() + + // Remove duplicate consecutive words (e.g., "be be" -> "be", "have have" -> "have") + cleanModifiers = cleanModifiers.replace(/\b(\w+)(\s+\1\b)+/g, "$1") + + // Ensure it starts with "to" if not empty + if (cleanModifiers && !cleanModifiers.startsWith("to")) { + cleanModifiers = "to " + cleanModifiers + } else if (!cleanModifiers) { + cleanModifiers = "to" + } + + // Build base message + let message = `Expected ${valueStr} ${cleanModifiers}` + + // Add assertion, checking if it duplicates the last word in modifiers + const modifierWords = cleanModifiers.trim().split(/\s+/) + const lastModWord = modifierWords[modifierWords.length - 1] + const firstAssertWord = assertion.split(/\s+/)[0] + + if (lastModWord === firstAssertWord) { + // Skip the duplicate word in assertion + const assertionRest = assertion.substring(firstAssertWord.length).trim() + if (assertionRest) { + message += ` ${assertionRest}` + } + } else { + message += ` ${assertion}` + } + + if (args.length > 0) { + // Special handling for keys assertions + if (assertion === "keys") { + let keys = args + // If first arg is an array and it's the only arg, flatten it + if (args.length === 1 && Array.isArray(args[0])) { + keys = args[0] + } + // Format keys with quotes (including numbers) + const keyStrs = keys.map((k) => + typeof k === "number" ? `'${k}'` : formatValue(k) + ) + message += ` ${keyStrs.join(", ")}` + } else if (assertion === "members") { + // Format members - if first arg is already an array, don't double-wrap + if (args.length === 1 && Array.isArray(args[0])) { + // Single array argument - format its contents directly + const memberStrs = args[0].map(formatValue) + message += ` [${memberStrs.join(", ")}]` + } else { + // Multiple arguments or non-array - wrap them + const argStrs = args.map(formatValue) + message += ` [${argStrs.join(", ")}]` + } + } else { + const argStrs = args.map(formatValue) + // Add comma separator for property-like assertions, space for others + const separator = assertion.includes("property") ? ", " : " " + message += `${separator}${argStrs.join(", ")}` + } + } + + return message + } + + // Return all Chai methods wrapped with defineSandboxFn + return { + // Equality assertions + chaiEqual: defineSandboxFn(ctx, "chaiEqual", (( + value: SandboxValue, + expected: SandboxValue, + modifiers: SandboxValue, + methodName: SandboxValue, + isSameReference?: boolean, + typeInfo?: SandboxValue + ) => { + const mods = modifiers || " to" + const isDeep = String(mods).includes("deep") + + // PRE-CHECK PATTERN: Use reference equality check from sandbox + // ONLY applies to .equal() WITHOUT .deep modifier + // Special handling: Date/RegExp have typeInfo even after serialization to strings + if ( + !isDeep && + isSameReference !== undefined && + (typeInfo || + (typeof value === "object" && + value !== null && + typeof expected === "object" && + expected !== null)) + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !isSameReference : isSameReference + + // For Date/RegExp, use type info to display proper values + let displayValue = formatValue(value) + let displayExpected = formatValue(expected) + + if (typeInfo) { + const info = typeInfo as { + type?: string + valueTime?: unknown + expectedTime?: unknown + valueSource?: unknown + valueFlags?: unknown + expectedSource?: unknown + expectedFlags?: unknown + } + if (info.type === "Date") { + displayValue = new Date(info.valueTime as number).toISOString() + displayExpected = new Date( + info.expectedTime as number + ).toISOString() + } else if (info.type === "RegExp") { + displayValue = `/${info.valueSource}/${info.valueFlags}` + displayExpected = `/${info.expectedSource}/${info.expectedFlags}` + } + } + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${displayValue}${String(mods)} ${String(methodName || "equal")} ${displayExpected}` + ) + } + }, + buildMessage( + displayValue, + String(mods), + String(methodName || "equal"), + [displayExpected] + ) + ) + } else { + // For primitives, .deep.equal(), or when reference info not available, use default Chai behavior + executeChaiAssertion( + () => applyModifiers(value, mods).equal(expected), + buildMessage(value, String(mods), methodName || "equal", [expected]) + ) + } + }) as any), + + chaiEql: defineSandboxFn(ctx, "chaiEql", (( + value: SandboxValue, + expected: SandboxValue, + modifiers?: SandboxValue, + valueMetadata?: SandboxValue, + expectedMetadata?: SandboxValue + ) => { + const mods = modifiers || " to" + + // PRE-CHECK PATTERN: Use metadata for special objects (RegExp, Date) + if (valueMetadata && expectedMetadata) { + const valueMeta = valueMetadata as { + type?: string + source?: unknown + flags?: unknown + time?: unknown + } + const expectedMeta = expectedMetadata as { + type?: string + source?: unknown + flags?: unknown + time?: unknown + } + const isNegated = String(mods).includes("not") + let matches = false + + if (valueMeta.type === "RegExp") { + // Compare RegExp by source and flags + matches = + valueMeta.source === expectedMeta.source && + valueMeta.flags === expectedMeta.flags + } else if (valueMeta.type === "Date") { + // Compare Date by timestamp + matches = valueMeta.time === expectedMeta.time + } + + const shouldPass = isNegated ? !matches : matches + + executeChaiAssertion( + () => { + if (!shouldPass) { + const displayValue = + valueMeta.type === "RegExp" + ? `/${valueMeta.source}/${valueMeta.flags}` + : new Date(valueMeta.time as number).toISOString() + const displayExpected = + expectedMeta.type === "RegExp" + ? `/${expectedMeta.source}/${expectedMeta.flags}` + : new Date(expectedMeta.time as number).toISOString() + + throw new Error( + `Expected ${displayValue}${String(mods)} eql ${displayExpected}` + ) + } + }, + valueMeta.type === "RegExp" + ? buildMessage( + `/${valueMeta.source}/${valueMeta.flags}`, + mods, + "eql", + [`/${expectedMeta.source}/${expectedMeta.flags}`] + ) + : buildMessage(value, mods, "eql", [expected]) + ) + } else { + // Default behavior for non-special objects + executeChaiAssertion( + () => applyModifiers(value, mods).eql(expected), + buildMessage(value, String(mods), "eql", [expected]) + ) + } + }) as any), + + chaiDeepEqual: defineSandboxFn( + ctx, + "chaiDeepEqual", + ( + value: SandboxValue, + expected: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).deep.equal(expected), + buildMessage(value, String(mods), "deep equal", [expected]) + ) + } + ), + + // Type assertions + chaiTypeOf: defineSandboxFn( + ctx, + "chaiTypeOf", + ( + value: SandboxValue, + type: SandboxValue, + modifiers?: SandboxValue, + preCheckedType?: unknown + ) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Use "an" for vowel sounds, "a" otherwise + const article = /^[aeiou]/i.test(type) ? "an" : "a" + + // PRE-CHECK PATTERN: Use pre-checked typeof result from sandbox + // After serialization, functions may not be recognized as functions by Chai + if (preCheckedType !== undefined) { + let matches = String(preCheckedType) === String(type) + + // Special case: Arrays are both 'array' and 'object' in JavaScript + // typeof [] === 'object', but Chai also recognizes 'array' + if (preCheckedType === "array" && type === "object") { + matches = true // Arrays should pass for both 'array' and 'object' + } + + const shouldPass = isNegated ? !matches : matches + + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, `${article} ${type}`), + }) + } else { + // Fallback to Chai's type checking if no pre-check available + executeChaiAssertion( + () => applyModifiers(value, mods).be.a(type), + buildMessage(value, String(mods), `${article} ${type}`) + ) + } + } + ), + + chaiInstanceOf: defineSandboxFn( + ctx, + "chaiInstanceOf", + function ( + value, + constructorName, + modifiers, + preCheckedType, + displayValue, + actualInstanceCheck + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // PRE-CHECK PATTERN: Use Object.prototype.toString result from sandbox + // We receive the constructor NAME as a string, not the constructor itself + // Map of constructor names to their Object.prototype.toString signatures + const builtInTypes: Record = { + Array: "[object Array]", + Date: "[object Date]", + Error: "[object Error]", + RegExp: "[object RegExp]", + Set: "[object Set]", + Map: "[object Map]", + TypeError: "[object Error]", + RangeError: "[object Error]", + ReferenceError: "[object Error]", + SyntaxError: "[object Error]", + } + + // constructorName is already a string from the sandbox + const constructorNameStr = String(constructorName) + + // Check if this is a built-in type we can detect + const expectedType = builtInTypes[constructorNameStr] + const isBuiltIn = expectedType !== undefined + + // Use pre-checked type if available, otherwise fall back to actual check + const actualType = + preCheckedType || Object.prototype.toString.call(value) + + let isInstance: boolean + // Special case: Object constructor + // In JavaScript, all objects (arrays, plain objects, dates, etc.) are instanceof Object + // Check if value is an object type (not null, not undefined, not primitives) + if (constructorNameStr === "Object") { + const valueType = typeof value + const isObjectType = + value !== null && + value !== undefined && + (valueType === "object" || valueType === "function") + isInstance = isObjectType + } else if (isBuiltIn) { + // For built-in types, compare Object.prototype.toString results + isInstance = actualType === expectedType + } else { + // For custom types, use the pre-checked result from sandbox + // The instanceof check was performed before serialization + isInstance = Boolean(actualInstanceCheck) + } + + const shouldPass = isNegated ? !isInstance : isInstance + + // Use displayValue if provided (for Set/Map), otherwise use value + const valueToDisplay = displayValue || value + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(valueToDisplay)}${String(mods)} be an instanceof ${constructorNameStr}` + ) + } + }, + buildMessage(valueToDisplay, String(mods), `be an instanceof`, [ + constructorNameStr, + ]) + ) + } + ), + + // Truthiness assertions + chaiOk: defineSandboxFn( + ctx, + "chaiOk", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.ok + }, + buildMessage(value, String(mods), "ok") + ) + } + ), + + chaiTrue: defineSandboxFn( + ctx, + "chaiTrue", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.true + }, + buildMessage(value, String(mods), "true") + ) + } + ), + + chaiFalse: defineSandboxFn( + ctx, + "chaiFalse", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.false + }, + buildMessage(value, String(mods), "false") + ) + } + ), + + // Nullish assertions + chaiNull: defineSandboxFn( + ctx, + "chaiNull", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.null + }, + buildMessage(value, String(mods), "null") + ) + } + ), + + chaiUndefined: defineSandboxFn( + ctx, + "chaiUndefined", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.undefined + }, + buildMessage(value, String(mods), "undefined") + ) + } + ), + + chaiNaN: defineSandboxFn( + ctx, + "chaiNaN", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.NaN + }, + buildMessage(value, String(mods), "NaN") + ) + } + ), + + chaiExist: defineSandboxFn( + ctx, + "chaiExist", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.exist + }, + buildMessage(value, String(mods), "exist") + ) + } + ), + + // Emptiness assertions + chaiEmpty: defineSandboxFn( + ctx, + "chaiEmpty", + ( + value: SandboxValue, + modifiers?: SandboxValue, + typeName?: unknown, + actualSize?: unknown + ) => { + const mods = modifiers || " to" + // Special handling for Set/Map + let isEmpty = false + let displayValue = value + + if (typeName === "Set" || typeName === "Map") { + // Use pre-checked size from sandbox + isEmpty = actualSize === 0 + if (actualSize === 0) { + displayValue = "{}" + } else { + // After serialization, Set/Map become {}, but we have the info they weren't empty + displayValue = value + } + } else if (value instanceof Set) { + isEmpty = value.size === 0 + displayValue = + value.size === 0 + ? "new Set()" + : `new Set([${Array.from(value).join(", ")}])` + } else if (value instanceof Map) { + isEmpty = value.size === 0 + displayValue = + value.size === 0 + ? "new Map()" + : `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + } else if (Array.isArray(value)) { + isEmpty = value.length === 0 + } else if (typeof value === "object" && value !== null) { + isEmpty = Object.keys(value).length === 0 + } else if (typeof value === "string") { + isEmpty = value.length === 0 + } + const isNegated = String(mods).includes("not") + const pass = isNegated ? !isEmpty : isEmpty + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, "empty"), + }) + } + ), + + // Inclusion assertions + chaiInclude: defineSandboxFn( + ctx, + "chaiInclude", + (value: SandboxValue, item: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).include(item), + buildMessage(value, String(mods), "include", [item]) + ) + } + ), + + chaiDeepInclude: defineSandboxFn( + ctx, + "chaiDeepInclude", + (value: SandboxValue, item: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).deep.include(item), + buildMessage(value, String(mods), "deep include", [item]) + ) + } + ), + + chaiIncludeKeys: defineSandboxFn( + ctx, + "chaiIncludeKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.include.keys(...keys) + }, + buildMessage(value, String(mods), "include keys", keys) + ) + } + ), + + // Length assertions + chaiLengthOf: defineSandboxFn( + ctx, + "chaiLengthOf", + function ( + value: SandboxValue, + length: unknown, + modifiers: SandboxValue, + methodName: SandboxValue, + actualSize?: unknown, + typeName?: unknown + ) { + const mods = (modifiers || " to") as string + const assertion = mods.trim().endsWith("has") + ? methodName || "lengthOf" + : `have ${methodName || "lengthOf"}` + if (actualSize !== undefined && typeName) { + if (testStack.length === 0) return + const matches = Number(actualSize) === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + let displayValue = value + if (typeof typeName === "string") { + if (typeName.includes("Set")) { + displayValue = `new Set([${Array.from(value).join(", ")}])` + } else if (typeName.includes("Map")) { + displayValue = `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + } + } + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else if (value instanceof Set) { + if (testStack.length === 0) return + const matches = value.size === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + const displayValue = `new Set([${Array.from(value).join(", ")}])` + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else if (value instanceof Map) { + if (testStack.length === 0) return + const matches = value.size === Number(length) + const negated = mods.includes("not") + const pass = negated ? !matches : matches + const displayValue = `new Map([${Array.from(value.entries()) + .map((entry: unknown) => { + const [key, value] = entry as [unknown, unknown] + return `[${key}, ${value}]` + }) + .join(", ")}])` + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(displayValue, mods, assertion, [length]), + }) + } else { + // Handle negation for regular arrays/strings + const negated = mods.includes("not") + executeChaiAssertion( + () => { + const expectChain = chai.expect(value) + if (negated) { + expectChain.to.not.have.lengthOf(Number(length)) + } else { + expectChain.to.have.lengthOf(Number(length)) + } + }, + buildMessage(value, String(mods), assertion, [length]) + ) + } + } + ), + + // Property assertions + chaiProperty: defineSandboxFn(ctx, "chaiProperty", (( + value: SandboxValue, + property: SandboxValue, + propertyValue?: SandboxValue, + modifiers?: SandboxValue, + hasProperty?: boolean + ) => { + const mods = modifiers || " to" + const hasValue = propertyValue !== undefined + + // PRE-CHECK PATTERN: Use property existence check from sandbox + // ONLY when checking existence (no value assertion) + if ( + !hasValue && + hasProperty !== undefined && + typeof value === "object" && + value !== null + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !hasProperty : hasProperty + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} have property '${property}'` + ) + } + }, + buildMessage(value, String(mods), `property '${property}'`) + ) + } else { + // For primitives, value assertions, or when property existence info not available + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (hasValue) { + assertion.have.property(property, propertyValue) + } else { + assertion.have.property(property) + } + }, + hasValue + ? buildMessage(value, mods, `property '${property}'`, [ + propertyValue, + ]) + : buildMessage(value, mods, `property '${property}'`) + ) + } + }) as any), + + chaiOwnProperty: defineSandboxFn(ctx, "chaiOwnProperty", (( + value: SandboxValue, + property: SandboxValue, + modifiers?: SandboxValue, + isOwnProperty?: boolean + ) => { + const mods = modifiers || " to" + + // PRE-CHECK PATTERN: Use hasOwnProperty check from sandbox + // When prototype chain info is available, use it directly + if ( + isOwnProperty !== undefined && + typeof value === "object" && + value !== null + ) { + const isNegated = String(mods).includes("not") + const shouldPass = isNegated ? !isOwnProperty : isOwnProperty + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} have own property '${property}'` + ) + } + }, + buildMessage(value, String(mods), `own property '${property}'`) + ) + } else { + // For primitives or when hasOwnProperty info not available, use default Chai behavior + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.own.property(property) + }, + buildMessage(value, String(mods), `own property '${property}'`) + ) + } + }) as any), + + chaiDeepOwnProperty: defineSandboxFn( + ctx, + "chaiDeepOwnProperty", + ( + value: SandboxValue, + property: SandboxValue, + propertyValue: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => + chai + .expect(value) + .to.have.deep.own.property(property, propertyValue), + buildMessage(value, String(mods), `property '${property}'`, [ + propertyValue, + ]) + ) + } + ), + + chaiNestedProperty: defineSandboxFn( + ctx, + "chaiNestedProperty", + ( + value: SandboxValue, + property: SandboxValue, + propertyValue?: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + const hasValue = propertyValue !== undefined + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (hasValue) { + assertion.have.nested.property(property, propertyValue) + } else { + assertion.have.nested.property(property) + } + }, + hasValue + ? buildMessage(value, mods, `property '${property}'`, [ + propertyValue, + ]) + : buildMessage(value, mods, `property '${property}'`) + ) + } + ), + + chaiNestedInclude: defineSandboxFn( + ctx, + "chaiNestedInclude", + (value: SandboxValue, obj: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.nested.include(obj) + }, + buildMessage(value, String(mods), "nested include", [obj]) + ) + } + ), + + // Numerical comparisons + chaiAbove: defineSandboxFn( + ctx, + "chaiAbove", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.above(n), + buildMessage(value, String(mods), "above", [n]) + ) + } + ), + + chaiBelow: defineSandboxFn( + ctx, + "chaiBelow", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.below(n), + buildMessage(value, String(mods), "below", [n]) + ) + } + ), + + chaiAtLeast: defineSandboxFn( + ctx, + "chaiAtLeast", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.at.least(n), + buildMessage(value, String(mods), "at least", [n]) + ) + } + ), + + chaiAtMost: defineSandboxFn( + ctx, + "chaiAtMost", + (value: SandboxValue, n: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.at.most(n), + buildMessage(value, String(mods), "at most", [n]) + ) + } + ), + + chaiWithin: defineSandboxFn( + ctx, + "chaiWithin", + ( + value: SandboxValue, + start: unknown, + end: unknown, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => applyModifiers(value, mods).be.within(start, end), + buildMessage(value, String(mods), "within", [start, end]) + ) + } + ), + + chaiCloseTo: defineSandboxFn( + ctx, + "chaiCloseTo", + ( + value: SandboxValue, + expected: SandboxValue, + delta: unknown, + modifiers?: SandboxValue, + methodName?: unknown + ) => { + const mods = modifiers || " to" + const method = methodName || "closeTo" + executeChaiAssertion( + () => applyModifiers(value, mods).be.closeTo(expected, delta), + buildMessage(value, String(mods), String(method), [expected, delta]) + ) + } + ), + + // Keys assertions + chaiKeys: defineSandboxFn( + ctx, + "chaiKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + chaiAllKeys: defineSandboxFn( + ctx, + "chaiAllKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.all.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + chaiAnyKeys: defineSandboxFn( + ctx, + "chaiAnyKeys", + (value: SandboxValue, keys: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.any.keys(...keys) + }, + buildMessage(value, String(mods), "keys", keys) + ) + } + ), + + // String/Pattern matching + chaiMatch: defineSandboxFn( + ctx, + "chaiMatch", + function ( + value: SandboxValue, + pattern: unknown, + modifiers: SandboxValue, + regexSource?: unknown, + regexFlags?: unknown + ) { + const mods = modifiers || " to" + let actualPattern = pattern + let displayPattern = pattern + if (regexSource !== undefined) { + actualPattern = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + displayPattern = `/${regexSource}/${regexFlags || ""}` + } + const isNegated = String(mods).includes("not") + let matched = false + try { + matched = Boolean( + actualPattern instanceof RegExp + ? actualPattern.test(value) + : String(value).match(String(actualPattern)) + ) + } catch { + matched = false + } + const pass = isNegated ? !matched : matched + if (testStack.length === 0) return + const displayValue = typeof value === "string" ? value : String(value) + const notStr = isNegated ? " not" : "" + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: `Expected '${displayValue}' to${notStr} match ${displayPattern}`, + }) + } + ), + chaiString: defineSandboxFn( + ctx, + "chaiString", + function ( + value: SandboxValue, + substring: unknown, + modifiers?: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const valueStr = String(value) + const hasSubstring = valueStr.includes(String(substring)) + const shouldPass = isNegated ? !hasSubstring : hasSubstring + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "have string", [`'${substring}'`]), + }) + } + ), + + // Members assertions + chaiMembers: defineSandboxFn( + ctx, + "chaiMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.members(members) + }, + buildMessage(value, String(mods), "members", [members]) + ) + } + ), + + chaiIncludeMembers: defineSandboxFn( + ctx, + "chaiIncludeMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.include.members(members) + }, + buildMessage(value, String(mods), "include members", [members]) + ) + } + ), + + chaiOrderedMembers: defineSandboxFn( + ctx, + "chaiOrderedMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.ordered.members(members) + }, + buildMessage(value, String(mods), "members", [...members]) + ) + } + ), + + chaiDeepMembers: defineSandboxFn( + ctx, + "chaiDeepMembers", + ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + assertion.have.deep.members(members) + }, + buildMessage(value, String(mods), "members", [members]) + ) + } + ), + + // OneOf assertion + chaiOneOf: defineSandboxFn( + ctx, + "chaiOneOf", + (value: SandboxValue, list: unknown, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + const isInclude = String(mods).includes("include") + const assertion = isInclude ? "include oneOf" : "oneOf" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + if (isInclude) { + assertion.include.oneOf(list) + } else { + assertion.be.oneOf(list) + } + }, + buildMessage(value, String(mods), assertion, [list]) + ) + } + ), + + // Object state assertions + // PRE-CHECKING PATTERN: Check object state BEFORE serialization + // The pre-checking is done in post-request.js (sandbox context) and passed as a third parameter + // This is because after serialization, objects lose their frozen/sealed/extensible state + chaiExtensible: defineSandboxFn( + ctx, + "chaiExtensible", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isExtensible = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isExtensible(value) + const shouldPass = isNegated ? !isExtensible : isExtensible + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${mods} be extensible` + ) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "extensible" + ) + ) + } + ), + + chaiSealed: defineSandboxFn( + ctx, + "chaiSealed", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isSealed = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isSealed(value) + const shouldPass = isNegated ? !isSealed : isSealed + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error(`Expected ${formatValue(value)}${mods} be sealed`) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "sealed" + ) + ) + } + ), + + chaiFrozen: defineSandboxFn( + ctx, + "chaiFrozen", + (value, modifiers, preCheckedResult) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // Use the pre-checked result from sandbox (before serialization) + const isFrozen = + preCheckedResult !== undefined + ? preCheckedResult + : Object.isFrozen(value) + const shouldPass = isNegated ? !isFrozen : isFrozen + + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error(`Expected ${formatValue(value)}${mods} be frozen`) + } + }, + // For buildMessage calls for extensible, sealed, frozen, pass "{}" if value is an empty object, otherwise String(value) + buildMessage( + Object.keys(value as object).length === 0 ? "{}" : String(value), + String(mods), + "frozen" + ) + ) + } + ), + + // Number state assertions + chaiFinite: defineSandboxFn( + ctx, + "chaiFinite", + (value: SandboxValue, modifiers?: SandboxValue) => { + const mods = modifiers || " to" + executeChaiAssertion( + () => { + const assertion = applyModifiers(value, mods) + void assertion.be.finite + }, + buildMessage(value, String(mods), "finite") + ) + } + ), + + // Arguments object assertion + chaiArguments: defineSandboxFn( + ctx, + "chaiArguments", + ( + value: SandboxValue, + modifiers?: SandboxValue, + preCheckedResult?: unknown + ) => { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + const isArguments = + preCheckedResult !== undefined + ? preCheckedResult + : Object.prototype.toString.call(value) === "[object Arguments]" + const shouldPass = isNegated ? !isArguments : isArguments + // Extract "arguments" or "Arguments" from modifiers + const assertionName = + mods.match(/\b(arguments|Arguments)\b/)?.[1] || "arguments" + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, assertionName), + }) + } + ), + + // Property descriptor assertion + chaiOwnPropertyDescriptor: defineSandboxFn( + ctx, + "chaiOwnPropertyDescriptor", + function ( + _value, + prop, + descriptor, + modifiers, + hasDescriptor, + matchesExpected + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // PRE-CHECK PATTERN: Use pre-checked results from sandbox + // After serialization, property descriptors lose metadata + let shouldPass = false + + if (descriptor !== undefined) { + // When descriptor comparison is provided + if (isNegated) { + shouldPass = !hasDescriptor || !matchesExpected + } else { + shouldPass = Boolean(hasDescriptor && matchesExpected) + } + } else { + // When only checking for existence + if (isNegated) { + shouldPass = !hasDescriptor + } else { + shouldPass = Boolean(hasDescriptor) + } + } + + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected {}${mods} ownPropertyDescriptor '${prop}'`, + }) + } + ), + + // Include deep ordered members + chaiIncludeDeepOrderedMembers: defineSandboxFn( + ctx, + "chaiIncludeDeepOrderedMembers", + function ( + value: SandboxValue, + members: SandboxValue, + modifiers?: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + let pass = false + try { + chai.expect(value).to.include.deep.ordered.members(members) + pass = !isNegated + } catch { + pass = isNegated + } + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: pass ? "pass" : "fail", + message: buildMessage(value, mods, "members", [...members]), + }) + } + ), + + chaiRespondTo: defineSandboxFn( + ctx, + "chaiRespondTo", + function (value, method, modifiers, preCheckedResult) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + // PRE-CHECK PATTERN: Use pre-checked result from sandbox + // After serialization, functions become strings and method checks fail + const hasMethod = + preCheckedResult !== undefined + ? preCheckedResult + : typeof (value as any)?.[method as string] === "function" || + typeof (value as any)?.prototype?.[method as string] === + "function" + const shouldPass = isNegated ? !hasMethod : hasMethod + executeChaiAssertion( + () => { + if (!shouldPass) { + throw new Error( + `Expected ${formatValue(value)}${String(mods)} respondTo '${method}'` + ) + } + }, + buildMessage(value, String(mods), `respondTo '${method}'`) + ) + } + ), + + // Function throw assertion + chaiThrow: defineSandboxFn( + ctx, + "chaiThrow", + function ( + fn: unknown, + threwError: unknown, + errorTypeName: unknown, + errorMessage: unknown, + expectedTypeName: unknown, + errMsgMatcher: unknown, + regexSource: unknown, + regexFlags: unknown, + isRegexMatcher: unknown, + modifiers: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + + // Determine what we're checking for + const hasErrorType = + expectedTypeName !== null && expectedTypeName !== undefined + const hasErrorMessage = + errMsgMatcher !== undefined && errMsgMatcher !== null + const hasRegexPattern = Boolean(isRegexMatcher) + + // Check if assertion should pass + let shouldPass = false + + if (isNegated) { + // For negated (.not.throw), logic depends on what's being checked + if (hasErrorType) { + // .not.throw(ErrorType) should pass if: + // 1. Didn't throw at all, OR + // 2. Threw a different error type + shouldPass = + !threwError || String(errorTypeName) !== String(expectedTypeName) + } else if (hasErrorMessage || hasRegexPattern) { + // .not.throw('message') should pass if: + // 1. Didn't throw at all, OR + // 2. Threw with a different message + if (!threwError) { + shouldPass = true + } else if (hasRegexPattern) { + const regex = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + shouldPass = !regex.test(String(errorMessage || "")) + } else { + const errMsg = String(errorMessage || "") + const matchMsg = String(errMsgMatcher || "") + shouldPass = errMsg !== matchMsg && !errMsg.includes(matchMsg) + } + } else { + // .not.throw() with no args should pass if did NOT throw + shouldPass = !threwError + } + } else { + // For positive assertion, should pass if threw error + shouldPass = Boolean(threwError) + + // Additional checks if error was thrown + if (threwError && hasErrorType) { + shouldPass = + shouldPass && String(errorTypeName) === String(expectedTypeName) + } + + if (threwError && hasErrorMessage && !hasRegexPattern) { + // String message match + const errMsg = String(errorMessage || "") + const matchMsg = String(errMsgMatcher || "") + shouldPass = + shouldPass && (errMsg === matchMsg || errMsg.includes(matchMsg)) + } + + if (threwError && hasRegexPattern) { + // RegExp message match + const regex = new RegExp( + String(regexSource), + String(regexFlags || "") + ) + shouldPass = shouldPass && regex.test(String(errorMessage || "")) + } + } + + // Build appropriate message + const messageArgs: string[] = [] + if (hasErrorType && hasErrorMessage) { + if (hasRegexPattern) { + messageArgs.push( + String(expectedTypeName), + `/${regexSource}/${regexFlags || ""}` + ) + } else { + messageArgs.push(String(expectedTypeName), String(errMsgMatcher)) + } + } else if (hasErrorType) { + messageArgs.push(String(expectedTypeName)) + } else if (hasErrorMessage) { + if (hasRegexPattern) { + messageArgs.push(`/${regexSource}/${regexFlags || ""}`) + } else { + messageArgs.push(String(errMsgMatcher)) + } + } + + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(fn, mods, "throw", messageArgs.filter(Boolean)), + }) + } + ), + + // Function satisfy assertion + chaiSatisfy: defineSandboxFn( + ctx, + "chaiSatisfy", + function ( + value: SandboxValue, + satisfyResult: unknown, + matcherString: unknown, + modifiers: SandboxValue + ) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const passed = Boolean(satisfyResult) + const shouldPass = isNegated ? !passed : passed + + if (testStack.length === 0) return + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "satisfy", [ + String(matcherString), + ]), + }) + } + ), + + // change/increase/decrease assertions (pre-check pattern - function already executed in sandbox) + chaiChange: defineSandboxFn( + ctx, + "chaiChange", + function (prop, modifiers, changed, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !changed : changed + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} change {}.'${prop}'`, + }) + } + ), + + chaiChangeBy: defineSandboxFn( + ctx, + "chaiChangeBy", + function (prop, modifiers, changed, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const actualDelta = Math.abs(numDelta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = + Math.abs(actualDelta - Math.abs(numExpectedDelta)) < 0.0001 + const byPasses = changed && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + if (testStack.length === 0) return + + // Update the last result (from chaiChange) + const lastResult = + testStack[testStack.length - 1].expectResults[ + testStack[testStack.length - 1].expectResults.length - 1 + ] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} change {}.'${prop}' by ${numExpectedDelta}` + } + ), + + chaiIncrease: defineSandboxFn( + ctx, + "chaiIncrease", + function (prop, modifiers, increased, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !increased : increased + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} increase {}.'${prop}'`, + }) + } + ), + + chaiIncreaseBy: defineSandboxFn( + ctx, + "chaiIncreaseBy", + function (prop, modifiers, increased, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = Math.abs(numDelta - numExpectedDelta) < 0.0001 + const byPasses = increased && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + if (testStack.length === 0) return + + // Update the last result (from chaiIncrease) + const lastResult = + testStack[testStack.length - 1].expectResults[ + testStack[testStack.length - 1].expectResults.length - 1 + ] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} increase {}.'${prop}' by ${numExpectedDelta}` + } + ), + + chaiDecrease: defineSandboxFn( + ctx, + "chaiDecrease", + function (prop, modifiers, decreased, _delta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const shouldPass = isNegated ? !decreased : decreased + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: `Expected [Function]${mods} decrease {}.'${prop}'`, + }) + } + ), + + chaiDecreaseBy: defineSandboxFn( + ctx, + "chaiDecreaseBy", + function (prop, modifiers, decreased, delta, expectedDelta) { + const mods = String(modifiers || " to") + const isNegated = mods.includes("not") + const numDelta = Number(delta) + const numExpectedDelta = Number(expectedDelta) + const deltaMatches = + Math.abs(Math.abs(numDelta) - numExpectedDelta) < 0.0001 + const byPasses = decreased && deltaMatches + const byShouldPass = isNegated ? !byPasses : byPasses + + if (testStack.length === 0) return + + // Update the last result (from chaiDecrease) + const lastResult = + testStack[testStack.length - 1].expectResults[ + testStack[testStack.length - 1].expectResults.length - 1 + ] + lastResult.status = byShouldPass ? "pass" : "fail" + lastResult.message = `Expected [Function]${mods} decrease {}.'${prop}' by ${numExpectedDelta}` + } + ), + + // Custom Postman-specific methods + chaiJsonSchema: defineSandboxFn( + ctx, + "chaiJsonSchema", + function ( + value: SandboxValue, + schema: SandboxValue, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Validation helper + const validateSchema = ( + data: SandboxValue, + schema: SandboxValue + ): boolean => { + // Type validation + if (schema.type !== undefined) { + const actualType = Array.isArray(data) ? "array" : typeof data + if (actualType !== schema.type) return false + } + + // Required properties + if (schema.required && Array.isArray(schema.required)) { + for (const key of schema.required) { + if (!(key in data)) return false + } + } + + // Properties validation + if (schema.properties && typeof data === "object" && data !== null) { + for (const key in schema.properties) { + if (key in data) { + const propSchema = schema.properties[key] + if (!validateSchema(data[key], propSchema)) return false + } + } + } + + return true + } + + const passes = validateSchema(value, schema) + const shouldPass = isNegated ? !passes : passes + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "jsonSchema", [schema]), + }) + } + ), + + chaiCharset: defineSandboxFn( + ctx, + "chaiCharset", + function ( + value: SandboxValue, + expectedCharset: unknown, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // For response body testing, check Content-Type header charset + const passes = typeof value === "string" // Simplified check + + const shouldPass = isNegated ? !passes : passes + + if (testStack.length === 0) return + + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "charset", [expectedCharset]), + }) + } + ), + + chaiCookie: defineSandboxFn( + ctx, + "chaiCookie", + function ( + value: SandboxValue, + cookieName: SandboxValue, + cookieValue: unknown, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Simplified: check if cookie-like structure has name + const hasCookie = + typeof value === "object" && value !== null && cookieName in value + const valueMatches = + cookieValue === undefined || value[cookieName] === cookieValue + + const passes = hasCookie && valueMatches + const shouldPass = isNegated ? !passes : passes + + if (testStack.length === 0) return + + const args = + cookieValue !== undefined ? [cookieName, cookieValue] : [cookieName] + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "cookie", args), + }) + } + ), + + chaiJsonPath: defineSandboxFn( + ctx, + "chaiJsonPath", + function ( + value: SandboxValue, + path: SandboxValue, + expectedValue: SandboxValue, + modifiers: SandboxValue + ) { + const mods = modifiers || " to" + const isNegated = String(mods).includes("not") + + // Enhanced JSONPath evaluation (supports $.path, array indices, wildcards) + const evaluatePath = (data: SandboxValue, path: string): unknown => { + let pathStr = String(path).trim() + + // Remove leading $. if present + if (pathStr.startsWith("$.")) { + pathStr = pathStr.substring(2) + } else if (pathStr.startsWith("$")) { + pathStr = pathStr.substring(1) + } + + // Handle empty path (just "$") + if (!pathStr || pathStr === "") { + return data + } + + let current: unknown = data + const segments = pathStr.split(/\.|\[/).filter(Boolean) + + for (let segment of segments) { + // Remove trailing ] for array indices + segment = segment.replace(/\]$/, "") + + if (segment === "*") { + // Wildcard - return array of all values + if (Array.isArray(current)) { + return current // For array[*], return the array itself + } else if (typeof current === "object" && current !== null) { + return Object.values(current) + } + return undefined + } else if (/^\d+$/.test(segment)) { + // Numeric index + const index = parseInt(segment, 10) + if ( + Array.isArray(current) && + index >= 0 && + index < current.length + ) { + current = current[index] + } else { + return undefined + } + } else { + // Object property + if ( + current && + typeof current === "object" && + segment in current + ) { + current = (current as Record)[segment] + } else { + return undefined + } + } + } + + return current + } + + const actualValue = evaluatePath(value, path) + const passes = + expectedValue === undefined + ? actualValue !== undefined + : actualValue === expectedValue + + const shouldPass = isNegated ? !passes : passes + + if (testStack.length === 0) return + + const args = + expectedValue !== undefined ? [path, expectedValue] : [path] + testStack[testStack.length - 1].expectResults.push({ + status: shouldPass ? "pass" : "fail", + message: buildMessage(value, mods, "jsonPath", args), + }) + } + ), + + // expect.fail() - Force a test failure + // Supports multiple signatures: + // expect.fail() + // expect.fail(message) + // expect.fail(actual, expected) + // expect.fail(actual, expected, message) + // expect.fail(actual, expected, message, operator) + chaiFail: defineSandboxFn(ctx, "chaiFail", (...args: unknown[]) => { + if (testStack.length === 0) return + + const [actual, expected, message, operator] = args + let errorMessage: string + + // Handle different call signatures + if (actual === undefined && expected === undefined) { + // expect.fail() - no arguments + errorMessage = "expect.fail()" + } else if ( + expected === undefined && + message === undefined && + operator === undefined + ) { + // expect.fail(message) - single string argument + errorMessage = typeof actual === "string" ? actual : "expect.fail()" + } else { + // expect.fail(actual, expected) or full signature + errorMessage = + (message as string) || + `expected ${actual !== undefined ? actual : "undefined"} to ${(operator as string) || "equal"} ${expected !== undefined ? expected : "undefined"}` + } + + // Always record as failure + testStack[testStack.length - 1].expectResults.push({ + status: "fail", + message: errorMessage, + }) + }), + } +} diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts index 5f369d1b..4d022ae5 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/expectation-helpers.ts @@ -1,6 +1,6 @@ import { CageModuleCtx, defineSandboxFn } from "faraday-cage/modules" -import { TestDescriptor, ExpectationMethods } from "~/types" +import { TestDescriptor, ExpectationMethods, SandboxValue } from "~/types" import { createExpectation } from "~/utils/shared" /** @@ -10,49 +10,53 @@ export const createExpectationMethods = ( ctx: CageModuleCtx, testRunStack: TestDescriptor[] ): ExpectationMethods => { - const createExpect = (expectVal: any) => + const createExpect = (expectVal: SandboxValue) => createExpectation(expectVal, false, testRunStack) return { expectToBe: defineSandboxFn( ctx, "expectToBe", - (expectVal: any, expectedVal: any) => { + (expectVal: SandboxValue, expectedVal: SandboxValue) => { return createExpect(expectVal).toBe(expectedVal) } ), expectToBeLevel2xx: defineSandboxFn( ctx, "expectToBeLevel2xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).toBeLevel2xx() } ), expectToBeLevel3xx: defineSandboxFn( ctx, "expectToBeLevel3xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).toBeLevel3xx() } ), expectToBeLevel4xx: defineSandboxFn( ctx, "expectToBeLevel4xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).toBeLevel4xx() } ), expectToBeLevel5xx: defineSandboxFn( ctx, "expectToBeLevel5xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).toBeLevel5xx() } ), expectToBeType: defineSandboxFn( ctx, "expectToBeType", - (expectVal: any, expectedType: any, isDate: any) => { + ( + expectVal: SandboxValue, + expectedType: SandboxValue, + isDate: SandboxValue + ) => { const resolved = isDate && typeof expectVal === "string" ? new Date(expectVal) @@ -65,14 +69,14 @@ export const createExpectationMethods = ( expectToHaveLength: defineSandboxFn( ctx, "expectToHaveLength", - (expectVal: any, expectedLength: any) => { + (expectVal: SandboxValue, expectedLength: SandboxValue) => { return createExpect(expectVal).toHaveLength(expectedLength) } ), expectToInclude: defineSandboxFn( ctx, "expectToInclude", - (expectVal: any, needle: any) => { + (expectVal: SandboxValue, needle: SandboxValue) => { return createExpect(expectVal).toInclude(needle) } ), @@ -81,42 +85,46 @@ export const createExpectationMethods = ( expectNotToBe: defineSandboxFn( ctx, "expectNotToBe", - (expectVal: any, expectedVal: any) => { + (expectVal: SandboxValue, expectedVal: SandboxValue) => { return createExpect(expectVal).not.toBe(expectedVal) } ), expectNotToBeLevel2xx: defineSandboxFn( ctx, "expectNotToBeLevel2xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).not.toBeLevel2xx() } ), expectNotToBeLevel3xx: defineSandboxFn( ctx, "expectNotToBeLevel3xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).not.toBeLevel3xx() } ), expectNotToBeLevel4xx: defineSandboxFn( ctx, "expectNotToBeLevel4xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).not.toBeLevel4xx() } ), expectNotToBeLevel5xx: defineSandboxFn( ctx, "expectNotToBeLevel5xx", - (expectVal: any) => { + (expectVal: SandboxValue) => { return createExpect(expectVal).not.toBeLevel5xx() } ), expectNotToBeType: defineSandboxFn( ctx, "expectNotToBeType", - (expectVal: any, expectedType: any, isDate: any) => { + ( + expectVal: SandboxValue, + expectedType: SandboxValue, + isDate: SandboxValue + ) => { const resolved = isDate && typeof expectVal === "string" ? new Date(expectVal) @@ -129,14 +137,14 @@ export const createExpectationMethods = ( expectNotToHaveLength: defineSandboxFn( ctx, "expectNotToHaveLength", - (expectVal: any, expectedLength: any) => { + (expectVal: SandboxValue, expectedLength: SandboxValue) => { return createExpect(expectVal).not.toHaveLength(expectedLength) } ), expectNotToInclude: defineSandboxFn( ctx, "expectNotToInclude", - (expectVal: any, needle: any) => { + (expectVal: SandboxValue, needle: SandboxValue) => { return createExpect(expectVal).not.toInclude(needle) } ), diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts index 05273f5b..d7b73791 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/utils/request-setters.ts @@ -23,68 +23,68 @@ export const createRequestSetterMethods = ( const setterMethods = { // Request setter methods - setRequestUrl: defineSandboxFn(ctx, "setRequestUrl", (url: any) => { + setRequestUrl: defineSandboxFn(ctx, "setRequestUrl", (url: unknown) => { requestMethods.setUrl(url as string) }), setRequestMethod: defineSandboxFn( ctx, "setRequestMethod", - (method: any) => { + (method: unknown) => { requestMethods.setMethod(method as string) } ), setRequestHeader: defineSandboxFn( ctx, "setRequestHeader", - (name: any, value: any) => { + (name: unknown, value: unknown) => { requestMethods.setHeader(name as string, value as string) } ), setRequestHeaders: defineSandboxFn( ctx, "setRequestHeaders", - (headers: any) => { + (headers: unknown) => { requestMethods.setHeaders(headers as HoppRESTHeaders) } ), removeRequestHeader: defineSandboxFn( ctx, "removeRequestHeader", - (key: any) => { + (key: unknown) => { requestMethods.removeHeader(key as string) } ), setRequestParam: defineSandboxFn( ctx, "setRequestParam", - (name: any, value: any) => { + (name: unknown, value: unknown) => { requestMethods.setParam(name as string, value as string) } ), setRequestParams: defineSandboxFn( ctx, "setRequestParams", - (params: any) => { + (params: unknown) => { requestMethods.setParams(params as HoppRESTParams) } ), removeRequestParam: defineSandboxFn( ctx, "removeRequestParam", - (key: any) => { + (key: unknown) => { requestMethods.removeParam(key as string) } ), - setRequestBody: defineSandboxFn(ctx, "setRequestBody", (body: any) => { + setRequestBody: defineSandboxFn(ctx, "setRequestBody", (body: unknown) => { requestMethods.setBody(body as HoppRESTReqBody) }), - setRequestAuth: defineSandboxFn(ctx, "setRequestAuth", (auth: any) => { + setRequestAuth: defineSandboxFn(ctx, "setRequestAuth", (auth: unknown) => { requestMethods.setAuth(auth as HoppRESTAuth) }), setRequestVariable: defineSandboxFn( ctx, "setRequestVariable", - (key: any, value: any) => { + (key: unknown, value: unknown) => { requestMethods.setRequestVariable(key as string, value as string) } ), diff --git a/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts b/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts new file mode 100644 index 00000000..7f88a712 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/constants/http-status-codes.ts @@ -0,0 +1,126 @@ +/** + * HTTP Status Code Reason Phrases + * + * Standard HTTP status codes and their corresponding reason phrases + * as defined in RFC 7231 and related specifications. + * + * @see https://tools.ietf.org/html/rfc7231#section-6 + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + */ +export const HTTP_STATUS_REASONS: Readonly> = { + // 1xx Informational + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 103: "Early Hints", + + // 2xx Success + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + + // 3xx Redirection + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + + // 4xx Client Error + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + + // 5xx Server Error + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", +} as const + +/** + * Get the reason phrase for an HTTP status code + * @param statusCode - The HTTP status code + * @returns The reason phrase, or "Unknown" if not found + */ +export const getStatusReason = (statusCode: number): string => { + return HTTP_STATUS_REASONS[statusCode] || "Unknown" +} + +/** + * Check if a status code is informational (1xx) + */ +export const isInformational = (statusCode: number): boolean => { + return statusCode >= 100 && statusCode < 200 +} + +/** + * Check if a status code is successful (2xx) + */ +export const isSuccess = (statusCode: number): boolean => { + return statusCode >= 200 && statusCode < 300 +} + +/** + * Check if a status code is a redirection (3xx) + */ +export const isRedirection = (statusCode: number): boolean => { + return statusCode >= 300 && statusCode < 400 +} + +/** + * Check if a status code is a client error (4xx) + */ +export const isClientError = (statusCode: number): boolean => { + return statusCode >= 400 && statusCode < 500 +} + +/** + * Check if a status code is a server error (5xx) + */ +export const isServerError = (statusCode: number): boolean => { + return statusCode >= 500 && statusCode < 600 +} diff --git a/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts b/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts new file mode 100644 index 00000000..67756a7d --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/constants/sandbox-markers.ts @@ -0,0 +1,31 @@ +/** + * Special marker constants for preserving undefined and null values + * across the sandbox boundary during serialization. + * + * These markers are used because: + * - JSON.stringify converts undefined to null or omits the property + * - We need to distinguish between actual null and serialized undefined + * - The sandbox boundary requires serialization, losing type information + */ +export const UNDEFINED_MARKER = "__HOPPSCOTCH_UNDEFINED__" as const +export const NULL_MARKER = "__HOPPSCOTCH_NULL__" as const + +export type SandboxMarker = typeof UNDEFINED_MARKER | typeof NULL_MARKER + +/** + * Converts marker strings back to their original values + */ +export const convertMarkerToValue = (value: unknown): unknown => { + if (value === UNDEFINED_MARKER) return undefined + if (value === NULL_MARKER) return null + return value +} + +/** + * Converts null/undefined values to marker strings for serialization + */ +export const convertValueToMarker = (value: unknown): unknown => { + if (value === undefined) return UNDEFINED_MARKER + if (value === null) return NULL_MARKER + return value +} diff --git a/packages/hoppscotch-js-sandbox/src/types/index.ts b/packages/hoppscotch-js-sandbox/src/types/index.ts index 4268538e..f7979da7 100644 --- a/packages/hoppscotch-js-sandbox/src/types/index.ts +++ b/packages/hoppscotch-js-sandbox/src/types/index.ts @@ -6,6 +6,34 @@ import type { EnvAPIOptions } from "~/utils/shared" // Infer the return type of defineSandboxFn from faraday-cage type SandboxFunction = ReturnType +/** + * Type alias for values that cross the QuickJS sandbox boundary. + * + * Values passed between the host environment and the QuickJS sandbox lose their + * TypeScript type information during serialization. This type alias serves as + * a documented alternative to raw `any`, making it explicit that these values: + * + * - Come from or go to the sandbox (pre-request/post-request scripts) + * - Have been serialized and may not preserve complex types + * - Require runtime validation when type safety is needed + * + * Use this type for: + * - Function parameters that accept user script values + * - Return values sent back to the sandbox + * - PM namespace compatibility (preserves non-string types like arrays, objects) + * + * @example + * ```typescript + * // Function accepting values from user scripts + * const envSetAny = (key: SandboxValue, value: SandboxValue) => { + * // Runtime validation + * if (typeof key !== "string") throw new Error("Expected string key") + * // ... handle value + * } + * ``` + */ +export type SandboxValue = any + /** * The response object structure exposed to the test script */ @@ -93,14 +121,14 @@ export type SandboxPreRequestResult = { } export interface Expectation { - toBe(expectedVal: any): void + toBe(expectedVal: SandboxValue): void toBeLevel2xx(): void toBeLevel3xx(): void toBeLevel4xx(): void toBeLevel5xx(): void - toBeType(expectedType: any): void - toHaveLength(expectedLength: any): void - toInclude(needle: any): void + toBeType(expectedType: SandboxValue): void + toHaveLength(expectedLength: SandboxValue): void + toInclude(needle: SandboxValue): void readonly not: Expectation } @@ -252,7 +280,7 @@ export interface BaseInputs cookieGetAll: SandboxFunction cookieDelete: SandboxFunction cookieClear: SandboxFunction - getUpdatedEnvs: () => any + getUpdatedEnvs: () => SandboxValue getUpdatedCookies: () => Cookie[] | null - [key: string]: any + [key: string]: SandboxValue // Index signature for dynamic namespace properties } diff --git a/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts b/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts new file mode 100644 index 00000000..c8e2c43b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/chai-integration.ts @@ -0,0 +1,354 @@ +import * as chai from "chai" +import { TestDescriptor, SandboxValue } from "../types" + +/** + * Creates a Chai expectation that records results to the test stack + * This integrates actual Chai.js with Hoppscotch's test reporting system + * + * Returns a serializable proxy object that can cross the sandbox boundary + */ +export function createChaiExpectation( + value: SandboxValue, + testStack: TestDescriptor[] +) { + // Create the actual Chai assertion + const assertion = chai.expect(value) + + // Create a serializable proxy that can cross the sandbox boundary + return createSerializableProxy(assertion, value, testStack, {}) +} + +/** + * Creates a serializable proxy object that mimics Chai's API + * This can cross the sandbox boundary unlike the actual Chai assertion object + */ +function createSerializableProxy( + assertion: any, // Chai assertion object - dynamic API, must be any + originalValue: SandboxValue, + testStack: TestDescriptor[], + flags: SandboxValue +): any { + // Returns dynamic proxy with Chai-like API + const proxy: any = {} // Dynamic proxy object with Chai-like methods + + // Helper to create assertion methods + const createMethod = (methodName: string) => { + return (...args: SandboxValue[]) => { + try { + // Call the actual Chai method + const result = assertion[methodName](...args) + + // Record success + recordResult( + testStack, + true, + buildMessage(assertion, methodName, args, originalValue, false) + ) + + // If result is a Chai assertion, return a new serializable proxy + if (result && typeof result === "object" && result.__flags) { + return createSerializableProxy( + result, + result._obj, + testStack, + result.__flags + ) + } + + return result + } catch (error: any) { + // Record failure but DON'T throw - allow test to continue + recordResult(testStack, false, extractErrorMessage(error)) + // Return a proxy to allow chaining even after failure + return createSerializableProxy( + assertion, + originalValue, + testStack, + flags + ) + } + } + } + + // Helper to create assertion getter properties (these perform assertions when accessed) + const createAssertionGetter = (propName: string) => { + return () => { + try { + // Access the property which triggers the assertion + void assertion[propName] + + // Record success + recordResult( + testStack, + true, + buildMessage(assertion, propName, [], originalValue, false) + ) + + // Return undefined (assertion getters don't return values) + return undefined + } catch (error: any) { + // Record failure but DON'T throw - allow test to continue + recordResult(testStack, false, extractErrorMessage(error)) + // Return undefined to allow test to continue + return undefined + } + } + } + + // Helper to create language chain getters (these just return new assertions) + const createChainGetter = (propName: string) => { + return () => { + // Access the property on the Chai assertion + const value = assertion[propName] + // Return a new serializable proxy + if (value && typeof value === "object" && value.__flags) { + return createSerializableProxy( + value, + value._obj || originalValue, + testStack, + value.__flags + ) + } + return value + } + } + + // Add all Chai assertion methods (functions) + const methods = [ + "equal", + "equals", + "eq", + "eql", + "include", + "includes", + "contain", + "contains", + "a", + "an", + "instanceof", + "instanceOf", + "property", + "ownProperty", + "ownPropertyDescriptor", + "lengthOf", + "length", + "match", + "matches", + "string", + "keys", + "key", + "throw", + "throws", + "Throw", + "respondTo", + "respondsTo", + "satisfy", + "satisfies", + "closeTo", + "approximately", + "members", + "oneOf", + "change", + "changes", + "increase", + "increases", + "decrease", + "decreases", + "by", + "above", + "gt", + "greaterThan", + "least", + "gte", + "below", + "lt", + "lessThan", + "most", + "lte", + "within", + ] + + // Add all methods to the proxy + methods.forEach((method) => { + proxy[method] = createMethod(method) + }) + + // Add assertion getters (these perform assertions when accessed) + const assertionGetters = [ + "ok", + "true", + "false", + "null", + "undefined", + "NaN", + "exist", + "empty", + "arguments", + "Arguments", + "finite", + "extensible", + "sealed", + "frozen", + ] + + assertionGetters.forEach((getter) => { + Object.defineProperty(proxy, getter, { + get: createAssertionGetter(getter), + enumerable: false, // Don't enumerate to avoid serialization issues + configurable: true, + }) + }) + + // Add language chains as getters (these just return new assertions) + const chains = [ + "to", + "be", + "been", + "is", + "that", + "which", + "and", + "has", + "have", + "with", + "at", + "of", + "same", + "but", + "does", + "not", + "deep", + "nested", + "own", + "ordered", + "any", + "all", + "itself", + ] + + chains.forEach((chain) => { + Object.defineProperty(proxy, chain, { + get: createChainGetter(chain), + enumerable: false, // Don't enumerate to avoid serialization issues + configurable: true, + }) + }) + + return proxy +} + +/** + * Records an assertion result to the test stack + */ +function recordResult( + testStack: TestDescriptor[], + passed: boolean, + message: string +) { + if (testStack.length === 0) return + + const currentTest = testStack[testStack.length - 1] + currentTest.expectResults.push({ + status: passed ? "pass" : "fail", + message, + }) +} + +/** + * Builds a message for an assertion + * Tries to match the format expected by tests + */ +function buildMessage( + assertion: any, // Chai assertion object - dynamic API, must be any + method: string, + args: SandboxValue[], + value: SandboxValue, + _failed: boolean +): string { + const flags = assertion.__flags || {} + const valueStr = formatValue(value) + + let message = `Expected ${valueStr}` + + // Add "to" or "to not" + if (flags.negate) { + message += " to not" + } else { + message += " to" + } + + // Add modifiers + if (flags.deep) message += " deep" + if (flags.own) message += " own" + if (flags.nested) message += " nested" + + // Add the method name + message += ` ${method}` + + // Add arguments + if (args.length > 0) { + const argStrs = args.map(formatValue) + message += ` ${argStrs.join(", ")}` + } + + return message +} + +/** + * Extracts a clean error message from a Chai assertion error + */ +function extractErrorMessage(error: any): string { + if (!error) return "Assertion failed" + + // Chai errors have a message property + let message = error.message || String(error) + + // Remove stack traces and extra info + const lines = message.split("\n") + if (lines.length > 0) { + message = lines[0] + } + + // Clean up Chai's "expected X to Y" format + // Chai uses lowercase "expected", we want "Expected" + if (message.startsWith("expected ")) { + message = "E" + message.substring(1) + } + + return message +} + +/** + * Formats a value for display in messages + */ +function formatValue(val: SandboxValue): string { + if (val === null) return "null" + if (val === undefined) return "undefined" + if (typeof val === "string") return `'${val}'` + if (typeof val === "number") { + if (isNaN(val)) return "NaN" + if (val === Infinity) return "Infinity" + if (val === -Infinity) return "-Infinity" + return String(val) + } + if (typeof val === "boolean") return String(val) + if (Array.isArray(val)) { + if (val.length === 0) return "[]" + const items = val.slice(0, 10).map(formatValue) + return `[${items.join(", ")}]` + } + if (typeof val === "object") { + try { + const keys = Object.keys(val) + if (keys.length === 0) return "{}" + const pairs = keys.slice(0, 5).map((k) => `${k}: ${formatValue(val[k])}`) + return `{${pairs.join(", ")}}` + } catch { + return "[object Object]" + } + } + if (typeof val === "function") { + return val.name || "[Function]" + } + return String(val) +} diff --git a/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts b/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts index ba4383ea..206e1ae1 100644 --- a/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts +++ b/packages/hoppscotch-js-sandbox/src/utils/pre-request.ts @@ -17,7 +17,8 @@ export const getRequestSetterMethods = (request: HoppRESTRequest) => { } const setMethod = (method: string) => { - updatedRequest.method = method.toUpperCase() + // NOTE: Postman does NOT normalize method to uppercase, so we preserve the original case + updatedRequest.method = method } const setHeader = (name: string, value: string) => { const headers = [...updatedRequest.headers] @@ -45,7 +46,9 @@ export const getRequestSetterMethods = (request: HoppRESTRequest) => { } const removeHeader = (key: string) => { - updatedRequest.headers = updatedRequest.headers.filter((h) => h.key !== key) + updatedRequest.headers = updatedRequest.headers.filter( + (h) => h.key.toLowerCase() !== key.toLowerCase() + ) } const setParam = (name: string, value: string) => { diff --git a/packages/hoppscotch-js-sandbox/src/utils/shared.ts b/packages/hoppscotch-js-sandbox/src/utils/shared.ts index d95a3ff4..7ed653f2 100644 --- a/packages/hoppscotch-js-sandbox/src/utils/shared.ts +++ b/packages/hoppscotch-js-sandbox/src/utils/shared.ts @@ -15,6 +15,7 @@ import { SelectedEnvItem, TestDescriptor, TestResult, + SandboxValue, } from "../types" export type EnvSource = "active" | "global" | "all" @@ -57,7 +58,7 @@ const findEnvIndex = ( const setEnv = ( envName: string, - envValue: string, + envValue: SandboxValue, envs: TestResult["envs"], options: { setInitialValue?: boolean; source: EnvSource } = { setInitialValue: false, @@ -154,6 +155,7 @@ export function getSharedEnvMethods( setInitial: (key: string, value: string, options?: EnvAPIOptions) => void } } + pmSetAny: (key: string, value: SandboxValue, options?: EnvAPIOptions) => void updatedEnvs: TestResult["envs"] } @@ -187,7 +189,7 @@ export function getSharedEnvMethods( let updatedEnvs = envs const envGetFn = ( - key: any, + key: unknown, options: EnvAPIOptions = { fallbackToNull: false, source: "all" } ) => { if (typeof key !== "string") { @@ -198,7 +200,7 @@ export function getSharedEnvMethods( getEnv(key, updatedEnvs, options), O.fold( () => (options.fallbackToNull ? null : undefined), - (env) => String(env.currentValue) + (env) => env.currentValue // Return value as-is (PM namespace preserves types) ) ) @@ -206,7 +208,7 @@ export function getSharedEnvMethods( } const envGetResolveFn = ( - key: any, + key: unknown, options: EnvAPIOptions = { fallbackToNull: false, source: "all" } ) => { if (typeof key !== "string") { @@ -225,13 +227,17 @@ export function getSharedEnvMethods( getEnv(key, updatedEnvs, options), E.fromOption(() => "INVALID_KEY" as const), - E.map((e) => - pipe( - parseTemplateStringE(e.currentValue, envVars), // If the recursive resolution failed, return the unresolved value - E.getOrElse(() => e.currentValue) - ) - ), - E.map((x) => String(x)), + E.map((e) => { + // Only resolve templates if the value is a string (PM namespace may have non-strings) + if (typeof e.currentValue === "string") { + return pipe( + parseTemplateStringE(e.currentValue, envVars), + E.getOrElse(() => e.currentValue) + ) + } + // Return non-string values as-is (arrays, objects, null, etc.) + return e.currentValue + }), E.getOrElseW(() => (options.fallbackToNull ? null : undefined)) ) @@ -240,8 +246,8 @@ export function getSharedEnvMethods( } const envSetFn = ( - key: any, - value: any, + key: unknown, + value: unknown, options: EnvAPIOptions = { source: "all" } ) => { if (typeof key !== "string") { @@ -257,7 +263,26 @@ export function getSharedEnvMethods( return undefined } - const envUnsetFn = (key: any, options: EnvAPIOptions = { source: "all" }) => { + // PM namespace-specific setter that accepts any type (for Postman compatibility) + const envSetAnyFn = ( + key: unknown, + value: SandboxValue, // Intentionally SandboxValue for PM namespace type preservation + options: EnvAPIOptions = { source: "all" } + ) => { + if (typeof key !== "string") { + throw new Error("Expected key to be a string") + } + + // PM namespace preserves ALL types (arrays, objects, primitives, null, undefined) + updatedEnvs = setEnv(key, value, updatedEnvs, options) + + return undefined + } + + const envUnsetFn = ( + key: unknown, + options: EnvAPIOptions = { source: "all" } + ) => { if (typeof key !== "string") { throw new Error("Expected key to be a string") } @@ -267,7 +292,7 @@ export function getSharedEnvMethods( return undefined } - const envResolveFn = (value: any) => { + const envResolveFn = (value: unknown) => { if (typeof value !== "string") { throw new Error("Expected value to be a string") } @@ -317,7 +342,7 @@ export function getSharedEnvMethods( } const envGetInitialRawFn = ( - key: any, + key: unknown, options: EnvAPIOptions = { source: "all" } ) => { if (typeof key !== "string") { @@ -328,7 +353,7 @@ export function getSharedEnvMethods( getEnv(key, updatedEnvs, options), O.fold( () => undefined, - (env) => String(env.initialValue) + (env) => env.initialValue // Return as-is (PM namespace preserves types) ) ) @@ -375,7 +400,8 @@ export function getSharedEnvMethods( setInitial: envSetInitialFn, }, }, - + // Expose PM-specific setter that accepts any type + pmSetAny: envSetAnyFn, updatedEnvs, } } @@ -408,7 +434,7 @@ export const getSharedCookieMethods = (cookies: Cookie[] | null) => { } } - const cookieGetFn = (domain: any, name: any): Cookie | null => { + const cookieGetFn = (domain: unknown, name: unknown): Cookie | null => { throwIfCookiesUnsupported() if (typeof domain !== "string" || typeof name !== "string") { @@ -492,7 +518,7 @@ export const getSharedCookieMethods = (cookies: Cookie[] | null) => { } } -const getResolvedExpectValue = (expectVal: any) => { +const getResolvedExpectValue = (expectVal: SandboxValue) => { if (typeof expectVal !== "string") { return expectVal } @@ -549,14 +575,14 @@ export function preventCyclicObjects>( * @returns Object with the expectation methods */ export const createExpectation = ( - expectVal: any, + expectVal: SandboxValue, negated: boolean, currTestStack: TestDescriptor[] ): Expectation => { // Non-primitive values supplied are stringified in the isolate context const resolvedExpectVal = getResolvedExpectValue(expectVal) - const toBeFn = (expectedVal: any) => { + const toBeFn = (expectedVal: SandboxValue) => { let assertion = resolvedExpectVal === expectedVal if (negated) { @@ -616,7 +642,7 @@ export const createExpectation = ( const toBeLevel4xxFn = () => toBeLevelXxx("400", 400, 499) const toBeLevel5xxFn = () => toBeLevelXxx("500", 500, 599) - const toBeTypeFn = (expectedType: any) => { + const toBeTypeFn = (expectedType: SandboxValue) => { if ( [ "string", @@ -656,7 +682,7 @@ export const createExpectation = ( return undefined } - const toHaveLengthFn = (expectedLength: any) => { + const toHaveLengthFn = (expectedLength: SandboxValue) => { if ( !( Array.isArray(resolvedExpectVal) || @@ -700,7 +726,7 @@ export const createExpectation = ( return undefined } - const toIncludeFn = (needle: any) => { + const toIncludeFn = (needle: SandboxValue) => { if ( !( Array.isArray(resolvedExpectVal) || @@ -806,7 +832,7 @@ export const getTestRunnerScriptMethods = (envs: TestResult["envs"]) => { testRunStack[testRunStack.length - 1].children.push(child) } - const expectFn = (expectVal: any) => + const expectFn = (expectVal: unknown) => createExpectation(expectVal, false, testRunStack) const { methods, updatedEnvs } = getSharedEnvMethods(cloneDeep(envs)) @@ -824,30 +850,42 @@ export const getTestRunnerScriptMethods = (envs: TestResult["envs"]) => { * Compiles shared scripting API properties (scoped to requests) for use in both pre and post request scripts * Extracts shared properties from a request object * @param request The request object to extract shared properties from + * @param getUpdatedRequest Optional function to get the updated request (for pre-request mutations) * @returns An object containing the shared properties of the request */ -export const getSharedRequestProps = (request: HoppRESTRequest) => { +export const getSharedRequestProps = ( + request: HoppRESTRequest, + getUpdatedRequest?: () => HoppRESTRequest +) => { return { get url() { - return request.endpoint + // For pre-request scripts, read from updated request to see mutations + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.endpoint }, get method() { - return request.method + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.method }, get params() { - return request.params + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.params }, get headers() { - return request.headers + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.headers }, get body() { - return request.body + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.body }, get auth() { - return request.auth + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.auth }, get requestVariables() { - return request.requestVariables + const currentRequest = getUpdatedRequest ? getUpdatedRequest() : request + return currentRequest.requestVariables }, } } diff --git a/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts b/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts new file mode 100644 index 00000000..ed1e3c3f --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/test-helpers.ts @@ -0,0 +1,192 @@ +/** + * Consolidated test helpers for all namespace tests + * + * This file provides reusable helper functions to eliminate duplication + * across 45+ test files that previously had inline `func` definitions. + */ + +import { getDefaultRESTRequest } from "@hoppscotch/data" +import * as TE from "fp-ts/TaskEither" +import { pipe } from "fp-ts/function" +import { runTestScript, runPreRequestScript } from "~/node" +import { TestResponse, TestResult } from "~/types" + +// Default fixtures used across test files +export const defaultRequest = getDefaultRESTRequest() +export const fakeResponse: TestResponse = { + status: 200, + statusText: "OK", + responseTime: 0, + body: "hoi", + headers: [], +} + +/** + * Run a test script and return the test results + * + * This is the most common pattern used across all test files. + * Replaces the inline `func` helper pattern. + * + * @param script - The test script to execute + * @param envs - Environment variables (defaults to empty) + * @param response - Response object (defaults to fakeResponse) + * @param request - Request object (defaults to defaultRequest) + * @returns TaskEither containing test results + * + * @example + * ```typescript + * test("pm.expect assertion", () => { + * return expect( + * runTest(`pm.test("test", () => pm.expect(1).to.equal(1))`, { + * global: [], + * selected: [] + * })() + * ).resolves.toEqualRight([...]) + * }) + * ``` + */ +export const runTest = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = fakeResponse, + request: ReturnType = defaultRequest +) => + pipe( + runTestScript(script, { + envs, + request, + response, + }), + TE.map((x) => x.tests) + ) + +/** + * Run a pre-request script and return the environment variables + * + * Used for testing pre-request scripts that modify environment variables. + * + * @param script - The pre-request script to execute + * @param envs - Initial environment variables (defaults to empty) + * @param request - Request object (defaults to defaultRequest) + * @returns TaskEither containing environment variables + * + * @example + * ```typescript + * test("pm.environment.set in pre-request", () => { + * return expect( + * runPreRequest( + * `pm.environment.set("key", "value")`, + * { global: [], selected: [] } + * )() + * ).resolves.toEqualRight({ + * global: [], + * selected: [{ key: "key", value: "value", secret: false }] + * }) + * }) + * ``` + */ +export const runPreRequest = ( + script: string, + envs: TestResult["envs"], + request: ReturnType = defaultRequest +) => + pipe( + runPreRequestScript(script, { + envs, + request, + }), + TE.map((x) => x.updatedEnvs) + ) + +/** + * Run a test script with custom response + * + * Convenience wrapper when you only need to customize the response. + * + * @param script - The test script to execute + * @param response - Custom response object + * @param envs - Environment variables (defaults to empty) + * @returns TaskEither containing test results + */ +export const runTestWithResponse = ( + script: string, + response: TestResponse, + envs: TestResult["envs"] = { global: [], selected: [] } +) => runTest(script, envs, response) + +/** + * Run a test script with custom request + * + * Convenience wrapper when you only need to customize the request. + * + * @param script - The test script to execute + * @param request - Custom request object + * @param envs - Environment variables (defaults to empty) + * @param response - Response object (defaults to fakeResponse) + * @returns TaskEither containing test results + */ +export const runTestWithRequest = ( + script: string, + request: ReturnType, + envs: TestResult["envs"] = { global: [], selected: [] }, + response: TestResponse = fakeResponse +) => runTest(script, envs, response, request) + +/** + * Run a test script with empty environments + * + * Convenience wrapper for the most common case (no environment variables). + * + * @param script - The test script to execute + * @param response - Response object (defaults to fakeResponse) + * @returns TaskEither containing test results + */ +export const runTestWithEmptyEnv = ( + script: string, + response: TestResponse = fakeResponse +) => runTest(script, { global: [], selected: [] }, response) + +/** + * Run a test script and return the environment variables (not test results) + * + * Used for testing scripts that modify environment variables but you want + * to inspect the final env state rather than test results. + * + * This is different from runPreRequest which uses runPreRequestScript. + * This uses runTestScript but extracts envs instead of tests. + * + * @param script - The test script to execute + * @param envs - Initial environment variables + * @param response - Response object (defaults to fakeResponse) + * @param request - Request object (defaults to defaultRequest) + * @returns TaskEither containing environment variables + * + * @example + * ```typescript + * test("env mutation in test script", () => { + * return expect( + * runTestAndGetEnvs( + * `pw.env.set("key", "value")`, + * { global: [], selected: [] } + * )() + * ).resolves.toEqualRight({ + * global: [], + * selected: [{ key: "key", value: "value", secret: false }] + * }) + * }) + * ``` + */ +export const runTestAndGetEnvs = ( + script: string, + envs: TestResult["envs"], + response: TestResponse = fakeResponse, + request: ReturnType = defaultRequest +) => + pipe( + runTestScript(script, { + envs, + request, + response, + }), + TE.map((x: TestResult) => x.envs) + ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6aac8d74..b6922e39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1158,6 +1158,9 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + chai: + specifier: 6.2.0 + version: 6.2.0 faraday-cage: specifier: 0.1.0 version: 0.1.0 @@ -1180,6 +1183,9 @@ importers: '@relmify/jest-fp-ts': specifier: 2.1.1 version: 2.1.1(fp-ts@2.16.11)(io-ts@2.2.22(fp-ts@2.16.11)) + '@types/chai': + specifier: 5.2.2 + version: 5.2.2 '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -8870,6 +8876,10 @@ packages: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -24957,6 +24967,8 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 + chai@6.2.0: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1