Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • cse493x-24sp/cse493x-24sp-tests
  • wongyh/cse493x-24sp-tests
2 results
Show changes
Commits on Source (21)
Showing
with 2152 additions and 59 deletions
[flake8]
ignore = E302,E305,E741,W504
max-line-length = 120
__pycache__
tests/web_browser.py
local_file.txt
# cse493x-24sp-tests
CSE 493X Browser Engineering
=============================
This repository contains the autograder tests for CSE 493x
Using this repository
---------------------
## Getting started
In the Github repository we create for you, run:
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
git submodule update --init
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
This command downloads this repository in the `tests` subdirectory.
## Add your files
Throughout the class, we'll likely push new versions of this
repository, to fix bugs or maybe update class-relevant files. You can
update the copy on your computer by running:
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
git submodule update --remote
When you do this, `git diff` or `git commit` or whatever other
commands will show changes to the `.gitmodules` file; those are fine,
go ahead and commit/push them together with any other changes.
```
cd existing_repo
git remote add origin https://gitlab.cs.washington.edu/cse493x-24sp/cse493x-24sp-tests.git
git branch -M main
git push -uf origin main
```
Try running the `run.py` script. Specifically, from your main
repository run:
## Integrate with your tools
$ python3 test/run.py
Summarised results
- [ ] [Set up project integrations](https://gitlab.cs.washington.edu/cse493x-24sp/cse493x-24sp-tests/-/settings/integrations)
chapter1-base-tests.md: passed
chapter1-exercise-http-1-1-tests.md: passed
chapter1-exercise-file-urls-tests.md: passed
chapter1-exercise-redirects-tests.md: passed
chapter1-exercise-caching-tests.md: passed
----------------------------------------------------
Final: all passed
## Collaborate with your team
The same exact script is run by the autograder in Github Actions.
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
The tests
---------
## Test and Deploy
You can find the tests themselves in the `tests/` subdirectory. (Yes, this is `tests/tests/`) The
`chapterN-base-tests.md` file always contains tests for the base
browser from the book for Chapter N. You should get those passing
first. The `chapterN-exercise-X-tests.md` file contains the tests for
the named exercise. Before doing those, read through the file itself.
It may indicate additional functions you have to implement for testing
(beyond those of the book itself) or have hints, additional rules, or
further explanations. You'll be graded on passing both the `base` and
`exercise` tests.
Use the built-in continuous integration in GitLab.
The full list of exercises we plan to assign in CSE 493x are:
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
**Chapter 1**: HTTP/1.1, `file://` URLs, redirects, caching
***
**Chapter 2**: Line breaks, resizing, scrollbar, emoji
# Editing this README
**Chapter 3**: Centered text, superscripts, soft hyphens, small caps
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
**Chapter 4**: Comments, paragraphs, scripts, quoted attributes
## Suggestions for a good README
**Chapter 5**: Links bar, hidden head, bullets, anonymous block boxes
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
**Chapter 6**: Fonts, width/height, class selectors, shorthand properties
## Name
Choose a self-explaining name for your project.
**Chapter 7**: Backspace, middle-click, fragments, bookmarks
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
**Chapter 8**: Enter key, GET forms, check boxes, rich buttons
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
**Chapter 9**: `Node.children`, `createElement`, IDs, event bubbling
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
**Chapter 10**: New inputs, certificate errors, script access, referer
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
Note that the exercises assigned for future chapters may change
without notice during the quarter. Italicized exercises don't yet
have tests; they'll be written during the quarter.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
Running the tests
-----------------
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
When run, the `run.py` script runs all tests for the current
chapter. But there are some additional options that might be handy:
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
The first argument select a specific chapter. For example, `python3
run.py chapter1` runs the tests for the first chapter. You
can use the argument `all` to run all available tests.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
The first argument can also select a specific exercise. For example,
`python3 run.py chapter1-base` runs the base tests for the
first chapter, while `python3 run.py chapter1-file-urls`
runs tests for the file URLs exercise.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
The tests themselves are written in Markdown using [doctest][doctest]
to run them. Any failing tests will output the relevant paragraph of
Markdown explanation as well as the expected and actual output. To
help with `print`-debugging, any line of output beginning with `!dbg`
is ignored.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
[doctest]: https://docs.python.org/3/library/doctest.html
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
The test framework _mocks_ certain methods in the standard library,
meaning it overwrites them for testing purposes. For example,
`socket.socket` no longer creates a real OS socket; instead, it
creates a mock socket that does not actually make connections over the
network. This makes the tests reproducible and also makes it possible
for the test framework to, for example, inspect the exact bytes sent
over the "socket". For the tests to work, it's important only to use
mocked methods. Specifically, here are the mocked methods in various
modules:
## License
For open source projects, say how it is licensed.
| Library | Methods |
|---------------------|-------------------------------------------------------------------|
| `socket` | `socket` |
| `socket.socket` | `connect`, `send`, `makefile`, `close` |
| `ssl` | `wrap_socket` |
| `certifi` | `where`, `load_default_certs`, `load_verify_locations` |
| `tkinter` | `Tk`, `Canvas`, `font` |
| `tkinter.Tk` | `bind` |
| `tkinter.Canvas` | `create_{text, rectangle, line, oval, polygon}`, `pack`, `delete` |
| `tkinter.font` | `Font` |
| `tkinter.font.Font` | `measure`, `metrics` |
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
#!/usr/bin/env python3
import argparse
import doctest
import os
import sys
import re
import datetime
import tests.wbemocks as wbemocks
import json
import multiprocessing
sys.path.append(os.getcwd())
PDT = datetime.timezone(datetime.timedelta(hours=-7), "PDT")
GH_JSON_PATH = "test/gh.json"
CHAPTER_DEADLINES = {
"chapter1": datetime.datetime(2024, 4, 1, tzinfo=PDT),
"chapter2": datetime.datetime(2024, 4, 8, tzinfo=PDT),
"chapter3": datetime.datetime(2024, 4, 15, tzinfo=PDT),
"chapter4": datetime.datetime(2024, 4, 22, tzinfo=PDT),
"chapter5": datetime.datetime(2024, 4, 29, tzinfo=PDT),
"chapter6": datetime.datetime(2024, 5, 6, tzinfo=PDT),
"chapter7": datetime.datetime(2024, 5, 13, tzinfo=PDT),
"chapter8": datetime.datetime(2024, 5, 20, tzinfo=PDT),
"chapter9": datetime.datetime(2024, 5, 27, tzinfo=PDT),
"chapter10": datetime.datetime(2024,6, 3, tzinfo=PDT),
}
def getCurrentChapter():
chapter = min([
(chapter, deadline)
for chapter, deadline in CHAPTER_DEADLINES.items()
if datetime.datetime.now(datetime.timezone.utc) <= deadline
], default=None, key=lambda x: x[1])
if chapter:
return chapter[0]
else:
print("WARNING: No chapters outstanding, using chapter10", file=sys.stderr)
return "chapter10"
DEFAULT_CICD = getCurrentChapter()
CURRENT_TESTS = {
"chapter1": ["chapter1-base-tests.md",
"chapter1-exercise-http-1-1-tests.md",
"chapter1-exercise-file-urls-tests.md",
"chapter1-exercise-redirects-tests.md",
"chapter1-exercise-caching-tests.md",
],
"chapter2": ["chapter2-base-tests.md",
"chapter2-exercise-line-breaks-tests.md",
"chapter2-exercise-resizing-tests.md",
"chapter2-exercise-scrollbar-tests.md",
"chapter2-exercise-emoji-tests.md",
],
"chapter3": ["chapter3-base-tests.md",
"chapter3-exercise-centered-text-tests.md",
"chapter3-exercise-superscripts-tests.md",
"chapter3-exercise-soft-hyphens-tests.md",
"chapter3-exercise-small-caps-tests.md",
],
"chapter4": ["chapter4-base-tests.md",
"chapter4-exercise-comments-tests.md",
"chapter4-exercise-paragraphs-tests.md",
"chapter4-exercise-scripts-tests.md",
"chapter4-exercise-quoted-attributes-tests.md",
],
"chapter5": ["chapter5-base-tests.md",
"chapter5-exercise-hidden-head-tests.md",
"chapter5-exercise-bullets-tests.md",
"chapter5-exercise-links-bar-tests.md",
"chapter5-exercise-anonymous-boxes-tests.md",
],
"chapter6": ["chapter6-base-tests.md",
"chapter6-exercise-fonts-tests.md",
"chapter6-exercise-width-height-tests.md",
"chapter6-exercise-class-selectors-tests.md",
"chapter6-exercise-shorthand-properties-tests.md",
],
"chapter7": ["chapter7-base-tests.md",
"chapter7-exercise-backspace-tests.md",
"chapter7-exercise-middle-click-tests.md",
"chapter7-exercise-fragments-tests.md",
"chapter7-exercise-bookmarks-tests.md",
],
"chapter8": ["chapter8-base-tests.md",
"chapter8-exercise-enter-key-tests.md",
"chapter8-exercise-check-boxes-tests.md",
"chapter8-exercise-get-forms-tests.md",
"chapter8-exercise-rich-buttons-tests.md",
"chapter8-exercise-tab-tests.md",
],
"chapter9": ["chapter9-base-tests.md",
"chapter9-exercise-create-element-tests.md",
"chapter9-exercise-node-children-tests.md",
"chapter9-exercise-ids-tests.md",
"chapter9-exercise-event-bubbling-tests.md",
],
"chapter10": ["chapter10-base-tests.md",
"chapter10-exercise-new-inputs-tests.md",
"chapter10-exercise-certificate-errors-tests.md",
"chapter10-exercise-script-access-tests.md",
"chapter10-exercise-referer-tests.md",
],
}
all_tests = list()
specific_file_tests = {}
'''
add option to run all tests by running script w/ argval 'all',
and add option to run individual test files by name (removing '-exercise-' infix substring if present)
'''
for chapterkey, tests in CURRENT_TESTS.items():
all_tests.extend(tests)
specific_file_tests[chapterkey + '-exercises'] = tests[1:]
for i, test in enumerate(tests):
arg_val = re.sub(r'-exercise', '', test)
arg_val = re.sub(r'-tests.md', '', arg_val)
specific_file_tests[arg_val] = [test]
specific_file_tests[chapterkey + '-' + str(i + 1)] = [test]
CURRENT_TESTS["all"] = all_tests
CURRENT_TESTS.update(specific_file_tests)
REPORT_FIRST_ERROR = False
REPORT_DIFF = False
# Below this are a variety of "fixes" to doctest that make it more user-friendly
old_truncate = doctest._SpoofOut.truncate
def patched_truncate(self, size=None):
"""
Patch the fake doctest stdout to save the output long enough to use when reporting errors
"""
self._old_getvalue = self.getvalue()
old_truncate(self, size)
def patched_report_failure(self, out, test, example, got):
"""
Patch the failure printer to record the current example so we can
count failures on a block, not line basis.
"""
test._failed_examples.append(example)
if REPORT_FIRST_ERROR and len(test._failed_examples) > 1: return
out(self._failure_header(test, example) +
self._checker.output_difference(example, got, self.optionflags))
def patched_report_unexpected_exception(self, out, test, example, exc_info):
"""
Patch the doctest printer to print output when exceptions occur.
Note that this uses the _old_getvalue saved above, which doctest otherwise throws out
when exceptions are thrown.
"""
test._failed_examples.append(example)
if REPORT_FIRST_ERROR and len(test._failed_examples) > 1: return
got = self._fakeout._old_getvalue
out(self._failure_header(test, example) +
self._checker.output_difference(example, got, self.optionflags) +
'Exception Raised:\n' + doctest._indent(doctest._exception_traceback(exc_info)))
old_parse = doctest.DocTestParser.parse
def patched_parse(self, string, name="<string>"):
"""
Save the text introducing each example to the parser object, so
that we can use it in the _failure_header.
"""
output = old_parse(self, string, name)
self._parsed = output
return output
old_get_doctest = doctest.DocTestParser.get_doctest
def patched_get_doctest(self, string, globs, name, filename, lineno):
"""
Copy the text introducing each example from the parser object to
the doctest object, so that we can use it in the _failure_header.
Also set up the failed_examples list.
"""
out = old_get_doctest(self, string, globs, name, filename, lineno)
out._parsed = self._parsed
out._failed_examples = []
return out
old_check_output = doctest.OutputChecker.check_output
def patched_check_output(self, want, got, optionflags):
"""
Strips all debug lines out from `got` before checking output. By
stripping them here but not in output_difference, we end up
calling debug-only diffs a successful result, but still print the
output when the diff has non-debug-only lines.
"""
got_no_debug = "\n".join([
line for line in
got.split("\n")
if not line.startswith("!dbg")
])
return old_check_output(self, want, got_no_debug, optionflags)
def patched_failure_header(self, test, example):
"""
Print the full block being executed, including the text
introducing the block and going up to the failing example, any
time a failure occurs.
To do so, we make use of the _parsed field on the doctest object,
which saves the complete parsed doctest file. In it, we find the
example that failed, and walk backward until we find a non-empty
piece of explanatory text. That starts a block, and we output it
in faux-markdown style until we get to the example that failed.
"""
out = [self.DIVIDER]
if test.filename:
if test.lineno is not None and example.lineno is not None:
lineno = test.lineno + example.lineno + 1
else:
lineno = '?'
out.append('File "%s", line %s, in %s' %
(test.filename, lineno, test.name))
else:
out.append('Line %s, in %s' % (example.lineno + 1, test.name))
example_idx = test._parsed.index(example)
header_idx = example_idx
while (header_idx > 0 and
(isinstance(test._parsed[header_idx], doctest.Example) or
test._parsed[header_idx] == '')):
header_idx -= 1
s = ""
for i in range(header_idx, example_idx + 1):
x = test._parsed[i]
if isinstance(x, str):
s += x
else:
s += ">>> " + "\n... ".join(x.source.strip("\n").split("\n")) + "\n"
if x.want and i != example_idx:
s += x.want
out.append(doctest._indent(s))
return '\n'.join(out) + "\n"
LAST_TEST_RESULT = None
old_record_outcome = doctest.DocTestRunner._DocTestRunner__record_outcome
def patched_record_outcome(self, test, failures, tries, skips=None):
"""
Compute failures and successes on a per-block basis.
"""
all_failures = set(test._failed_examples)
idx = 0
passed_blocks = 0
failed_blocks = 0
while idx < len(test._parsed):
assert isinstance(test._parsed[idx], str) and test._parsed[idx] != ''
idx += 1
did_fail = False
while idx < len(test._parsed):
if isinstance(test._parsed[idx], str) and test._parsed[idx] != '':
break
elif isinstance(test._parsed[idx], doctest.Example):
did_fail = did_fail or (test._parsed[idx] in all_failures)
idx += 1
if did_fail:
failed_blocks += 1
else:
passed_blocks += 1
global LAST_TEST_RESULT
LAST_TEST_RESULT = (failed_blocks, failed_blocks + passed_blocks)
skips_arg = [] if skips is None else [skips]
return old_record_outcome(self, test, failed_blocks, passed_blocks + failed_blocks, *skips_arg)
def patch_doctest():
doctest.DocTestRunner.report_failure = patched_report_failure
doctest.DocTestRunner.report_unexpected_exception = patched_report_unexpected_exception
doctest.DocTestRunner._failure_header = patched_failure_header
doctest.OutputChecker.check_output = patched_check_output
doctest._SpoofOut.truncate = patched_truncate
doctest.DocTestParser.parse = patched_parse
doctest.DocTestParser.get_doctest = patched_get_doctest
doctest.DocTestRunner._DocTestRunner__record_outcome = patched_record_outcome
def run_doctests(files):
global LAST_TEST_RESULT
patch_doctest()
mapped_results = dict()
sys.modules["wbemocks"] = wbemocks
flags = doctest.ELLIPSIS
if REPORT_DIFF: flags |= doctest.REPORT_NDIFF
for fname in files:
fname_abs = os.path.join(os.path.dirname(__file__), "tests", fname)
doctest.testfile(fname_abs, module_relative=False, optionflags=flags)
mapped_results[fname] = LAST_TEST_RESULT
LAST_TEST_RESULT = None
return mapped_results
def parse_arguments(argv):
parser = argparse.ArgumentParser(description='WBE test runner')
parser.add_argument("chapter",
nargs="?",
default=DEFAULT_CICD,
choices=list(CURRENT_TESTS),
help="Which chapter's tests to run")
parser.add_argument("--index",
type=int,
help="Run the nth test from the chapter. "
"(Requires passing a full chapter name.)")
# Control over output
parser.add_argument(
"--all", action="store_true",
help="Run all the tests, instead of stopping at the first failure.")
parser.add_argument(
"--diff", action="store_true",
help="Show a line-by-line diff between expected and actual output")
# Control over test configuration
parser.add_argument(
'-b', '--browser_path',
help='Directory containing browser.py'),
# Control over GH mode
parser.add_argument(
"--gh", action="store_true",
help=f"Write results to {GH_JSON_PATH} (for generating grade summaries)")
parser.add_argument(
"--ghsetup", action="store_true",
help="Output environment variables for Github CI script")
args = parser.parse_args(argv[1:])
return args
def ghsetup(tests):
assert os.getenv("GITHUB_ENV"), "Cannot execute gh subcommand without GITHUB_ENV set"
with open(os.getenv("GITHUB_ENV"), "a") as ghenv:
ghenv.write(f"HWPARTS={len(tests)}\n")
for i, test in enumerate(tests):
fname_abs = os.path.join(os.path.dirname(__file__), "tests", test)
name = open(fname_abs).readline()
name = name.removeprefix("Tests for WBE")
ghenv.write(f"HWPART{i+1}={name}\n")
print("Saved Github information in environment variables")
if os.path.isfile(GH_JSON_PATH):
os.unlink(GH_JSON_PATH)
return 0
def main(argv):
args = parse_arguments(argv)
testkey = args.chapter
if args.index is not None:
assert args.chapter.startswith("chapter")
testkey = args.chapter + "-" + str(args.index)
tests = CURRENT_TESTS[testkey]
bpath = args.browser_path
sys.path.append(bpath)
if args.ghsetup:
ghsetup(tests)
return 0
global REPORT_FIRST_ERROR, REPORT_DIFF
REPORT_FIRST_ERROR = not args.gh and not args.all
REPORT_DIFF = args.diff
mapped_results = run_doctests(tests)
total_state = "all passed"
print("\nSummarised results\n")
for name, (failure_count, test_count) in mapped_results.items():
state = "passed"
if failure_count != 0:
state = "failed {:<2} out of {:<2} tests".format(failure_count, test_count)
total_state = "failed"
print("{:>42}: {}".format(name, state))
print("-" * 52)
print("{:>42}: {} ".format("Final", total_state))
if args.gh:
ALL_TESTS = CURRENT_TESTS[testkey.split("-", 1)[0]]
if os.path.isfile(GH_JSON_PATH):
current_data = json.load(open(GH_JSON_PATH))
else:
current_data = []
current_data = [t for t in current_data if t[0] in ALL_TESTS]
res = sorted(current_data + list(mapped_results.items()),
key=lambda a: ALL_TESTS.index(a[0]))
with open(GH_JSON_PATH, "w") as f:
json.dump(res, f)
return int(total_state == "failed")
if __name__ == "__main__":
retcode = 130 # meaning "Script terminated by Control-C"
try:
retcode = main(sys.argv)
except KeyboardInterrupt:
print("")
print("Goodbye")
sys.exit(retcode)
#!/bin/sh
set -e -x
pip install tk dukpy
#!/usr/bin/env python3
import os
import sys
import json
def summarize(data):
s = ""
s += "| Test file | Failed | Total |\n"
s += "|---|---|---|\n"
grade = 0
for name, (failure_count, test_count) in data:
if failure_count:
s += f"| `{name}` | {failure_count} | {test_count} |\n"
else:
s += f"| `{name}` | \N{White heavy check mark} | {test_count} |\n"
if test_count > 0:
grade += 1 - failure_count / test_count
else:
grade += 1
s += "\n"
s += "**Overall Grade**: " + str(round(grade * 10)) + "/50\n"
print(s)
if __name__ == "__main__":
data = json.load(open("test/gh.json"))
summarize(data)
Tests for WBE Chapter 1
=======================
Chapter 1 (Downloading Web Pages) covers parsing URLs, HTTP requests
and responses, and a very simplistic print function that writes
to the screen. This file contains tests for those components.
Make sure to add the following to your URL class so that it can be
printed out:
```
class URL:
def __repr__(self):
return "URL(scheme={}, host={}, port={}, path={!r})".format(
self.scheme, self.host, self.port, self.path)
```
Testing `show`
--------------
Here's the testing boilerplate.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
The `show` function is supposed to print some HTML to the screen, but
skip the tags inside.
>>> browser.show('<body>hello</body>')
hello
>>> browser.show('<body><wbr>hello</body>')
hello
>>> browser.show('<body>he<wbr>llo</body>')
hello
>>> browser.show('<body>hel<div>l</div>o</body>')
hello
Note that the tags do not have to match:
>>> browser.show('<body><p>hel</div>lo</body>')
hello
>>> browser.show('<body>h<p>el<div>l</p>o</div></body>')
hello
Newlines should not be removed:
>>> browser.show('<body>hello\nworld</body>')
hello
world
Testing `request`
-----------------
The `request` function makes HTTP requests.
To test it, we use the `wbemocks.socket` object, which mocks the HTTP server:
>>> url = 'http://wbemocks.test/example1'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "\r\n" +
... "Body text").encode())
Then we request the URL and test that the browser generated request is proper:
>>> response_body = browser.URL(url).request()
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> command
'GET'
>>> path
'/example1'
>>> version in {"HTTP/1.0", "HTTP/1.1"}
True
>>> headers["host"]
'wbemocks.test'
Also check that the browser parsed the response properly
>>> response_body
'Body text'
Testing SSL support
-------------------
Since this next URL uses https as the scheme the browser should automatically use
SSL and switch the default port used to 443.
>>> url = 'https://wbemocks.test/example2'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "\r\n" +
... "SSL working").encode())
>>> response_body = browser.URL(url).request()
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> command
'GET'
>>> path
'/example2'
>>> version in {"HTTP/1.0", "HTTP/1.1"}
True
>>> headers["host"]
'wbemocks.test'
>>> response_body
'SSL working'
SSL support also means some support for specifying ports in the URL.
>>> url = 'https://wbemocks.test:400/example3'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "\r\n" +
... "Ports working").encode())
>>> response_body = browser.URL(url).request()
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> command
'GET'
>>> path
'/example3'
>>> version in {"HTTP/1.0", "HTTP/1.1"}
True
>>> headers["host"]
'wbemocks.test'
>>> response_body
'Ports working'
Requesting the wrong port is an error.
>>> browser.URL("http://wbemocks.test:401/example3").request()
Traceback (most recent call last):
...
AssertionError: You are requesting a url that you shouldn't: http://wbemocks.test:401/example3
Tests for WBE Chapter 1 Exercise `Caching`
==========================================
Typically, the same images, styles, and scripts are used on multiple
pages; downloading them repeatedly is a waste. It’s generally valid to
cache any HTTP response, as long as it was requested with GET and
received a 200 response. Implement a cache in your browser and test it
by requesting the same file multiple times. Servers control caches
using the `Cache-Control` header. Add support for this header,
specifically for `no-store` and `max-age` values. If the
`Cache-Control` header contains any other value than these two, it’s
best not to cache the response.
Also don't cache things if the `Cache-Control` header is missing.
Tests
-----
Testing boilerplate:
>>> import time
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
A server response can indicate if the response itself can be cached and for how
long.
The __Cache-Control__ header can be set to __max-age=[number]__ to allow caching of
the response for __[number]__ seconds.
We can test if a browser is caching responses by changing the response and
telling the browser to re-request the page.
>>> url = "http://wbemocks.test/cache_me1"
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Keep this for a while").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a while'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Don't even ask for this").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a while'
Sometimes the server will explicitly state that caches are not to be used.
In this case the response contains __no-store__ for the value of the
__Cache-Control__ header.
>>> url = "http://wbemocks.test/do_not_cache_me"
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: no-store\r\n" +
... "\r\n" +
... "Don't cache me").encode())
>>> body = browser.URL(url).request()
>>> body
"Don't cache me"
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: no-store\r\n" +
... "\r\n" +
... "Ask for this").encode())
>>> body = browser.URL(url).request()
>>> body
'Ask for this'
A cache should be able to hold multiple responses, and keep them separate.
Here we cache, then change, another URL and check that both of the URLs cached
so far are present.
>>> url = "http://wbemocks.test/cache_me2"
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Keep this for a while, also").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a while, also'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Don't even ask for this").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a while, also'
>>> body = browser.URL("http://wbemocks.test/cache_me1").request()
>>> body
'Keep this for a while'
A cached entry can be invalidated by time elapsing, so here we cache a response
with a one second life and wait for it to be invalidated.
>>> url = "http://wbemocks.test/cache_me3"
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=1\r\n" +
... "\r\n" +
... "Keep this for a short while").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a short while'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "\r\n" +
... "Don't ask for this immediately").encode())
>>> body = browser.URL(url).request()
>>> body
'Keep this for a short while'
>>> time.sleep(2)
>>> body = browser.URL(url).request()
>>> body
"Don't ask for this immediately"
Each cached response will have different lifetimes.
The responses cached earlier should still be valid.
>>> body = browser.URL("http://wbemocks.test/cache_me1").request()
>>> body
'Keep this for a while'
>>> body = browser.URL("http://wbemocks.test/cache_me2").request()
>>> body
'Keep this for a while, also'
Objective: Verify that your caching mechanism doesn't serve stale data when the scheme, host, or port of the requested URL changes.
>>> URL_base = "http://wbemocks.test/cache_me4"
>>> wbemocks.socket.respond(url=URL_base,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Different port page").encode())
>>> browser.URL(URL_base).request()
'Different port page'
>>> URL_diff_scheme = "https://wbemocks.test/cache_me4"
>>> wbemocks.socket.respond(url=URL_diff_scheme,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Different scheme page").encode())
>>> browser.URL(URL_diff_scheme).request()
'Different scheme page'
>>> URL_diff_host = "http://mock.test/cache_me4"
>>> wbemocks.socket.respond(url=URL_diff_host,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Different host page").encode())
>>> browser.URL(URL_diff_host).request()
'Different host page'
>>> URL_diff_port = "http://wbemocks.test:8080/cache_me4"
>>> wbemocks.socket.respond(url=URL_diff_port,
... response=("HTTP/1.0 200 Ok\r\n" +
... "Cache-Control: max-age=9001\r\n" +
... "\r\n" +
... "Keep this PORT for a while").encode())
>>> browser.URL(URL_diff_port).request()
'Keep this PORT for a while'
Tests for WBE Chapter 1 Exercise `File URLs`
============================================
Add support for the file scheme, which allows the browser to open
local files. For example, `file:///path/goes/here` should refer to the
file on your computer at location `/path/goes/here`. Also make it so
that, if your browser is started without a URL being given, some
specific file on your computer is opened. You can use that file for
quick testing.
For file URL the `host` and `port` should be `None`. If the file
doesn't exist, raise a `FileNotFoundError`; this should happen
automatically when you call `open`.
Tests
-----
Testing boilerplate:
>>> from os import path
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
Test URLs with a `:` in them
>>> browser.URL("file://C:/Users/test/test.html")
URL(scheme=file, host=None, port=None, path='C:/Users/test/test.html')
Test Windows-style paths
>>> browser.URL("file://C:\\Users\\test\\test.html")
URL(scheme=file, host=None, port=None, path='C:\\Users\\test\\test.html')
Test relative paths
>>> browser.URL("file://local_file.html")
URL(scheme=file, host=None, port=None, path='local_file.html')
Here we make a file, put some text in it, and make a file scheme request.
>>> filename = "local_file.txt"
>>> full_path = path.abspath(filename)
>>> with open(full_path, "w") as f:
... f.write("Hello world")
11
>>> url = "file://{}".format(full_path)
>>> body = browser.URL(url).request()
>>> body
'Hello world'
Requesting a nonexistent file should result in a `FileNotFoundError`:
>>> browser.URL("file:///this/file/does/not/exist").request()
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: '/this/file/does/not/exist'
Tests for WBE Chapter 1 Exercise `HTTP/1.1`
===========================================
Along with `Host`, send the `Connection` header in the request
function with the value `close`. Your browser can now declare that it
is using HTTP/1.1. Also add a `User-Agent` header. Its value can be
whatever you want---it identifies your browser to the host. Make it
easy to add further headers in the future.
Add an optional `headers` argument to your `request` method. It should
be a dictionary mapping header names to header values, which should be
sent along with the request. If the `headers` argument includes a
header your browser already sends, like `Host`, `User-Agent`, or
`Connection`, the value in the `headers` argument should override the
default.
**Warning**: Default values for optional arguments in Python generally should
not be mutable objects like dictionaries, for reasons detailed in the
[Python guide](https://docs.python-guide.org/writing/gotchas/#default-args).
Test
----
Testing boilerplate:
>>> import time
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
Mock the HTTP server, as before:
>>> url = 'http://wbemocks.test/example1'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "Header1: Value1\r\n" +
... "\r\n" +
... "Body text").encode(),
... method="GET")
This request/response pair was tested in the base tests, but now we are
checking that the __Connection__ header is present and contains __close__, that a
__User-Agent__ header is present, and that the request is HTTP 1.1:
>>> response_body = browser.URL(url).request()
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> command
'GET'
>>> path
'/example1'
>>> headers["connection"]
'close'
>>> "user-agent" in headers
True
>>> version
'HTTP/1.1'
Use a new mock HTTP server with a new URL to avoid possible caching errors.
>>> url = 'http://wbemocks.test/example2'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "Header2: Value2\r\n" +
... "\r\n" +
... "Body text").encode(),
... method="GET")
Let's test the extra headers feature:
>>> extra_client_headers = {"ClientHeader" : "42"}
>>> body = browser.URL(url).request(headers=extra_client_headers)
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> headers["clientheader"]
'42'
Use a new mock HTTP server with a new URL to avoid possible caching errors.
>>> url = 'http://wbemocks.test/example3'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "Header2: Value2\r\n" +
... "\r\n" +
... "Body text").encode(),
... method="GET")
If the `headers` argument includes headers that are sent by default, like `User-Agent`,
the `headers` argument should overwrite their value.
In other words, the request should only contain one occurrence of each header.
>>> extra_client_headers = {"User-Agent" : "different/1.0"}
>>> body = browser.URL(url).request(headers=extra_client_headers)
>>> wbemocks.socket.count_header_last_request(url, "User-Agent")
1
>>> command, path, version, headers = wbemocks.socket.parse_last_request(url)
>>> headers["user-agent"]
'different/1.0'
Remember that headers are case-insensitive:
>>> extra_client_headers = {"user-agent" : "a/1.0", "User-Agent" : "b/1.0"}
>>> body = browser.URL(url).request(headers=extra_client_headers)
>>> wbemocks.socket.count_header_last_request(url, "User-Agent")
1
Tests for WBE Chapter 1 Exercise `Redirects`
============================================
Error codes in the 300 range request a redirect. When your browser
encounters one, it should make a new request to the URL given in the
`Location` header. Sometimes the `Location` header is a full URL, but
sometimes it skips the host and scheme and just starts with a `/`
(meaning the same host and scheme as the original request). The new
URL might itself be a redirect, so make sure to handle that case. You
don’t, however, want to get stuck in a redirect loop, so make sure to
limit how many redirects your browser can follow in a row. You can
test this with the URL http://browser.engineering/redirect, which
redirects back to this page, and its `/redirect2` and `/redirect3`
cousins which do more complicated redirect chains.
Limit the number of redirects in a chain to 10. Define the following
exception class and raise it for redirect loops:
```
class RedirectLoopError(Exception): pass
```
If you instead get a `RecursionError` that means you didn't detect a
redirect loop.
Tests
-----
Testing boilerplate:
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
When a response from the server has an HTTP code in the 300s
it is a redirect.
The browser should use the URL located in the __Location__ header
of the response to find where the content is now.
>>> from_url = 'http://wbemocks.test/redirect1'
>>> to_url = 'http://wbemocks.redirect_test/target1'
>>> wbemocks.socket.respond(url=from_url,
... response=("HTTP/1.0 301 Moved Permanently\r\n" +
... "Location: {}\r\n" +
... "\r\n").format(to_url).encode())
>>> wbemocks.socket.respond(url=to_url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "\r\n" +
... "You found me").encode())
>>> body = browser.URL(from_url).request()
>>> body
'You found me'
This __Location__ header may contain a full URL, as seen above, or
omit the scheme and host as seen here.
>>> from_url = 'http://wbemocks.test/redirect2'
>>> to_url = 'http://wbemocks.test/target2'
>>> wbemocks.socket.respond(url=from_url,
... response=("HTTP/1.0 301 Moved Permanently\r\n" +
... "Location: /target2\r\n" +
... "\r\n").encode())
>>> wbemocks.socket.respond(url=to_url,
... response=("HTTP/1.0 200 Ok\r\n"
... "\r\n" +
... "You found me again").encode())
>>> body = browser.URL(from_url).request()
>>> body
'You found me again'
The result of a redirect may be another redirect which forms
a chain to the requested content.
Now that you have seen the content of the redirect response we
will use a helper function to make the linkage clearer.
>>> start_url = 'http://wbemocks.test/redirect3'
>>> middle_url = 'http://wbemocks.test/target3'
>>> final_url = 'http://wbemocks.redirect_test/target4'
>>> wbemocks.socket.redirect_url(from_url=start_url, to_url=middle_url)
>>> wbemocks.socket.redirect_url(from_url=middle_url, to_url=final_url)
>>> wbemocks.socket.respond(url=final_url,
... response=("HTTP/1.0 200 Ok\r\n" +
... "\r\n" +
... "I need to hide better").encode())
>>> body = browser.URL(start_url).request()
>>> body
'I need to hide better'
Redirection opens up the possibly for infinite loops, these should
lead to an error. The simplest infinite loop is a redirect to itself.
>>> url = 'http://wbemocks.test/redirect4'
>>> wbemocks.socket.redirect_url(from_url=url, to_url=url)
>>> browser.URL(url).request() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
browser.RedirectLoopError: Infinite redirect loop
Infinite loops can be more complex, this is a two stage loop.
>>> url1 = 'http://wbemocks.test/redirect5'
>>> url2 = 'http://wbemocks.test/target5'
>>> wbemocks.socket.redirect_url(from_url=url1, to_url=url2)
>>> wbemocks.socket.redirect_url(from_url=url2, to_url=url1)
>>> browser.URL(url1).request() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
browser.RedirectLoopError: Infinite redirect loop avoided
>>> browser.URL(url2).request() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
browser.RedirectLoopError: Infinite redirect loop
The browser should not perform a redirect for non 3XX status codes, even if
a __Location__ header is present
>>> url = 'http://wbemocks.test/not_redirect'
>>> do_not_follow = 'http://wbemocks.test/not_target'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "Location: {}\r\n" +
... "\r\n" +
... "Stay here").format(do_not_follow).encode())
>>> wbemocks.socket.respond(url=do_not_follow,
... response=("HTTP/1.0 200 Ok\r\n" +
... "\r\n" +
... "Too far").encode())
>>> body = browser.URL(url).request()
>>> body
'Stay here'
Tests for WBE Chapter 10
========================
Chapter 10 (Keeping Data Private) introduces cookies.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> wbemocks.NO_CACHE = True
>>> import browser
Testing basic cookies
=====================
When a server sends a `Set-Cookie` header, the browser should save it
in the cookie jar:
>>> this_browser = browser.Browser()
>>> url = 'http://wbemocks.wbemocks.chapter10/login'
>>> wbemocks.socket.respond(url,
... b"HTTP/1.0 200 OK\r\n" +
... b"Set-Cookie: foo=bar\r\n" +
... b"\r\n" +
... b"empty")
>>> this_browser.new_tab(browser.URL(url))
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10"]
('foo=bar', {})
Moreover, the browser should now send a `Cookie` header with future
requests:
>>> url2 = 'http://wbemocks.wbemocks.chapter10/'
>>> wbemocks.socket.respond(url2, b"HTTP/1.0 200 OK\r\n\r\n\r\nempty")
>>> this_browser.new_tab(browser.URL(url2))
>>> b'cookie: foo=bar' in wbemocks.socket.last_request(url2).lower()
True
Unrelated sites should not be sent the cookie:
>>> url3 = 'http://other.site.chapter10/'
>>> wbemocks.socket.respond(url3, b"HTTP/1.0 200 OK\r\n\r\n\r\nempty")
>>> this_browser.new_tab(browser.URL(url3))
>>> b'cookie' in wbemocks.socket.last_request(url3).lower()
False
Note that these three requests were across three different tabs. All
tabs should use the same cookie jar.
Cookie values can be updated:
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10"]
('foo=bar', {})
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\nSet-Cookie: foo=baz\r\n\r\nempty")
>>> this_browser.new_tab(browser.URL(url))
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10"]
('foo=baz', {})
Testing XMLHttpRequest
======================
First, let's test the basic `XMLHttpRequest` functionality. We'll be
making a lot of `XMLHttpRequest` calls so let's add a little helper
for that:
>>> def xhrjs(url):
... return """x = new XMLHttpRequest();
... x.open("GET", """ + repr(url) + """, false);
... x.send();
... console.log(x.responseText);"""
Now let's test a simple same-site request:
>>> url = "http://about.blank.chapter10/"
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\n\r\nempty")
>>> url2 = "http://about.blank.chapter10/hello"
>>> wbemocks.socket.respond(url2, b"HTTP/1.0 200 OK\r\n\r\nHello!")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> tab = this_browser.tabs[0]
>>> tab.js.run(xhrjs(url2))
Hello!
Relative URLs also work:
>>> tab.js.run(xhrjs("/hello"))
Hello!
Non-synchronous XHRs should fail:
>>> tab.js.run("XMLHttpRequest().open('GET', '/', true)") #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
_dukpy.JSRuntimeError: <complicated error message>
If cookies are present, they should be sent:
>>> browser.COOKIE_JAR["about.blank.chapter10"] = ('foo=bar', {})
>>> tab.js.run(xhrjs(url2))
Hello!
>>> b'cookie: foo=bar' in wbemocks.socket.last_request(url2).lower()
True
Note that the cookie value is sent.
Now let's see that cross-domain requests fail:
>>> url3 = "http://other.site.chapter10/"
>>> wbemocks.socket.respond(url3, b"HTTP/1.0 200 OK\r\n\r\nPrivate")
>>> tab.js.run(xhrjs(url3)) #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
_dukpy.JSRuntimeError: <complicated error message>
It's not important whether the request is _ever_ sent; the CORS
exercise requires sending it but the standard implementation does not
send it.
Testing SameSite cookies and CSRF
=================================
`SameSite` cookies should be sent on cross-site `GET`s and
same-site `POST`s but not on cross-site `POST`s.
Cookie without `SameSite` have already been tested above. Let's create
a `SameSite` cookie to start.
>>> url = "http://wbemocks.wbemocks.chapter10/"
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\nSet-Cookie: bar=baz; SameSite=Lax\r\n\r\nempty")
>>> tab.load(browser.URL(url))
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10"]
('bar=baz', {'samesite': 'lax'})
Now the browser should have `bar=baz` as a `SameSite` cookie for
`wbemocks.wbemocks.chapter10`. First, let's check that it's sent in a same-site `GET`
request:
>>> url2 = "http://wbemocks.wbemocks.chapter10/2"
>>> wbemocks.socket.respond(url2, b"HTTP/1.0 200 OK\r\n\r\n2")
>>> tab.load(browser.URL(url2))
>>> b'cookie: bar=baz' in wbemocks.socket.last_request(url2).lower()
True
Now let's submit a same-site `POST` and check that it's also sent
there:
>>> url3 = "http://wbemocks.wbemocks.chapter10/add"
>>> wbemocks.socket.respond(url3, b"HTTP/1.0 200 OK\r\n\r\nAdded!", method="POST")
>>> tab.load(browser.URL(url3), payload="who=me")
>>> req = wbemocks.socket.last_request(url3).lower()
>>> req.startswith(b'post')
True
>>> b'cookie: bar=baz' in req
True
>>> b'content-length: 6' in req
True
>>> req.endswith(b'who=me')
True
Now we navigate to another site, navigate back by `GET`, and the
cookie should *still* be sent:
>>> url4 = "http://other.site.chapter10/"
>>> wbemocks.socket.respond(url4, b"HTTP/1.0 200 OK\r\n\r\nHi!")
>>> tab.load(browser.URL(url4))
>>> tab.load(browser.URL(url))
>>> b'cookie: bar=baz' in wbemocks.socket.last_request(url).lower()
True
Finally, let's try a cross-site `POST` request and check that in this
case the cookie is *not* sent:
>>> tab.load(browser.URL(url4))
>>> tab.load(browser.URL(url3), payload="who=me")
>>> req = wbemocks.socket.last_request(url3).lower()
>>> req.startswith(b'post')
True
>>> b'content-length: 6' in req
True
>>> req.endswith(b'who=me')
True
Testing Content-Security-Policy
===============================
We test `Content-Security-Policy` by checking that subresources are
loaded / not loaded as required. To do that we need a page with a lot
of subresources:
>>> url = "http://wbemocks.wbemocks.chapter10/"
>>> body = """<!doctype html>
... <link rel=stylesheet href=http://wbemocks.wbemocks.chapter10/css />
... <script src=http://wbemocks.wbemocks.chapter10/js></script>
... <link rel=stylesheet href=http://library.wbemocks.chapter10/css />
... <script src=http://library.wbemocks.chapter10/js></script>
... <link rel=stylesheet href=http://other.wbemocks.chapter10/css />
... <script src=http://other.wbemocks.chapter10/js></script>
... """
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\n\r\n" + body.encode("utf8"))
We also need to create all those subresources:
>>> wbemocks.socket.respond_ok(url + "css", "")
>>> wbemocks.socket.respond_ok(url + "js", "")
>>> url2 = "http://library.wbemocks.chapter10/"
>>> wbemocks.socket.respond_ok(url2 + "css", "")
>>> wbemocks.socket.respond_ok(url2 + "js", "")
>>> url3 = "http://other.wbemocks.chapter10/"
>>> wbemocks.socket.respond_ok(url3 + "css", "")
>>> wbemocks.socket.respond_ok(url3 + "js", "")
Now with all of these URLs set up, let's load the page without CSP and
check that all of these requests were made:
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> [wbemocks.socket.made_request(url + "css"),
... wbemocks.socket.made_request(url + "js")]
[True, True]
>>> [wbemocks.socket.made_request(url2 + "css"),
... wbemocks.socket.made_request(url2 + "js")]
[True, True]
>>> [wbemocks.socket.made_request(url3 + "css"),
... wbemocks.socket.made_request(url3 + "js")]
[True, True]
Now let's reload the page, but with CSP enabled for `wbemocks.wbemocks.chapter10` and
`library.wbemocks.chapter10` but not `other.wbemocks.chapter10`:
>>> wbemocks.socket.clear_history()
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\n" + \
... b"Content-Security-Policy: default-src http://wbemocks.wbemocks.chapter10 http://library.wbemocks.chapter10\r\n\r\n" + \
... body.encode("utf8"))
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
Blocked script http://other.wbemocks.chapter10/js due to CSP
Blocked style http://other.wbemocks.chapter10/css due to CSP
The URLs on `wbemocks.wbemocks.chapter10` and `library.wbemocks.chapter10` should have been loaded:
>>> [wbemocks.socket.made_request(url + "css"),
... wbemocks.socket.made_request(url + "js")]
[True, True]
>>> [wbemocks.socket.made_request(url2 + "css"),
... wbemocks.socket.made_request(url2 + "js")]
[True, True]
However, neither script nor style from `other.wbemocks.chapter10` should be loaded:
>>> [wbemocks.socket.made_request(url3 + "css"),
... wbemocks.socket.made_request(url3 + "js")]
[False, False]
Let's also test that XHR is blocked by CSP. This requires a little
trickery, because cross-site XHR is already blocked, so we need a CSP
that restricts all sites---but then we can't load and run any
JavaScript!
>>> url = "http://weird.wbemocks.chapter10/"
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\n" + \
... b"Content-Security-Policy: default-src\r\n\r\nempty")
>>> this_browser.new_tab(browser.URL(url))
>>> tab = this_browser.tabs[-1]
>>> tab.js.run("""
... x = new XMLHttpRequest()
... x.open('GET', 'http://weird.wbemocks.chapter10/xhr', false);
... x.send();""") #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
_dukpy.JSRuntimeError: <complicated wrapper around 'Cross-origin XHR blocked by CSP'>
Tests for WBE Chapter 10 Exercise `Certificate errors`
============================================
When accessing an HTTPS page, the web server can send an invalid
certificate (`badssl.com` hosts various invalid certificates you can
use for testing). In this case, the `wrap_socket` function will raise
a certificate error; Catch these errors and show a warning message to
the user. For all other HTTPS pages draw a padlock (spelled
`\N{lock}`) in the address bar.
If you first `connect` your socket then wrap it the exception will be
thrown when calling `wrap_socket`. If you wrap the socket before calling
`connect` then the exception is thrown when calling `connect`.
To show a warning message to the user simply do not load the page and
instead display the following web page:
```html
<!doctype html>
Secure Connection Failed
```
Tests
-----
Boilerplate.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> wbemocks.NO_CACHE = True
>>> wbemocks.NORMALIZE_FONT = True
>>> import browser
Check that using http does not add the lock character.
>>> wbemocks.TK_CANVAS_CALLS = list()
>>> url = "http://wbemocks.wbemocks.chapter10-certificate-errors/"
>>> wbemocks.socket.respond_ok(url, "Insecure page")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> tk_text = [c for c in wbemocks.TK_CANVAS_CALLS if
... c.startswith("create_text")]
>>> any("\N{lock}" in c for c in tk_text)
False
The lock character should be displayed when the page is https and no errors
occur.
>>> wbemocks.TK_CANVAS_CALLS = list()
>>> url = "https://wbemocks.wbemocks.chapter10-certificate-errors/"
>>> wbemocks.socket.respond_ok(url, "Secure page")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> tk_text = [c for c in wbemocks.TK_CANVAS_CALLS if
... c.startswith("create_text")]
>>> any("\N{lock}" in c for c in tk_text)
True
When the certificate is invalid display the above page and do not display a
lock.
>>> wbemocks.TK_CANVAS_CALLS = list()
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL("https://untrusted-root.badssl.com/"))
>>> browser.print_tree(this_browser.tabs[0].document)
DocumentLayout()
BlockLayout(x=13, y=18, width=774, height=15.0)
BlockLayout(x=13, y=18, width=774, height=15.0)
LineLayout(x=13, y=18, width=774, height=15.0)
TextLayout(x=13, y=20.25, width=72, height=12, word=Secure)
TextLayout(x=97, y=20.25, width=120, height=12, word=Connection)
TextLayout(x=229, y=20.25, width=72, height=12, word=Failed)
>>> tk_text = [c for c in wbemocks.TK_CANVAS_CALLS if
... c.startswith("create_text")]
>>> any("\N{lock}" in c for c in tk_text)
False
Tests for WBE Chapter 10 Exercise `New inputs`
============================================
Add support for hidden and password input elements. Hidden inputs
shouldn’t show up or take up space, while password input elements
should show ther contents as stars instead of characters.
To hide the input element set its width and height to `0.0`
Tests
-----
Boilerplate.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> wbemocks.NO_CACHE = True
>>> wbemocks.NORMALIZE_FONT = True
>>> import browser
Make a page with a hidden input element.
>>> url = "http://wbemocks.wbemocks.chapter10-new-inputs/"
>>> page = """<!doctype html>
... <form action="/tricky" method=POST>
... <p>Not hidden: <input name=visible value=1></p>
... <p>Hidden: <input type=hidden name=invisible value=doNotShowMe></p>
... <p><button>Submit!</button></p>
... </form>"""
>>> wbemocks.socket.respond_ok(url, page)
>>> wbemocks.socket.respond(url + "tricky", b"HTTP/1.0 200 OK\r\n\r\nEmpty", "POST")
The hidden element should not show up.
There are many ways to achieve this effect, we will set the width and height of
the element to 0.0.
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> browser.print_tree(this_browser.tabs[0].document)
DocumentLayout()
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=15.0)
LineLayout(x=13, y=18, width=774, height=15.0)
TextLayout(x=13, y=20.25, width=36, height=12, word=Not)
TextLayout(x=61, y=20.25, width=84, height=12, word=hidden:)
InputLayout(x=157, y=20.25, width=200, height=12, tag=input)
BlockLayout(x=13, y=33.0, width=774, height=15.0)
LineLayout(x=13, y=33.0, width=774, height=15.0)
TextLayout(x=13, y=35.25, width=84, height=12, word=Hidden:)
InputLayout(x=109, y=35.25, width=0.0, height=0.0, tag=input)
BlockLayout(x=13, y=48.0, width=774, height=15.0)
LineLayout(x=13, y=48.0, width=774, height=15.0)
InputLayout(x=13, y=50.25, width=200, height=12, tag=button)...
Submission of the form should still pass along the value.
>>> this_browser.handle_click(wbemocks.ClickEvent(21, this_browser.chrome.bottom+58))
>>> req = wbemocks.socket.last_request(url + "tricky").decode().lower()
>>> req.startswith("post")
True
>>> "content-length: 31" in req
True
>>> req.endswith("visible=1&invisible=donotshowme")
True
Make a page with a password input element.
>>> url = "http://wbemocks.wbemocks.chapter10-new-inputs/"
>>> page = """<!doctype html>
... <form action="/login" method=POST>
... <p>Name: <input name=name value=Skroob></p>
... <p>Password: <input type=password name=password value=12345></p>
... <p><button>Submit!</button></p>
... </form>"""
>>> wbemocks.socket.respond_ok(url, page)
>>> wbemocks.socket.respond(url + "login", b"HTTP/1.0 200 OK\r\n\r\nEmpty", "POST")
The password element should be all `*`.
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> browser.print_tree(this_browser.tabs[0].document)
DocumentLayout()
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=45.0)
BlockLayout(x=13, y=18, width=774, height=15.0)
LineLayout(x=13, y=18, width=774, height=15.0)
TextLayout(x=13, y=20.25, width=60, height=12, word=Name:)
InputLayout(x=85, y=20.25, width=200, height=12, tag=input)
BlockLayout(x=13, y=33.0, width=774, height=15.0)
LineLayout(x=13, y=33.0, width=774, height=15.0)
TextLayout(x=13, y=35.25, width=108, height=12, word=Password:)
InputLayout(x=133, y=35.25, width=200, height=12, tag=input)
BlockLayout(x=13, y=48.0, width=774, height=15.0)
LineLayout(x=13, y=48.0, width=774, height=15.0)
InputLayout(x=13, y=50.25, width=200, height=12, tag=button)...
>>> document = this_browser.active_tab.document
>>> form = document.children[0].children[0].children[0]
>>> para = form.children[1].children[0]
>>> pswd = para.children[1]
>>> wbemocks.print_list(pswd.paint())
DrawRect(top=35.25 left=133 bottom=47.25 right=333 color=lightblue)
DrawText(top=35.25 left=133 bottom=47.25 text=***** font=Font size=12 weight=normal slant=roman style=None)
Submission of the form should still pass along the value.
>>> this_browser.handle_click(wbemocks.ClickEvent(21, this_browser.chrome.bottom+58))
>>> req = wbemocks.socket.last_request(url + "login").decode().lower()
>>> req.startswith("post")
True
>>> "content-length: 26" in req
True
>>> req.endswith("name=skroob&password=12345")
True
Tests for WBE Chapter 10 Exercise `Referer`
===========================================
When your browser visits a web page, or when it loads a CSS or
JavaScript file, it sends a `Referer` header (yep, spelled that way)
containing the URL it is coming from. Sites often use this for
analytics. Implement this in your browser. However, some URLs contain
personal data that they don’t want revealed to other websites, so
browsers support a `Referrer-Policy` header, which can contain values
like `no-referer` (never send the `Referer` header when leaving this page)
or `same-origin` (only do so if navigating to another page on the same
origin). Implement those two values for `Referrer-Policy`.
Note the differences in spelling, the headers are `Referer` and
`Referrer-Policy`, and the value is `no-referrer`. You can blame
[Phillip Hallam-Baker][wiki-referer]
[wiki-referer]: https://en.wikipedia.org/wiki/HTTP_referer#Etymology
Tests
-----
Boilerplate.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> wbemocks.NO_CACHE = True
>>> wbemocks.NORMALIZE_FONT = True
>>> import browser
Load a page with some CSS, and check that `Referer` is used.
>>> url = "http://wbemocks.wbemocks.chapter10-referer-1/"
>>> body = """<!DOCTYPE html>
... <link rel="stylesheet" href="style.css" />
... Empty"""
>>> wbemocks.socket.respond_ok(url, body)
>>> wbemocks.socket.respond_ok(url + "style.css", "")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> req = wbemocks.socket.last_request(url + "style.css").decode().lower()
>>> "referer:" in req
True
>>> "referer: {}".format(url) in req
True
Now load a page setting the `Referrer-Policy` header to `no-referrer`, and
check that `Referer` is not used.
>>> url = "http://wbemocks.wbemocks.chapter10-referer-2/"
>>> body = """<!DOCTYPE html>
... <script src=http://wbemocks.wbemocks.chapter10-referer-2/same.js></script>
... <script src=http://wbemocks.diff.chapter10-referer-2/diff.js></script>
... Empty"""
>>> body = b"HTTP/1.0 200 OK\r\nReferrer-Policy: no-referrer\r\n\r\n" + body.encode("utf8")
>>> wbemocks.socket.respond(url, body)
>>> wbemocks.socket.respond_ok("http://wbemocks.wbemocks.chapter10-referer-2/same.js", "")
>>> wbemocks.socket.respond_ok("http://wbemocks.diff.chapter10-referer-2/diff.js", "")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> req = wbemocks.socket.last_request("http://wbemocks.wbemocks.chapter10-referer-2/same.js").decode().lower()
>>> "referer:" in req
False
>>> req = wbemocks.socket.last_request("http://wbemocks.diff.chapter10-referer-2/diff.js").decode().lower()
>>> "referer:" in req
False
Finally load a page setting the `Referrer-Policy` header to `same-origin`, and
check that `Referer` is only used when the origin is the same.
>>> url = "http://wbemocks.wbemocks.chapter10-referer-3/"
>>> body = """<!DOCTYPE html>
... <script src=http://wbemocks.wbemocks.chapter10-referer-3/same.js></script>
... <script src=http://wbemocks.diff.chapter10-referer-3/diff.js></script>
... Empty"""
>>> body = b"HTTP/1.0 200 OK\r\nReferrer-Policy: same-origin\r\n\r\n" + body.encode("utf8")
>>> wbemocks.socket.respond(url, body)
>>> wbemocks.socket.respond_ok("http://wbemocks.wbemocks.chapter10-referer-3/same.js", "")
>>> wbemocks.socket.respond_ok("http://wbemocks.diff.chapter10-referer-3/diff.js", "")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> req = wbemocks.socket.last_request("http://wbemocks.wbemocks.chapter10-referer-3/same.js").decode().lower()
>>> "referer:" in req
True
>>> "referer: {}".format(url) in req
True
>>> req = wbemocks.socket.last_request("http://wbemocks.diff.chapter10-referer-3/diff.js").decode().lower()
>>> "referer:" in req
False
Tests for WBE Chapter 10 Exercise `Script access`
============================================
Implement the document.cookie JavaScript API. Reading this field
should return a string containing the cookie value ~~and parameters~~,
formatted similarly to the `Cookie` header. Writing to this field
updates the cookie value and parameters, just like receiving a
`Set-Cookie` header does. Also implement the `HttpOnly` cookie
parameter; cookies with this parameter cannot be read or written from
JavaScript.
Implementation is made easier since the browser supports one cookie
per host, so getting `document.cookie` will return either 0 or 1
cookie and setting it will overwrite the cookie (if allowable).
Tests
-----
Boilerplate.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> wbemocks.NO_CACHE = True
>>> wbemocks.NORMALIZE_FONT = True
>>> import browser
Open an empty page to get the javascript instance
>>> url = "http://wbemocks.wbemocks.chapter10-script-access/"
>>> wbemocks.socket.respond_ok(url, "Empty")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> js = this_browser.tabs[0].js
Check that javascript doesn't see any cookies, and that the cookie jar is empty.
>>> js.run("console.log(document.cookie)")
<BLANKLINE>
>>> "wbemocks.wbemocks.chapter10-script-access" in browser.COOKIE_JAR
False
Set the cookie and make sure the changes are applied.
>>> js.run('void(document.cookie = "yoo=hoo")')
>>> js.run("console.log(document.cookie)")
yoo=hoo
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10-script-access"]
('yoo=hoo', {})
Modify the key and see that change works.
>>> js.run('void(document.cookie = "yoo=hey")')
>>> js.run("console.log(document.cookie)")
yoo=hey
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10-script-access"]
('yoo=hey', {})
Set the cookie and use the parameter syntax.
>>> js.run('void(document.cookie = "summer=blowout; SameSite=Lax")')
>>> js.run("console.log(document.cookie)")
summer=blowout
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10-script-access"]
('summer=blowout', {'samesite': 'lax'})
Load a new page with a http only cookie.
>>> url = "http://wbemocks.wbemocks.chapter10-script-access-http-only/"
>>> wbemocks.socket.respond(url, b"HTTP/1.0 200 OK\r\nSet-Cookie: no=share;SameSite=None;HttpOnly\r\n\r\nEmpty")
>>> this_browser = browser.Browser()
>>> this_browser.new_tab(browser.URL(url))
>>> js = this_browser.tabs[0].js
Reading from javascript should show nothing, but the cookie jar will have the
cookie.
>>> js.run("console.log(document.cookie)")
<BLANKLINE>
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10-script-access-http-only"][0]
'no=share'
Assigning from javascript will not change the content of the cookie.
>>> js.run('void(document.cookie = "overwrite=attempt")')
>>> js.run("console.log(document.cookie)")
<BLANKLINE>
>>> browser.COOKIE_JAR["wbemocks.wbemocks.chapter10-script-access-http-only"][0]
'no=share'
Tests for WBE Chapter 2
=======================
Chapter 2 (Drawing to the Screen) is about how to get text parsed, laid out
and drawn on the screen, plus a very simple implementation of scrolling. This
file contains tests for this functionality.
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> import browser
Please copy this `set_parameters` function into your browser:
``` {.python}
def set_parameters(**params):
global WIDTH, HEIGHT, HSTEP, VSTEP, SCROLL_STEP
if "WIDTH" in params: WIDTH = params["WIDTH"]
if "HEIGHT" in params: HEIGHT = params["HEIGHT"]
if "HSTEP" in params: HSTEP = params["HSTEP"]
if "VSTEP" in params: VSTEP = params["VSTEP"]
if "SCROLL_STEP" in params: SCROLL_STEP = params["SCROLL_STEP"]
```
If you'd like to define these constants in some other file, you can do
that by modifying this function definition.
Testing `lex`
-------------
The `lex` function is the same as `show` from chapter 1, but now it returns
the result instead of printing it.
>>> s = browser.lex('<body>hello</body>')
>>> s
'hello'
>>> s = browser.lex('<body><wbr>hello</body>')
>>> s
'hello'
>>> s = browser.lex('<body>he<wbr>llo</body>')
>>> s
'hello'
>>> s = browser.lex('<body>hel<div>l</div>o</body>')
>>> s
'hello'
Note that the tags do not have to match:
>>> s = browser.lex('<body><p>hel</div>lo</body>')
>>> s
'hello'
>>> s = browser.lex('<body>h<p>el<div>l</p>o</div></body>')
>>> s
'hello'
Newlines should not be removed:
>>> s = browser.lex('<body>hello\nworld</body>')
>>> s
'hello\nworld'
Testing `layout`
----------------
The layout function takes in text and outputs a display list. It uses `WIDTH` to
determine the maximum length of a line, `HSTEP` for the horizontal distance
between letters, and `VSTEP` for the vertical distance between lines. Each entry
in the display list is of the form `(x, y, c)`, where `x` is the horizontal offset
to the right, `y` is the vertical offset downward, and `c` is the character to
draw.
Let's override those values to convenient ones that make it easy to do math
when testing:
>>> browser.set_parameters(WIDTH=11, HSTEP=1, VSTEP=1)
Both of these fit on one line:
>>> browser.layout("hello")
[(1, 1, 'h'), (2, 1, 'e'), (3, 1, 'l'), (4, 1, 'l'), (5, 1, 'o')]
>>> browser.layout("hello mom")
[(1, 1, 'h'), (2, 1, 'e'), (3, 1, 'l'), (4, 1, 'l'), (5, 1, 'o'), (6, 1, ' '), (7, 1, 'm'), (8, 1, 'o'), (9, 1, 'm')]
This does not though (notice that the `'s'` has a 2 in the `y` coordinate):
>>> browser.layout("hello moms")
[(1, 1, 'h'), (2, 1, 'e'), (3, 1, 'l'), (4, 1, 'l'), (5, 1, 'o'), (6, 1, ' '), (7, 1, 'm'), (8, 1, 'o'), (9, 1, 'm'), (1, 2, 's')]
Testing `Browser`
-----------------
The Browser class defines a simple web browser, with methods to load,
draw to the screen, and scroll down.
Let's first mock a URL to load:
>>> url = 'http://wbemocks.test/chapter2-example1'
>>> wbemocks.socket.respond(url=url,
... response=("HTTP/1.0 200 OK\r\n" +
... "Header1: Value1\r\n"
... "\r\n" +
... "Body text").encode())
Loading that URL results in a display list:
>>> this_browser = browser.Browser()
>>> this_browser.load(browser.URL(url))
>>> this_browser.display_list
[(1, 1, 'B'), (2, 1, 'o'), (3, 1, 'd'), (4, 1, 'y'), (5, 1, ' '), (6, 1, 't'), (7, 1, 'e'), (8, 1, 'x'), (9, 1, 't')]
Tests for WBE Chapter 2 Exercise `Emoji`
========================================
Add support for emoji to your browser. Emoji are characters, and you
can call `create_text` to draw them, but the results aren’t very good.
Instead, head to the OpenMoji project, download the emoji for
“grinning face” as a PNG file, resize it to 16x16 pixels, and save it
to the same folder as the browser. Use Tk’s `PhotoImage` class to load
the image and then the `create_image` method to draw it to the canvas.
~~In fact, download the whole OpenMoji library (look for the “Get
OpenMojis” button at the top right)--then your browser can look up
whatever emoji is used in the page.~~
You only need to handle the grinning face emoji. It is a single
character that you can refer to as `\N{GRINNING FACE}` in Python. It's
best to test the browser directly before running the tests.
This exercise has a few difficult pieces:
- Download the OpenMoji 72x72 PNG images and call the resulting folder
of emoji images `openmoji`, placed in the same directory as `browser.py`
The grinning face emoji is in `1F600.png`.
- Resize the emoji images to 16x16 manually. When you do so, overwrite
the original file so that it's still called `1F600.png`.
(Technically there are also `zoom` and `subsample` methods on
`PhotoImage` objects but they are hard to use and work poorly.)
- When you create a `PhotoImage` object, you need to make sure that it
is not garbage collected. If it is garbage collected, it won't show
up on the screen. You can do this by storing the `PhotoImage` in a
field on your `Browser` or in a global.
- Make sure to draw the emoji in the right place!
- If you have a really old Python, you might need to convert the emoji
picture to GIF for the image to show up, but your Python is probably
not that old.
For these tests, you only need to handle the "Grinning Face" emoji,
not any others (though you're free to handle others if you'd like).
Tests
-----
Testing boilerplate; hiding `create_rectangle` because of scrollbar tests:
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> _ = wbemocks.patch_canvas()
>>> import browser
>>> wbemocks.tkinter.Canvas.hide_command("create_rectangle")
We make the window small and create a test page with several grinning
faces.
>>> browser.set_parameters(WIDTH=4, HEIGHT=4, VSTEP=1, HSTEP=1, SCROLL_STEP=4)
>>> wbemocks.tkinter.Canvas.require_image_size(16, 16)
>>> url = 'http://wbemocks.test/chapter2-example6'
>>> wbemocks.socket.respond_200(url=url,
... body="Hi \N{Grinning Face} and our \N{Grinning Face}")
>>> b = browser.Browser()
>>> url = browser.URL(url)
Let's see what it looks like:
>>> b.load(url)
create_text: x=1 y=1 text=H
create_text: x=2 y=1 text=i
create_image: x=2 y=2 image=PhotoImage('openmoji/1F600.png')
create_text: x=2 y=3 text=a
create_text: x=1 y=4 text=n
create_text: x=2 y=4 text=d
Now let's scroll down and see the next smiley:
>>> b.scrolldown({})
create_text: x=2 y=-1 text=a
create_text: x=1 y=0 text=n
create_text: x=2 y=0 text=d
create_text: x=2 y=1 text=o
create_text: x=1 y=2 text=u
create_text: x=2 y=2 text=r
create_image: x=2 y=3 image=PhotoImage('openmoji/1F600.png')
Tests for WBE Chapter 2 Exercise `Line Breaks`
==============================================
Change `layout` to end the current line and start a new one
when it sees a newline character. Increment `y` by more than
`VSTEP` to give the illusion of paragraph breaks. There are
poems embedded in "Journey to the West"; now you’ll be able
to make them out.
Specifically, detect the `\n` character and add a line break.
(Side note: The difference between `\n` and `\r\n` stems from old
typewriter mechanisms. `\n` (line feed) moves down to a new line,
while `\r` (carriage return) moves the carriage to the beginning of
the line. `\r\n`, combining both, is used in some systems (like
Windows) to start a new line.)
Tests
-----
Testing boilerplate:
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> _ = wbemocks.patch_canvas()
>>> import browser
Let's override text spacing to make it easy to do math
when testing:
>>> browser.set_parameters(HSTEP=1, VSTEP=1)
Let's mock a URL to load:
>>> url = 'http://wbemocks.test/chapter2-example3'
>>> wbemocks.socket.respond_200(url=url,
... body=("u\r\n" +
... "d"))
Create a browser instance and load the url.
Even though the visible text could fit on one line it is split into two.
Note that the newline characters are not present in the output,
but instead cause the text to be moved down by twice the `VSTEP`
>>> this_browser = browser.Browser()
>>> this_browser.load(browser.URL(url))
create_text: x=1 y=1 text=u
create_text: x=1 y=3 text=d
Each additional newline moves the text down by twice `VSTEP`
>>> url = 'http://wbemocks.test/chapter2-example4'
>>> wbemocks.socket.respond_200(url=url,
... body=("u\r\n" +
... "\r\n" +
... "\r\n" +
... "d"))
>>> this_browser.load(browser.URL(url))
create_text: x=1 y=1 text=u
create_text: x=1 y=7 text=d
Make sure that `cursor_x` is reset on a line break:
>>> url = 'http://wbemocks.test/cursor-reset-test'
>>> wbemocks.socket.respond_200(url=url, body="eren\r\nmika")
>>> this_browser = browser.Browser()
>>> this_browser.load(browser.URL(url))
create_text: x=1 y=1 text=e
create_text: x=2 y=1 text=r
create_text: x=3 y=1 text=e
create_text: x=4 y=1 text=n
create_text: x=1 y=3 text=m
create_text: x=2 y=3 text=i
create_text: x=3 y=3 text=k
create_text: x=4 y=3 text=a
Tests for WBE Chapter 2 Exercise `Resizing`
==============================================
Make the browser resizable. To do so, pass the fill and expand
arguments to `canvas.pack`, call and bind to the `<Configure>` event,
which happens when the window is resized. The window’s new width and
height can be found in the width and height fields on the event
object. Remember that when the window is resized, the line breaks must
change, so you will need to call layout again.
Make sure that your `Browser` class handles resize events in a
`resize` method.
Tests
-----
Testing boilerplate:
>>> import wbemocks
>>> _ = wbemocks.socket.patch().start()
>>> _ = wbemocks.ssl.patch().start()
>>> _ = wbemocks.patch_canvas()
>>> import browser
Let's override text spacing and line width to make it easy to do math
when testing:
>>> browser.set_parameters(WIDTH=1, HSTEP=1, VSTEP=1)
Let's mock a URL to load:
>>> url = 'http://wbemocks.test/chapter2-example6'
>>> wbemocks.socket.respond_200(url=url,
... body="abcd")
Loading that URL results in the text with each letter on a new y value.
The `x` value is always one, but `y` increments, since the canvas is of width
one.
>>> this_browser = browser.Browser()
>>> this_browser.load(browser.URL(url))
create_text: x=1 y=1 text=a
create_text: x=1 y=2 text=b
create_text: x=1 y=3 text=c
create_text: x=1 y=4 text=d
Calling `resize` with a wider window should allow the text to be on one line.
Now all the characters have the same `y`, but different `x` increments.
>>> e = wbemocks.ResizeEvent(width=100, height=10)
>>> this_browser.resize(e)
create_text: x=1 y=1 text=a
create_text: x=2 y=1 text=b
create_text: x=3 y=1 text=c
create_text: x=4 y=1 text=d
Calling `resize` with a narrower window should split the text across two lines.
>>> e = wbemocks.ResizeEvent(width=4, height=10)
>>> this_browser.resize(e)
create_text: x=1 y=1 text=a
create_text: x=2 y=1 text=b
create_text: x=1 y=2 text=c
create_text: x=2 y=2 text=d