Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unable to change window.location using Object.defineProperty #5124

Closed
simon360 opened this issue Dec 18, 2017 · 84 comments
Closed

Unable to change window.location using Object.defineProperty #5124

simon360 opened this issue Dec 18, 2017 · 84 comments

Comments

@simon360
Copy link

simon360 commented Dec 18, 2017

Do you want to request a feature or report a bug? Report a bug

What is the current behavior?

Calling the following from inside a test suite:

Object.defineProperty(location, "hostname", {
  value: "example.com",
  writable: true
});

throws the following error:

    TypeError: Cannot redefine property: hostname
        at Function.defineProperty (<anonymous>)

What is the expected behavior?

The code should not throw an exception, and window.location.hostname === "example.com" should evaluate true.

From the looks of it, jsdom now sets window.location to be unforgeable. The only way to change the values within window.location is to use reconfigure, but (per #2460) Jest doesn't expose jsdom for tests to play around with.

Please provide your exact Jest configuration and mention your Jest, node,
yarn/npm version and operating system.

Jest version: 22.0.1
Node version: 8.6.0
Yarn version: 1.2.0
OS: macOS High Sierra 10.13.2

@simon360 simon360 changed the title Unable to change window.location using defineProperty Unable to change window.location using Object.defineProperty Dec 18, 2017
@oliverzy
Copy link

oliverzy commented Dec 19, 2017

I have the similar issue. You can create your own JSDOMEnvironment and expose jsdom object to the global like this.

const JSDOMEnvironment = require('jest-environment-jsdom');

module.exports = class CustomizedJSDomEnvironment extends JSDOMEnvironment {
  constructor(config) {
    super(config);
    this.global.jsdom = this.dom;
  }

  teardown() {
    this.global.jsdom = null;
    return super.teardown();
  }
};

And then you can call jsdom.reconfigure in your test case as you like

@SimenB
Copy link
Member

SimenB commented Dec 19, 2017

That's a good workaround, thanks for sharing!

You should return super.teardown(); as it's a promise, btw

@simon360
Copy link
Author

simon360 commented Dec 19, 2017

Perfect, @oliverzy - I'll give that a try. Thanks!

Is there an appropriate place to document this? It seems to be a question that comes up reasonably often; hopefully, future issues could be cut down if this were integrated in the docs?

robmcguinness pushed a commit to Availity/sdk-js that referenced this issue Dec 19, 2017
jestjs/jest#5124

```
Object.defineProperty(location, "hostname", {
  value: "example.com",
  writable: true
});
```

```
TypeError: Cannot redefine property: hostname
        at Function.defineProperty (<anonymous>)
```
@simon360
Copy link
Author

This solution didn't quite work.

Inside our test files, it seems like global is set to be JSDom's window object.

In other words, inside a test suite, global is the same as window, but inside the class that extends JSDOMEnvironment, global comes from Node's environment.

As a result, having this:

describe("test suite", () => {
  it("should not fail", () => {
    global.jsdom.reconfigure({
      url: "https://www.example.com/"
    });
  });
});

fails because global.jsdom is undefined.

I got around it by doing this, but I'm not super fussed about it.

const JSDOMEnvironment = require("jest-environment-jsdom");

module.exports = class JSDOMEnvironmentGlobal extends JSDOMEnvironment {
  constructor(config) {
    super(config);

    this.dom.window.jsdom = this.dom;
  }
};

With this environment, global.jsdom inside a test suite is equal to this.dom, and the test suite above works.

To me, it feels like setting jsdom to be a property of its own window object is bound to fall apart eventually - is there a cleaner way to do it?

@oliverzy
Copy link

oliverzy commented Dec 19, 2017

you need to write jsdom rather than global.jsdom in your tests.

@simon360
Copy link
Author

@oliverzy Like this?

describe("test suite", () => {
  it("should not fail", () => {
    jsdom.reconfigure({
      url: "https://www.example.com/"
    });
  });
});

That throws jsdom is not defined, but I may be misinterpreting.

@danielbayerlein
Copy link

danielbayerlein commented Dec 19, 2017

@simon360 Please configure the testEnvironment with the code from @oliverzy, see https://facebook.github.io/jest/docs/en/configuration.html#testenvironment-string

@simon360
Copy link
Author

@danielbayerlein my Jest config has this:

"testEnvironment": "@wel-ui/jest-environment-jsdom-global"

where @wel-ui/jest-environment-jsdom-global is the name of a package in our monorepo. The environment is getting used correctly, though, because the solution that sets jsdom on window works as expected.

@modestfake
Copy link

modestfake commented Dec 19, 2017

BTW, does anyone know why the original solution doesn't work in the new version?
This one:

Object.defineProperty(location, "hostname", {
  value: "example.com",
  writable: true
});

@SimenB
Copy link
Member

SimenB commented Dec 19, 2017

@modestfake we have upgraded from JSDOM@9 to JSDOM@11, my guess is that they changed how the variable is defined

@modestfake
Copy link

modestfake commented Dec 19, 2017

@SimenB Got it. Just found a description of jsdom reconfigure method.

The top property on window is marked [Unforgeable] in the spec, meaning it is a non-configurable own property and thus cannot be overridden or shadowed by normal code running inside the jsdom, even using Object.defineProperty.

@simon360
Copy link
Author

I added a new repository to demonstrate this behaviour. Is anyone able to reproduce it by cloning locally?

https://github.com/simon360/test-environment-for-jest

@modestfake
Copy link

modestfake commented Dec 19, 2017

@simon360 reproduced
image

@modestfake
Copy link

modestfake commented Dec 19, 2017

@simon360 I've found. You've missed this keyword when defining global.jsdom:

const JSDOMEnvironment = require("jest-environment-jsdom");

module.exports = class JSDOMEnvironmentGlobal extends JSDOMEnvironment {
  constructor(config) {
    super(config);

    this.global.jsdom = this.dom;
  }

  teardown() {
    this.global.jsdom = null;

    return super.teardown();
  }
};

@qndrey
Copy link

qndrey commented Dec 19, 2017

@modestfake
Copy link

@andrewBalekha What about this?

jsdom.reconfigure({
  url: 'https://www.example.com/endpoint?queryparam1=15&queryparam2=test'
});

@simon360
Copy link
Author

simon360 commented Dec 19, 2017

Thanks @modestfake - sorry for the dumb mistake!

Ok, I see it now - this.global on a Jest environment object gets set as global in a Jest test file. That makes sense - thanks for helping me through it! If there's enough interest, I could package the repaired version of that repo and put it on npm as jest-environment-jsdom-global.

However, I do hope there's a cleaner way to do this in Jest in the future. This isn't a low friction way to change window.location -

Could there be a new docblock, like there is for @jest-environment? For example...

/**
 * @jest-url https://www.example.com/
 */

Or, maybe JSDom can be exposed on a special part of the jest object - something like:

jest.environment.jsdom.reconfigure({
  url: "https://www.example.com/"
});

(which would have the added benefit of being able to change window.top)

@SimenB
Copy link
Member

SimenB commented Dec 19, 2017

We have merged #5003 now. being able to add it as a docblock might make sense, not sure. @cpojer? We could deprecate testUrl as well, as it can be provided through that new option.

If there's enough interest, I could package the repaired version of that repo and put it on npm as jest-environment-jsdom-global.

I think that makes sense in any case, as it does more than just let you set url - it exposes the full JSDOM to the environment

@msholty-fd
Copy link

@andrewBalekha Object.defineProperty(location, 'search', { ...options }); throws the same error as window.location. Thanks for the suggestion though.

@ekelvin
Copy link

ekelvin commented Dec 19, 2017

Object.defineProperty(window.location, 'href', {
set: newValue => { currentUrl = newValue; },
});
I had this in previous versions and now throws error.
If I add writable: true
throws another exception that I can't specify both accessor and writable

@simon360
Copy link
Author

I've published a new package on npm called jest-environment-jsdom-global, which may help with the problems some people are having with Object.defineProperty.

@danielbayerlein
Copy link

Does anyone have a workaround for { writable: true }?

For example:

Object.defineProperty(window.location, 'href', { writable: true })

...

Object.defineProperty(window.location, 'hash', { writable: true })

...

Object.defineProperty(window.location, 'search', { writable: true })

@modestfake
Copy link

@danielbayerlein read this thread. You need to create custom environment. Previous message contains url with example

@danielbayerlein
Copy link

danielbayerlein commented Dec 21, 2017

@modestfake I've already read this thread and #5124 (comment) works fine. But I've another use case. With Jest 21.x.x I have set Object.defineProperty(window.location, 'href', { writable: true }) without the URL - only { writable: true }. If I set the URL, then the test makes no sense.

@modestfake
Copy link

@danielbayerlein what the use case to make it writable but not override it actually? Maybe understanding this can help me to come up with workaround

@danielbayerlein
Copy link

I've a function that changes the URL.

routing.js

...

export function redirectToErrorPage () {
  window.location.href = '/error.html'
}

...

routing.test.js

test('redirect to the error page', () => {
  ...
  expect(window.location.href).toBe('/error.html')
  ...
})

With Jest 21.x.x I have set Object.defineProperty(window.location, 'href', { writable: true })

@coolzjy
Copy link

coolzjy commented Apr 28, 2019

probably a better solution:

import { URL } from 'whatwg-url';

const location = new URL(window.location.href);
location.assign = jest.fn()
location.replace = jest.fn()
location.reload = jest.fn()

delete window.location
window.location = location

@LuanAraldi
Copy link

Solved my issue using the solution given by @kdelmonte, I've had to mock the window.location.search variable. so I've used

window.history.pushState({}, null, '?skuId=1234')

@qjnz
Copy link

qjnz commented Jul 23, 2019

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

rk-for-zulip added a commit to rk-for-zulip/zulip-mobile that referenced this issue Oct 16, 2019
For a later test, we'll want to be able to set up the test environment
to appear as though it's a browser at a specified URL. Jest currently
uses `jsdom` as its mock-browser environment, but doesn't (presently)
expose the `jsdom` facilities needed to set the mock `location`. (See
jestjs/jest#5124.)

Fortunately, Jest does allow alternate mock environments. We install
one which is identical to Jest's default environment, except that it
does expose that capability.
@clearyandzap
Copy link

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

This is no longer workin 😢

@asnewman
Copy link

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

This is no longer workin 😢

Not working for me either

@gjlacerda
Copy link

gjlacerda commented Feb 6, 2020

I solved this doing:

delete window.location
window.location = {
  href: 'http://example.org/,
}

@alshdavid
Copy link

I'm using the following mock as a utility for working with Location

export class MockLocation extends URL implements Location {
  ancestorOrigins: any = []
  toString = jest.fn().mockImplementation(() => this.toString())
  assign = jest.fn(href => this.href = href)
  replace = jest.fn(href => this.href = href)
  reload = jest.fn()
  
  constructor(
    url: string = 'http://mock.localhost',
  ) {
    super(url)
  }

  onWindow(window: Window) {
    Object.defineProperty(window, 'location', { 
      writable: true,
      value: this
    });
    return this
  }
}

Then in my tests

let location: MockLocation

beforeEach(() => {
    location = new MockLocation(MOCK_PARTNER_URL).onWindow(window)
})

@AndrewSouthpaw
Copy link
Contributor

AndrewSouthpaw commented Jun 9, 2020

I found myself stubbing tricky objects like these all the time, and created a flexible helper function:

export const safelyStubAndThenCleanup = (target, method, value) => {
  const original = target[method]
  beforeEach(() => {
    Object.defineProperty(target, method, { configurable: true, value })
  })
  afterEach(() => {
    Object.defineProperty(target, method, { configurable: true, value: original })
  })
}

And then usage:

describe('when on /pages', () => {
  safelyStubAndThenCleanup(window, 'location', { pathname: '/pages' })

  it('should do something neat', () => { /* ... */ })
})

And you can stub whatever you want: pathname, href, etc... This gets you the added free benefit of cleanup.

The key is you can't mess with location itself, so just swap out location with a fake, and then put it back when the test is done.

@Jazzmanpw
Copy link

As I can see in my debugging session, global.location is implemented via getter but not a simple property. Wouldn't it be safer to redefine it like this?

let originalLocationDescriptor;
beforeAll(() => {
  originalLocationDescriptor = Object.getOwnPropertyDescriptor(global, 'location');
  delete global.location;
  global.location = {};
});
afterAll(() => {
  Object.defineProperty(global, 'location', originalLocationDescriptor);
}):

Though it's hard to imagine why would I want to use the original global.location, it seems just a bit more correct.
And this code works fine for me, of course. I just access location.pathname, but this object may be easily extended with some jest.fn() if needed.

@hyphenized
Copy link

This has been working for me, using jest 26.5

function stubLocation(location) {
  beforeEach(() => {
    jest.spyOn(window, "location", "get").mockReturnValue({
      ...window.location,
      ...location,
    });
  });
}

stubLocation({ pathname: "/facebook/jest/issues/5124" });

test("mocks location prop", () => {
  expect(window.location.pathname).toEqual("/facebook/jest/issues/5124");
});

@didagu
Copy link

didagu commented Nov 13, 2020

Adding an example of testing location.search based on @vastus's solution:

  test('gets passed query param and returns it as a string if it exists', () => {
    history.replaceState({}, 'Test', '/test?customer=123');
    const customerId = getQueryParam('customer');
    expect(customerId).toBe('123');
  });

This worked exactly well for the problem I was having

@sanek306
Copy link

sanek306 commented Mar 8, 2021

Maybe it's help anyone.

function shallowClone(obj, assignObj) {
  const clone = Object.create(Object.getPrototypeOf(obj));
  const descriptors = Object.getOwnPropertyDescriptors(obj);
  const assignObjDescriptors = Object.getOwnPropertyDescriptors(
    Object.getPrototypeOf(assignObj),
  );
  Object.defineProperties(clone, { ...descriptors, ...assignObjDescriptors });
  Object.keys(assignObj).forEach((key) => {
    Object.defineProperty(clone, key, {
      configurable: true, value: assignObj[key], writable: true,
    });
  });
  return clone;
}

const safelyStubAndThenCleanup = (target, method, value = {}) => {
  const original = target[method];

  beforeEach(() => {
    Object.defineProperty(target, method, {
      configurable: true, value: shallowClone(original, value),
    });
  });
  afterEach(() => {
    delete target[method];
    target[method] = original;
  });
};

const safelyStubAndThenCleanupWindow = () => {
  MockLocation.mockHistoryState();
  safelyStubAndThenCleanup(window, 'location', new MockLocation(window.location.href));
};

const getAbsoluteUrl = (relativeUrl) => {
  const { location: { origin } } = window;

  if (relativeUrl) {
    const preparedRelativeUrl = relativeUrl[0] === '/'
      ? relativeUrl.substring(1, relativeUrl.length)
      : relativeUrl;

    return relativeUrl ? `${origin}/${preparedRelativeUrl}` : origin;
  }

  return origin;
}

class MockLocation {
  constructor(url) {
    const {
      hash,
      hostname,
      origin,
      pathname,
      port,
      protocol,
      search,
    } = new URL(url);

    this.hash = hash;
    this.hostname = hostname;
    this.protocol = protocol;
    this.pathname = pathname;
    this.search = search;
    this.origin = origin || hostname;
    this.port = port;
  }

  static mockHistoryState() {
    const { pushState, replaceState } = window.history;

    window.history.pushState = (...arg) => {
      const url = arg[2];
      window.location.href = url;
      pushState.apply(window.history, arg);
    };

    window.history.replaceState = (...arg) => {
      const url = arg[2];
      window.location.href = url;
      replaceState.apply(window.history, arg);
    };
  }

  get href() {
    return `${this._protocol}//${this.host}${this._pathname}${this._search}`;
  }

  set href(value) {
    const isRelativeUrl = !value.includes(this.protocol);
    const {
      hash,
      href,
      hostname,
      origin,
      pathname,
      port,
      protocol,
      search,
    } = new URL(isRelativeUrl ? getAbsoluteUrl(value) : value);
    this.hash = hash;
    this.hostname = hostname;
    this.protocol = protocol;
    this.pathname = pathname;
    this.search = search;
    this.origin = origin || hostname;
    this.port = port;

    return href;
  }

  get protocol() {
    return this._protocol;
  }

  set protocol(value) {
    this._protocol = value;
  }

  get host() {
    return `${this._hostname}${this.port ? ':' : ''}${this.port}`;
  }

  get hostname() {
    return this._hostname;
  }

  set hostname(value) {
    this._hostname = value;
  }

  get port() {
    return this._port;
  }

  set port(value) {
    this._port = value;
  }

  get hash() {
    return this._hash;
  }

  set hash(value) {
    this._hash = value;
  }

  get pathname() {
    return this._pathname;
  }

  set pathname(value) {
    this._pathname = value;
  }

  get search() {
    return this._search;
  }

  set search(value) {
    this._search = value;
  }

  get origin() {
    return this._origin;
  }

  set origin(value) {
    this._origin = value;
  }

  assign(value) {
    this.href = value;
  }

  replace(value) {
    this.href = value;
  }

  toString() {
    return this.href;
  }
}

You just need write in setupTests.js

safelyStubAndThenCleanupWindow();

@TazmanianDI
Copy link

TazmanianDI commented Sep 7, 2021

Most of the workarounds in here haven't been working for me. In particular, the jest-environment-jsdom-global doesn't seem to work because installing jest-environment-jsdom causes setImmediate to no longer be defined in the global scope and we rely on it fairly heavily for use in the flushPromises solution. I had some issues with some of the other solutions causing security exceptions with history when trying to change the origin.

In the end, I decided that we didn't need to change the origin for our purposes and in that case, we might as well just use the built in browser mechanism for changing the URL using history.replaceState. If your application doesn't rely on history listeners, this might be perfectly acceptable for you and doesn't rely on any other libraries or hacks. I wish jest would just implement a proper mechanism for changing the URL.

/**
 * Changes the URL used by the global JSDOM instance in the current window.
 *
 * NOTE: This does not change the origin which would result in a security exception.
 */
function changeJSDOMURL(url: string) {
  const newURL = new URL(url);
  const href = `${window.origin}${newURL.pathname}${newURL.search}${newURL.hash}`;
  history.replaceState(history.state, null, href);
}

@christianopaets
Copy link

I solved this doing:

delete window.location
window.location = {
  href: 'http://example.org/,
}

This still works

da70 added a commit to NYULibraries/ariadne that referenced this issue Feb 21, 2023
…..) with `window.location` method used in List.test.js [+]

Reasons:

- Consistency with List.test.js
- Altering `window` with custom `location` is the method used by Facebook, the owners of Jest: jestjs/jest#890 (note that the original method stopped working -- see jestjs/jest#5124)
- `window.history.pushState` simulates something slightly different than what we are testing for, and in a real browser changes state and also triggers events.  While here in these tests we are not dealing with a browser `window` object but the one provided by jsdom, it would still seem more brittle, because whether or not the jsdom project implements history to work the way it does in actual browsers, it might have an API in flux, and we don't want to have to keep track of the fluctuations and potential side effects.  The `delete window.location` followed by replacement with our own `location` is not subject to changes to the jsdom API (in theory, although see the 2nd link in the previous point).
@abudayah
Copy link

abudayah commented Feb 27, 2023

Had the same issue, but this piece of code worked with me

  Object.defineProperty(window, "location", {
    value: new URL("http://example.com"),
    configurable: true,
  });

@garethweaver
Copy link

I found the best way to do it for my function which was calling window.location = 'http://www.foo.com' was to use a beforeEach and afterEach.

let tmpWindow = {};

describe('my test, () => {
  beforeEach(function () {
    tmpWindow = window.location;
    delete window.location;
  })

  afterEach(function () {
    window.location = tmpWindow;
  })

  ...tests

@tkrotoff
Copy link
Contributor

tkrotoff commented Oct 30, 2023

I've fixed this issue by re-implementing window.location & window.history: https://gist.github.com/tkrotoff/52f4a29e919445d6e97f9a9e44ada449

It's heavily inspired by #5124 (comment) (thx @sanek306) and firefox-devtools window-navigation.js (thx @gregtatum & @julienw)

It comes with unit tests and it works well with our source base using Vitest.

You can then use window.location in your tests like you would expect:

it('should ...', () => {
  window.location.pathname = '/product/308072';

  render(<MyComponent />);

  const link = screen.getByRole<HTMLAnchorElement>('link', { name: 'Show more' });
  expect(link.href).toBe('http://localhost:3000/product/308072/more');
});

it('should ...', () => {
  const assignSpy = vi.spyOn(window.location, 'assign');

  render(<MyComponent />);

  const input = screen.getByRole<HTMLInputElement>('searchbox');
  fireEvent.change(input, { target: { value: 'My Search Query' } });
  fireEvent.submit(input);

  expect(assignSpy).toHaveBeenCalledTimes(1);
  expect(assignSpy).toHaveBeenNthCalledWith(1, '/search?query=My+Search+Query');

  assignSpy.mockRestore();
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests