k6 icon indicating copy to clipboard operation
k6 copied to clipboard

Standartize what `open` , `k6/experimental/fs.open` and `k6/net/grpc.Client#load` are relative to

Open mstoykov opened this issue 1 year ago • 3 comments

What?

Have open , k6/experimental/fs.open and k6/net/grpc.Client#load be relative to the same path for each execution.

Or at least specify and stick to one new norm among them and anything else related to loading files.

Why?

Consistency, ease of reasoning, also potentially debugging and using one in place of the other.

Current situation:

k6/experimental/fs.open and k6/net/grpc.Client#load are relative to the entry point of k6 - so either the folder of the argument to run or if is - (stdin) - the folder k6 is executed in.

I am pretty sure this was a mistake in gRPC (#2781). fs.open was more of "let's not make this more complicated especially given this is an experimental module".

open on the other hand is relative to the currently executed file, but I would argue by design it was supposed to be relative to the file it is called in. Similar to how require (previously buggy) is defined, and also how imports both static and dynamic usually work in ECMAScript. And how they are actually now implemented.

So for me this seems like a bug all around.

Examples of the behaviour:

If we have the following structure

/root/main.js
/path/A.JS
/path/B/B.js

main.js:

import "/path/A.JS"

A.js

import { openHelper, fsOpenHelper } from "./B/B.js";
import fs from "k6/experimental/fs";
open("./some/path/file.csv") // this is /path/some/path/file.csv - which seems pretty correct
fs.open("./some/path/file.csv") // this is /root/some/path/file.csv - which isn't the worst ... but still confusing
// same for grpc 
openHelper("./some/path/file.csv") // this is /path/some/path/file.csv - which is ... quite impossible to guess
fsOpenHelper("./some/path/file.csv") // this is /root/some/path/file.csv - at least this is consistent

B.js

import fs from "k6/experimental/fs";

export function openHelper(s) { open(s) }
export function fsOpenHelper(s) { fs.open(s) }

In this case the openHelper is arguably the "good" example for the current behaviour, but if you wanted to have helper that does something completely different but as part of it it opens

export function checkSomethingExists(id) {
    return open("./some/path/to/file.json")[id]
} 

That relative path will be relative to whatever calls checkSomethingExists as in the example above.

Proposal:

Change all of them to be relative to the file being called in - how imports and require work.

Given that the three currently have 3 different behaviours and only on of them is experimental - we will need to have at least a couple of releases with warnings about this.

I recommend we do the same as with require - test that the new implementation will not give a different result (which is possible in some cases) and only if it isn't - then emit a warning.

We can also recommend using https://github.com/grafana/k6/issues/3856 if it is implemented at the time.

The existance of import.meta.resolve doesn't change the fact that the functions having different things they are relative to is a confusing behaviour.

Related issues and PRs

https://github.com/grafana/k6/issues/2674 https://github.com/grafana/k6/pull/2782 https://github.com/grafana/k6/issues/3534

mstoykov avatar Jul 18 '24 11:07 mstoykov

I would not go the require way. You have a few limiting naïve assumptions about the project structure.

what is really missing for me is to be able to express in paths relative to current-work-dir, not the installation dir. I actually had to code an extension for that.

Here's the use-case.

Consider that we ship an installation of our extended compilation together with a bunch of js libs that facilitate API testing with all kinds of patterns, export pre-configured accessors to all kinds of data storages, and pre-configured clients to the SUT. The end result is that the test scripts are very DRY and in a very high language, engaging for semi-technical stake-holders as well.|(where all the details are encapsulated in the libs)

We load configuration and fixture files using open, and we expect to load them from current-work-directory, not from the installation directory, so that we can use the same dists/k6 + lib with different test setups across different project directories, each represent a different suite made of a set of fixtures, scenarios and stages.

When dist/k6 is added to the $PATH and we expect to run it from the project directory - the installation path becomes irrelevant, while now we are brutally couple with it.

Same behavior with a native installation and a docker image that packs the setup. Now, in docker env - it forces me to mount the user scripts & fixtures volume on a sub directory of the installation dir, which ...kinda limiting... 🤷

osher avatar Feb 05 '25 07:02 osher

Thank you for input 🙇 This is exactly why this issue is open.

what is really missing for me is to be able to express in paths relative to current-work-dir, not the installation dir.

This is arguably what experimental fs.open currently does(see correction below), and you can use it (potentially with a wrapper) to make it useful for you. Which I would argue will work way nicer than having to have an extension.

There isn't really a way to get the currently working directory in k6 and it is mentioned in https://github.com/grafana/k6/issues/2259 - arguably this is going to be a very easy addition.

I would argue that this is related but not on the topic of what the functions here should be relative to.

Arguably I would prefer if we have single behavior for this so that it is at least consistent if not intuitive or perfect.

Looking over all other tools it does seem that while require and imports do what I propose, open like things usually are relative to the current working directory. Which is a pretty good argument for actually doing the same.

Arguably open in k6 didn't do that because there was not import.meta.resolve so if you needed a way to open path relative to the current file it was not possible. I would argue that this (opening relative to the current file) is the much more common case.

Now that k6 does have import.meta.resolve that is less of an issue and arguably fixes this problem for the experimental fs and the grpc Client.load.

From implementation perspective (which arguably isn't the most important one, but still) this is way easier. The current working directory is readily available in the init context and is already the behavior for 2 of the 3 functions.

Now of course while writing this something in the back of my head was "not exactly". So after some checking - it isn't an easy :(. Basically for old reasons around how require and open were implemented in the old days the field we call pwd in k6 is actually just the main module path.

This also means that actually experimental fs's open and grpc Client.load are relative to the main module NOT to the working directory.

I have confirmed that both deno's Deno.open and node's fs.open are relative to where you execute deno or node not to the file that is on the cli.

CORRECTION: Unfortunately this likely will mean that implementation wise this will be either convoluted or will need to be a breaking change on the go API as well. This means that just like with open and require it might be the same for some uses cases but not all.

It also makes the breaking change for open way bigger IME. And it also is the most used of the three, which is not great.

Now ideally we would be dropping open so 🤷 - maybe we just keep it as is and warn users to go away from open the moment the experimental fs module stops being experimental. cc @grafana/k6-core

But that still means that we have to break grpc Client.load and experimental fs.open. Which makes it a bit less nice than I first thought at first.

Some of your example make a compelling argument for being relative to the current working directory, some don't or at least I am not certain that they will.

Again adding a way to get the current working directory + module.meta.resolve arguably make any of the problems solvable by either going relative to the current working directory or calling module.meta.resolve. Arguably even now with import.meta.resolve in the main module you can get the main module directory which is unfortunately what we call pwd inside of the go codebase.

We likely should look into this more especially before experimental fs is made not experimental.

p.s. as I did go over the post after I have written most of it to correct it, there is a good chance that I have missed some place that needs a fix or rewording. So if something seems off -please ask.

mstoykov avatar Feb 07 '25 15:02 mstoykov

+1 for resolving relative paths against the current working directory at the time of process startup. Not the directory of the script file.

Consider:

  • The script receives a file name in an environment variable and reads it, like const data = JSON.parse(open(__env.DATA_FILE));.
  • While in the directory named /home/username/myproject, the user runs k6 foo/test.js --env DATA_FILE=bar/data.json.

Expected: the script /home/username/myproject/foo/test.js runs and reads the file /home/username/myproject/bar/data.json.

Observed: attempts to read /home/username/myproject/foo/bar/data.json, fails.

The user has no reason to expect that file names need to be specified relative to the script; shell tab completion works against working directory.

(k6/experimental/fs.open also resolves against the modified base.)

yurikhan avatar Jun 09 '25 15:06 yurikhan