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

Java visual #276

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft

Java visual #276

wants to merge 8 commits into from

Conversation

titusfortner
Copy link
Collaborator

This is a PR against #275, and it'll stay in Draft until that one is merged.
It subsumes #259

I finally really like this API!

Easiest to see what this does from the examples:

Using default:

// 1. Create Visual Session object with minimum required
VisualSession session = new VisualSession("Example test name");
// 2. Start Session to get the driver
RemoteWebDriver driver = session.start();
// 3. Use the driver in your tests just like normal
driver.get("https://www.saucedemo.com/");
// 4. Take snapshot
session.takeSnapshot("Name of Snapshot");
// 5. Stop the Session
session.stop();
}

Updating Options:

// 1. Create Sauce Options object with desired test settings
// Note: setting the test name is required
SauceOptions sauceOptions = SauceOptions.chrome()
.setName("Example Visual Options Test")
.build();
// 2. Create Visual Options instance with Sauce Options instance
VisualOptions visualOptions = new VisualOptions(sauceOptions);
// 3. Set desired visual options
visualOptions.setFailOnNewStates(false);
// 4. Create Visual Session instance with Visual Options instance
VisualSession session = new VisualSession(visualOptions);
// 5. Start Session to get the driver
RemoteWebDriver driver = session.start();
// 6. Use the driver in your tests just like normal
driver.get("https://www.saucedemo.com/");
// 7. Take snapshot
session.takeSnapshot("Name of Snapshot");
// 8. Stop the Session
session.stop();

Cool things this does:

  • Automatically populates the default branch name with the actual branch name if it can determine it
  • Automatically populates the Sauce Labs job with pass/fail based on whether Visual tests pass/fail
  • Useful error when attempting to send a visual command after getting results (i.e., calling "end")

I still need to add javadocs because that's important and stuff

import java.io.InputStreamReader;

public class GitManager {
private static String currentBranch = "_default_";
Copy link

@mykola-mokhnach mykola-mokhnach Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does such branch (default) really exist?

Copy link
Collaborator Author

@titusfortner titusfortner Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't specify anything, Screener uses (default), presumably to make sure it isn't a real branch. Unfortunately, this prevents me from clicking a link with that URL because my IDE doesn't like that character. So I changed the default to _default_, also unlikely to be the actual branch name, but I can click on resulting links.

} catch (IOException | InterruptedException e) {
// Ignore exception and use default
}
return currentBranch;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it ok that a previously remembered value is returned if an exception happens?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to think of a scenario where that could happen and be bad. It's going to just use the default value set at the top. I made these static methods/fields because no one should be switching git branches in the middle of their test execution.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my scenario is:

  • get current branch
  • the branch is changed
  • get current branch and get an exception
  • the previous (incorrect) branch name is returned

If we want to always return the cached value then this method should probably have an explicit condition for that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you can programmatically change branches in the middle of a test run. If it was found before, use that, if it wasn't found before, use default. This is a weird nice-to-have-but-not-very-important feature in the scheme of this project.

Copy link

@mykola-mokhnach mykola-mokhnach Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this totally makes sense to me. What I want to say is that this method is going to still spend time for the command line execution even though the branch name has been already retrieved. the condition I was talking about:

if (!Objects.equal(currentBranch, DEFAULT_BRANCH)) {
  return currentBranch;
}

... do existing stuff to get the branch name from git

BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = reader.readLine();
if (!line.contains("fatal:")) {
currentBranch = line;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the line be trimmed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? (and I don't remember what all I did to get while trying to get this to work when I wrote it over a year ago).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean whether it could be that line ends with a line break. Also, I would assume it would be safer to check the process exit code than the actual stdout


this.passed = (Boolean) results.get("passed");
this.message = (String) results.get("message");
this.status = (String) results.get("status"); // success, failure, error, timeout, cancelled

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to have an enum instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually only do enums for Constructor/Method parameters so users don't have to remember the "magic string" to get the code to work correctly. We could do it for the return value here, but the use case for 99% of people is seeing the result displayed in stdout at the end of their test. I can't see anyone asserting on this or using it in logic anywhere.

this.message = (String) results.get("message");
this.status = (String) results.get("status"); // success, failure, error, timeout, cancelled

for (Map state : (List<Map>) results.get("states")) {
Copy link

@mykola-mokhnach mykola-mokhnach Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could also be rewritten in more functional style:

((List<Map>) results.get("states")).map(VisualSnapshot::new).forEach(snapshots::add)


@Setter @Getter
public class VisualResults {
Map<String, Object> results;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the field be private?

@Override
public URL getSauceUrl() {
try {
return new URL("https://hub.screener.io/wd/hub");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the URL be moved into a constant/config?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. the getSauceUrl() in the superclass is based on sauceUrl field, which is a Data Center Enum, which doesn't apply for visual. How we use it here is probably going to depend on if we're planning on having a new visual data center or somehow merge the screener.io into saucelabs.com. I'll get more info.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sauce does have plans to add data centers (internal link- https://saucedev.atlassian.net/browse/VSLT-2178) I'll figure out what the plan is for formatting, etc.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I just got about future data center URL, so I should definitely create a VisualDataCenter enum for these and update when necessary.

[11:19 AM] the future endpoints are going to be visual.eu-central-1.saucelabs.com and visual.hub.eu-central-1.saucelabs.com

@Override
public void stop(String result) {
if (driver != null) {
System.out.println(getVisualResults().getSummary());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really want to use println? this function does not allow any external configuration/customization

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want users to be able to know how to see Sauce info by looking at their Jenkins console logs, etc.
For instance, the Jenkins plugin does this by default.
Making it configurable is possible, but out of scope for current purposes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really, the link to the build is the most important thing here, imo, which is why it is frustrating that Screener doesn't expose it if the test passes. Trying to get that changed.


@Override
public void stop(Boolean passed) {
String result = passed ? "passed" : "failed";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

magic strings

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know, this is a good point. It comes from our using — https://docs.saucelabs.com/basics/test-config-annotation/test-annotation/#methods which is literally *all magic strings.

When we update the implementation to use the API instead of JS to record pass/fail, then the magic strings are completely useless... Yeah, now I want to deprecate the method in SauceSession instead of supporting it in VisualSession.

}
}

public void newVisualTest(String testName) {
Copy link

@mykola-mokhnach mykola-mokhnach Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like when methods return this instead of void, so calls could be chained

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, I was just getting chastised for using a fluent pattern in code I wrote for Selenium, and that was for a builder construction where it makes more sense to me. In this case it doesn't matter, there is nothing else for the Session to do after this method is called, the next line will be using the driver.

if (driver != null) {
VisualResults results = getVisualResults();
System.out.println(results.getSummary());
String result = results.getPassed() ? "passed" : "failed";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can observe some code duplication here


@Override
public RemoteWebDriver start() {
this.driver = createRemoteWebDriver(getSauceUrl(), visualOptions.toCapabilities());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this. is redundant

private Boolean iframes = null;
private Map<String, Object> iframesOptions = null;

public final List<String> validOptions = Arrays.asList(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually wrap constants into Collections.immutableList

public MutableCapabilities toCapabilities() {
String msg = "Environment Variable or System Property for 'SCREENER_API_KEY' must be provided";
String sauceVisualApiKey = SystemManager.get("SCREENER_API_KEY", msg);
capabilities.setCapability("apiKey", sauceVisualApiKey);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO it is not a very secure practice to expose API keys in capabilities as their values are visible in logs. Why not, for example, use request headers for such purpose?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screener doesn't accept it in the header (because that's for Sauce credentials). It has to be in the capabilities.
As for body vs header, it might be pedantic, but it is security by convention rather than an actual difference in security. Nothing prevents a logger from recording header information even if it is less common. Either way, Sauce removes references to these keys in our logs.

if (key.equals("visual")) {
((Map<String, Object>) entry.getValue()).forEach(this::setCapability);
} else {
sauceOptions.setCapability(key, entry.getValue());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can sauceOptions be null?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, there are 2 constructors. One that accepts a SauceOptions instance, the other creates one.

session.takeSnapshot("Name of Snapshot");

// 8. Stop the Session
session.stop();
Copy link

@mykola-mokhnach mykola-mokhnach Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this call should be in a finally block or AfterEach section

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same below

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The examples directory is for displaying on our website, and are designed to show how easy it is to get the functionality. Best practices would be to use it with the test runner library and not have to think about it at all. :)


assertNull(results.getMessage());

assertEquals(Long.valueOf(1), results.getTotal());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long.valueOf(1) could be simplified to 1L


public void newVisualTest(String testName) {
validateVisualStatus();
driver.executeScript("/*@visual.init*/", testName);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we first verify is the session is active/driver is initialised? I assume customers might be confused by NPE if they call this method without invoking start() first

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment to the below methods. They only make sense while the session is active

assertEquals("VisualTest getVisualResults", snapshot.getGroupName());
assertEquals("accepted", snapshot.getStatus());
String url = snapshot.getUrl();
assertTrue(url.contains(GitManager.getCurrentBranch()));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to use something like Assert.assertThat(x, CoreMatchers.containsString("foo"));. This is giving much better error messages with actual/expected values instead of just true/false

driver.executeScript("/*@visual.snapshot*/", name);
}

public VisualResults getVisualResults() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we make it possible to call this method after the session is stopped?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs the driver to call executeScript, and we don't have the driver after the session is stopped

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I know. Currently NPE is going to be thrown in such case (which is anyway not very useful for a client). My though was that we could automatically cache that result if stop is called, so we don't need to query driver instance anymore.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been treating stop() as the final everything is done method, but yeah, there's no reason we couldn't cache this.

.build();

Map<String, Object> diffOptions = new HashMap<>();
diffOptions.put("structure", true);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this map initialisation syntax a bit too talkative. We might use some available shortcuts instead: https://www.baeldung.com/java-initialize-hashmap

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, guava is a part of selenium, so it is safe to use ImmutableMap.of()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol, yeah, I just started using it. This code is a copy/paste from other stuff I've done. I'll take a look at those examples.


@SneakyThrows
public Map<String, Object> serialize(String key) {
InputStream input = new FileInputStream(new File("src/test/java/com/saucelabs/saucebindings/options.yml"));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually use something like https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html with closeable streams to make sure they are properly closed

@mykola-mokhnach
Copy link

It is a good practice to document public methods in user-facing libs, not sure how much this principle applies to this framework

@titusfortner
Copy link
Collaborator Author

Thanks @mykola-mokhnach ! Yes, this definitely needs the javadocs updated before it can be released. That's just never the fun part. :)

@titusfortner
Copy link
Collaborator Author

And looks like there are Snapshot options that I'm not supporting yet — https://docs.saucelabs.com/visual/e2e-testing/commands-options/#arguments-1

@titusfortner titusfortner force-pushed the java_fixes branch 7 times, most recently from 9aecf9e to fcb089c Compare March 1, 2022 23:27
@titusfortner titusfortner deleted the branch main March 14, 2022 20:19
@titusfortner
Copy link
Collaborator Author

Inadvertently closed because I deleted the merged branch that this was PR'd against. Trying to fix now.

@titusfortner titusfortner reopened this Mar 31, 2022
@titusfortner titusfortner changed the base branch from java_fixes to main March 31, 2022 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants